Compare commits
92 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
37d6eda1cf | |
|
|
6e54471102 | |
|
|
5ef8c35ca8 | |
|
|
61b50df864 | |
|
|
3b31c4e6cb | |
|
|
997fbb6985 | |
|
|
33e0349a1b | |
|
|
82f205bf5c | |
|
|
26783c2d41 | |
|
|
fb1c015d9a | |
|
|
29eb856a45 | |
|
|
9449a45ef9 | |
|
|
214ac28926 | |
|
|
abbd6ec9be | |
|
|
b64b7a3880 | |
|
|
d08a02b6e7 | |
|
|
0f983eb34d | |
|
|
53495a0b83 | |
|
|
327694f87d | |
|
|
c8bc9bba07 | |
|
|
91d9c916e2 | |
|
|
1b96d049c7 | |
|
|
46f3b22ec9 | |
|
|
7d8096be4c | |
|
|
2351ee51eb | |
|
|
5f4b13d7ba | |
|
|
0b1dc23cc6 | |
|
|
2274cb4a4a | |
|
|
060785a11a | |
|
|
4645093029 | |
|
|
1c1ee5df16 | |
|
|
7cb7a7d73a | |
|
|
0bc77e44f8 | |
|
|
d10065cfcc | |
|
|
d42f6eb9e8 | |
|
|
f675a4ca49 | |
|
|
9aee5f10d2 | |
|
|
add9cb471e | |
|
|
a58c73c112 | |
|
|
90d629876b | |
|
|
e34e65b337 | |
|
|
fc8e8cba5e | |
|
|
b952a76ccb | |
|
|
f1decf7448 | |
|
|
e7d5acc155 | |
|
|
dd62962de8 | |
|
|
3e9564d7b9 | |
|
|
7f7dde7b28 | |
|
|
fc6fb5003e | |
|
|
1d22ac2173 | |
|
|
7705908484 | |
|
|
372f84d1b1 | |
|
|
9c2a415886 | |
|
|
0fc292829f | |
|
|
691dea4bc8 | |
|
|
578c82f6dd | |
|
|
881ff70a1c | |
|
|
58d2d3aa17 | |
|
|
9c4851d35f | |
|
|
a81aee843f | |
|
|
778db1ce9c | |
|
|
39fef5cb2a | |
|
|
ed33936e4f | |
|
|
9d2b4c158f | |
|
|
71284b722c | |
|
|
a9111959cd | |
|
|
e4f3ddb6cd | |
|
|
d298c58e08 | |
|
|
0531e60222 | |
|
|
9cef81a3c1 | |
|
|
1e7e773cb1 | |
|
|
2440fdf61a | |
|
|
0ecc2b4a3c | |
|
|
c5c36cb078 | |
|
|
b03533dea6 | |
|
|
939de83654 | |
|
|
bf99462835 | |
|
|
afc1670ac0 | |
|
|
1b79b15a31 | |
|
|
99c9f00110 | |
|
|
6d5f73e90e | |
|
|
f875a6afc0 | |
|
|
08dc809e9d | |
|
|
b4ceaa387a | |
|
|
72ad5b3a21 | |
|
|
419eed81e6 | |
|
|
195ee714e9 | |
|
|
6d37b74706 | |
|
|
35c9a6704d | |
|
|
54275d997b | |
|
|
8c217ab321 | |
|
|
5c3500d17c |
|
|
@ -1,3 +1,5 @@
|
||||||
.idea
|
.idea
|
||||||
node_modules
|
node_modules
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
dist
|
||||||
|
.env*
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "es5"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
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;"]
|
||||||
|
|
||||||
12
README.md
12
README.md
|
|
@ -1,9 +1,19 @@
|
||||||
|
|
||||||
# React + TypeScript + Vite
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
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
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
|
|
||||||
24
index.html
24
index.html
|
|
@ -2,7 +2,7 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link href="/vite.svg" rel="icon" type="image/svg+xml" />
|
<link href="/favicon.png" rel="icon" type="image/png">
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||||
|
|
||||||
<meta
|
<meta
|
||||||
|
|
@ -11,12 +11,30 @@
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<meta content="true" name="HandheldFriendly" />
|
<meta content="true" name="HandheldFriendly" />
|
||||||
|
|
||||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
|
|
||||||
<title>MyMusic</title>
|
<title>MY</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<!-- Yandex.Metrika counter -->
|
||||||
|
<script type="text/javascript" >
|
||||||
|
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||||
|
m[i].l=1*new Date();
|
||||||
|
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
||||||
|
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
|
||||||
|
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
|
||||||
|
|
||||||
|
ym(99984890, "init", {
|
||||||
|
clickmap:true,
|
||||||
|
trackLinks:true,
|
||||||
|
accurateTrackBounce:true,
|
||||||
|
webvisor:true
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<noscript><div><img src="https://mc.yandex.ru/watch/99984890" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
||||||
|
<!-- /Yandex.Metrika counter -->
|
||||||
|
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script src="/src/entry.tsx" type="module"></script>
|
<script src="/src/entry.tsx" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,8 +9,12 @@
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
|
"@sentry/react": "^9.1.0",
|
||||||
|
"@ton/core": "^0.59.1",
|
||||||
|
"@tonconnect/ui-react": "^2.0.2",
|
||||||
"@vkruglikov/react-telegram-web-app": "^2.1.9",
|
"@vkruglikov/react-telegram-web-app": "^2.1.9",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
|
@ -18,7 +22,9 @@
|
||||||
"react-player": "^2.15.1",
|
"react-player": "^2.15.1",
|
||||||
"react-query": "^3.39.3",
|
"react-query": "^3.39.3",
|
||||||
"react-router-dom": "^6.22.2",
|
"react-router-dom": "^6.22.2",
|
||||||
|
"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"
|
||||||
},
|
},
|
||||||
|
|
@ -1108,6 +1114,27 @@
|
||||||
"url": "https://opencollective.com/unts"
|
"url": "https://opencollective.com/unts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-dnd/asap": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/@react-dnd/invariant": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/@react-dnd/shallowequal": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/@remix-run/router": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.15.2",
|
"version": "1.15.2",
|
||||||
"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",
|
||||||
|
|
@ -1285,6 +1312,217 @@
|
||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@sentry-internal/browser-utils": {
|
||||||
|
"version": "9.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.1.0.tgz",
|
||||||
|
"integrity": "sha512-S1uT+kkFlstWpwnaBTIJSwwAID8PS3aA0fIidOjNezeoUE5gOvpsjDATo9q+sl6FbGWynxMz6EnYSrq/5tuaBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry/core": "9.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry-internal/feedback": {
|
||||||
|
"version": "9.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.1.0.tgz",
|
||||||
|
"integrity": "sha512-jTDCqkqH3QDC8m9WO4mB06hqnBRsl3p7ozoh0E774UvNB6blOEZjShhSGMMEy5jbbJajPWsOivCofUtFAwbfGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry/core": "9.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry-internal/replay": {
|
||||||
|
"version": "9.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.1.0.tgz",
|
||||||
|
"integrity": "sha512-E2xrUoms90qvm0BVOuaZ8QfkMoTUEgoIW/35uOeaqNcL7uOIj8c5cSEQQKit2Dr7CL6W+Ci5c6Khdyd5C0NL5w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry-internal/browser-utils": "9.1.0",
|
||||||
|
"@sentry/core": "9.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry-internal/replay-canvas": {
|
||||||
|
"version": "9.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.1.0.tgz",
|
||||||
|
"integrity": "sha512-gxredVe+mOgfNqDJ3dTLiRON3FK1rZ8d0LHp7TICK/umLkWFkuso0DbNeyKU+3XCEjCr9VM7ZRqTDMzmY6zyVg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry-internal/replay": "9.1.0",
|
||||||
|
"@sentry/core": "9.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/browser": {
|
||||||
|
"version": "9.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.1.0.tgz",
|
||||||
|
"integrity": "sha512-G55e5j77DqRW3LkalJLAjRRfuyKrjHaKTnwIYXa6ycO+Q1+l14pEUxu+eK5Abu2rtSdViwRSb5/G6a/miSUlYA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry-internal/browser-utils": "9.1.0",
|
||||||
|
"@sentry-internal/feedback": "9.1.0",
|
||||||
|
"@sentry-internal/replay": "9.1.0",
|
||||||
|
"@sentry-internal/replay-canvas": "9.1.0",
|
||||||
|
"@sentry/core": "9.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/core": {
|
||||||
|
"version": "9.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.1.0.tgz",
|
||||||
|
"integrity": "sha512-djWEzSBpMgqdF3GQuxO+kXCUX+Mgq42G4Uah/HSUBvPDHKipMmyWlutGRoFyVPPOnCDgpHu3wCt83wbpEyVmDw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/react": {
|
||||||
|
"version": "9.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-9.1.0.tgz",
|
||||||
|
"integrity": "sha512-aP2sXHH+erbomuzU762ktg340IGDh8zD7ueuqwBwGu98fhCpTYsLXiS85I29tUvPLljwNU9puLPmxbgW4iZ2tQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry/browser": "9.1.0",
|
||||||
|
"@sentry/core": "9.1.0",
|
||||||
|
"hoist-non-react-statics": "^3.3.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.14.0 || 17.x || 18.x || 19.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ton/core": {
|
||||||
|
"version": "0.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ton/core/-/core-0.59.1.tgz",
|
||||||
|
"integrity": "sha512-SxFBAvutYJaIllTkv82vbHTJhJI6NxzqUhi499CDEjJEZ9i6i9lHJiK2df4dlLAb/4SiWX6+QUzESkK4DEdnCw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"symbol.inspect": "1.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@ton/crypto": ">=3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ton/crypto": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ton/crypto/-/crypto-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-/A6CYGgA/H36OZ9BbTaGerKtzWp50rg67ZCH2oIjV1NcrBaCK9Z343M+CxedvM7Haf3f/Ee9EhxyeTp0GKMUpA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@ton/crypto-primitives": "2.1.0",
|
||||||
|
"jssha": "3.2.0",
|
||||||
|
"tweetnacl": "1.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ton/crypto-primitives": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ton/crypto-primitives/-/crypto-primitives-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-PQesoyPgqyI6vzYtCXw4/ZzevePc4VGcJtFwf08v10OevVJHVfW238KBdpj1kEDQkxWLeuNHEpTECNFKnP6tow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"jssha": "3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ton/crypto-primitives/node_modules/jssha": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jssha/-/jssha-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-QuruyBENDWdN4tZwJbQq7/eAK85FqrI4oDbXjy5IBhYD+2pTJyBUWZe8ctWaCkrV0gy6AaelgOZZBMeswEa/6Q==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ton/crypto/node_modules/jssha": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jssha/-/jssha-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-QuruyBENDWdN4tZwJbQq7/eAK85FqrI4oDbXjy5IBhYD+2pTJyBUWZe8ctWaCkrV0gy6AaelgOZZBMeswEa/6Q==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tonconnect/isomorphic-eventsource": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tonconnect/isomorphic-eventsource/-/isomorphic-eventsource-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-B4UoIjPi0QkvIzZH5fV3BQLWrqSYABdrzZQSI9sJA9aA+iC0ohOzFwVVGXanlxeDAy1bcvPbb29f6sVUk0UnnQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"eventsource": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tonconnect/isomorphic-fetch": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tonconnect/isomorphic-fetch/-/isomorphic-fetch-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-jIg5nTrDwnite4fXao3dD83eCpTvInTjZon/rZZrIftIegh4XxyVb5G2mpMqXrVGk1e8SVXm3Kj5OtfMplQs0w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"node-fetch": "^2.6.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tonconnect/protocol": {
|
||||||
|
"version": "2.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tonconnect/protocol/-/protocol-2.2.6.tgz",
|
||||||
|
"integrity": "sha512-kyoDz5EqgsycYP+A+JbVsAUYHNT059BCrK+m0pqxykMODwpziuSAXfwAZmHcg8v7NB9VKYbdFY55xKeXOuEd0w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tweetnacl": "^1.0.3",
|
||||||
|
"tweetnacl-util": "^0.15.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tonconnect/sdk": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tonconnect/sdk/-/sdk-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-dJipe0Cw43p/7o3Pa6Y6h0QMDtY2V2YKzwdCqcYvmyCYadBNmvA+8ScH9QK5GpkngRJnYaWq+321lAaQTFpUwA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tonconnect/isomorphic-eventsource": "^0.0.2",
|
||||||
|
"@tonconnect/isomorphic-fetch": "^0.0.3",
|
||||||
|
"@tonconnect/protocol": "^2.2.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tonconnect/ui": {
|
||||||
|
"version": "2.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tonconnect/ui/-/ui-2.0.11.tgz",
|
||||||
|
"integrity": "sha512-5TOhfEDeyY8R9oyEGavLU+DRmDW3wSGyxVshWhHisi8597cZIuG39HHNbP05WJBBjmVm/Kh/bhcHtfx7lQp/Ig==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tonconnect/sdk": "3.0.6",
|
||||||
|
"classnames": "^2.3.2",
|
||||||
|
"csstype": "^3.1.1",
|
||||||
|
"deepmerge": "^4.2.2",
|
||||||
|
"ua-parser-js": "^1.0.35"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tonconnect/ui-react": {
|
||||||
|
"version": "2.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tonconnect/ui-react/-/ui-react-2.0.11.tgz",
|
||||||
|
"integrity": "sha512-h8E4zlbdNBJCAPgg6+O5ZkVDZh8mvnc82VRmhInSPLFAr6qDZbE+qSjRVm4lkuN1N/m24lhkDXFCFjvJ9CgCow==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tonconnect/ui": "2.0.11"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=17.0.0",
|
||||||
|
"react-dom": ">=17.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|
@ -1344,11 +1582,26 @@
|
||||||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/lodash": {
|
||||||
|
"version": "4.17.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz",
|
||||||
|
"integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/lodash-es": {
|
||||||
|
"version": "4.17.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||||
|
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/lodash": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.11.24",
|
"version": "20.11.24",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz",
|
||||||
"integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==",
|
"integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~5.26.4"
|
"undici-types": "~5.26.4"
|
||||||
}
|
}
|
||||||
|
|
@ -1916,6 +2169,26 @@
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"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",
|
||||||
|
|
@ -2001,6 +2274,36 @@
|
||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.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",
|
||||||
|
|
@ -2108,6 +2411,12 @@
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/classnames": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz",
|
||||||
|
|
@ -2131,6 +2440,15 @@
|
||||||
"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",
|
||||||
|
|
@ -2191,8 +2509,13 @@
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"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=="
|
||||||
"devOptional": true
|
},
|
||||||
|
"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",
|
||||||
|
|
@ -2296,6 +2619,18 @@
|
||||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/dnd-core": {
|
||||||
|
"version": "14.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz",
|
||||||
|
"integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@react-dnd/asap": "^4.0.0",
|
||||||
|
"@react-dnd/invariant": "^2.0.0",
|
||||||
|
"redux": "^4.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/doctrine": {
|
"node_modules/doctrine": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||||
|
|
@ -2957,11 +3292,19 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventsource": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/fast-diff": {
|
"node_modules/fast-diff": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
|
|
@ -3361,6 +3704,12 @@
|
||||||
"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",
|
||||||
|
|
@ -3448,6 +3797,35 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hoist-non-react-statics": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"react-is": "^16.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ieee754": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
|
||||||
|
|
@ -3711,6 +4089,18 @@
|
||||||
"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",
|
||||||
|
|
@ -3807,6 +4197,12 @@
|
||||||
"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",
|
||||||
|
|
@ -3928,12 +4324,80 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash-es": {
|
||||||
|
"version": "4.17.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||||
|
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||||
|
"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",
|
||||||
|
|
@ -4096,6 +4560,26 @@
|
||||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.14",
|
"version": "2.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
|
||||||
|
|
@ -4667,6 +5151,23 @@
|
||||||
"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",
|
||||||
|
|
@ -4681,6 +5182,12 @@
|
||||||
"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",
|
||||||
|
|
@ -4712,6 +5219,47 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-dnd": {
|
||||||
|
"version": "14.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz",
|
||||||
|
"integrity": "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@react-dnd/invariant": "^2.0.0",
|
||||||
|
"@react-dnd/shallowequal": "^2.0.0",
|
||||||
|
"dnd-core": "14.0.1",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"hoist-non-react-statics": "^3.3.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/hoist-non-react-statics": ">= 3.3.1",
|
||||||
|
"@types/node": ">= 12",
|
||||||
|
"@types/react": ">= 16",
|
||||||
|
"react": ">= 16.14"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/hoist-non-react-statics": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-dnd-html5-backend": {
|
||||||
|
"version": "14.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz",
|
||||||
|
"integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"dnd-core": "14.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.2.0",
|
"version": "18.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
||||||
|
|
@ -4828,6 +5376,33 @@
|
||||||
"react-dom": ">=16.8"
|
"react-dom": ">=16.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-tag-input": {
|
||||||
|
"version": "6.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-tag-input/-/react-tag-input-6.10.3.tgz",
|
||||||
|
"integrity": "sha512-IVneBnT0/7N8FktHsFUA2UVDvcwLSJoWDTdEm/Ei/r5O6iBABaVw1RMh6SlG+P7oummvzSY6EBjGCXr/Ntq8rQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
"classnames": "~2.3.1",
|
||||||
|
"lodash-es": "^4.17.21"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/ad1992"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dnd": "^14.0.2",
|
||||||
|
"react-dnd-html5-backend": "^14.0.0",
|
||||||
|
"react-dom": "^18.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-tag-input/node_modules/classnames": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-1inzZmicIFcmUya7PGtUQeXtcF7zZpPnxtQoYOrz0uiOBGlLFa4ik4361seYL2JCcRDIyfdFHiwQolESFlw+Og==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
|
|
@ -4849,6 +5424,16 @@
|
||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.9.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/regenerator-runtime": {
|
"node_modules/regenerator-runtime": {
|
||||||
"version": "0.14.1",
|
"version": "0.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||||
|
|
@ -4877,6 +5462,12 @@
|
||||||
"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",
|
||||||
|
|
@ -4903,6 +5494,15 @@
|
||||||
"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",
|
||||||
|
|
@ -5383,6 +5983,12 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/symbol.inspect": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/symbol.inspect/-/symbol.inspect-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-YQSL4duoHmLhsTD1Pw8RW6TZ5MaTX5rXJnqacJottr2P2LZBF/Yvrc3ku4NUpMOm8aM0KOCqM+UAkMA5HWQCzQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/synckit": {
|
"node_modules/synckit": {
|
||||||
"version": "0.8.8",
|
"version": "0.8.8",
|
||||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz",
|
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz",
|
||||||
|
|
@ -5496,6 +6102,12 @@
|
||||||
"node": ">=8.0"
|
"node": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.2.1.tgz",
|
||||||
|
|
@ -5564,6 +6176,36 @@
|
||||||
"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": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
|
"node_modules/tweetnacl-util": {
|
||||||
|
"version": "0.15.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz",
|
||||||
|
"integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|
@ -5674,6 +6316,32 @@
|
||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ua-parser-js": {
|
||||||
|
"version": "1.0.40",
|
||||||
|
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.40.tgz",
|
||||||
|
"integrity": "sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ua-parser-js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://paypal.me/faisalman"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/faisalman"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"ua-parser-js": "script/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/unbox-primitive": {
|
"node_modules/unbox-primitive": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",
|
||||||
|
|
@ -5693,7 +6361,7 @@
|
||||||
"version": "5.26.5",
|
"version": "5.26.5",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||||
"dev": true
|
"devOptional": true
|
||||||
},
|
},
|
||||||
"node_modules/unload": {
|
"node_modules/unload": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
|
|
@ -5743,6 +6411,16 @@
|
||||||
"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",
|
||||||
|
|
@ -5831,6 +6509,22 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,12 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
|
"@sentry/react": "^9.1.0",
|
||||||
|
"@ton/core": "^0.59.1",
|
||||||
|
"@tonconnect/ui-react": "^2.0.2",
|
||||||
"@vkruglikov/react-telegram-web-app": "^2.1.9",
|
"@vkruglikov/react-telegram-web-app": "^2.1.9",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
|
@ -20,7 +24,9 @@
|
||||||
"react-player": "^2.15.1",
|
"react-player": "^2.15.1",
|
||||||
"react-query": "^3.39.3",
|
"react-query": "^3.39.3",
|
||||||
"react-router-dom": "^6.22.2",
|
"react-router-dom": "^6.22.2",
|
||||||
|
"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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
|
|
@ -1,26 +1,35 @@
|
||||||
import "~/app/styles/globals.css";
|
import '~/app/styles/globals.css';
|
||||||
|
import '~/shared/libs/buffer';
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from 'react';
|
||||||
import { useExpand, useWebApp } from "@vkruglikov/react-telegram-web-app";
|
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';
|
||||||
|
|
||||||
export const App = () => {
|
export const App = () => {
|
||||||
const WebApp = useWebApp();
|
const WebApp = useWebApp();
|
||||||
const [, expand] = useExpand();
|
const [, expand] = useExpand();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
WebApp.enableClosingConfirmation();
|
if (!WebApp) {
|
||||||
expand();
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
WebApp.setHeaderColor("#1d1d1b");
|
WebApp.enableClosingConfirmation?.();
|
||||||
WebApp.setBackgroundColor("#1d1d1b");
|
if (typeof expand === 'function') {
|
||||||
}, []);
|
expand();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
WebApp.setHeaderColor?.('#1d1d1b');
|
||||||
<Providers>
|
WebApp.setBackgroundColor?.('#1d1d1b');
|
||||||
<AppRouter />
|
}, [WebApp, expand]);
|
||||||
</Providers>
|
|
||||||
);
|
return (
|
||||||
|
<Providers>
|
||||||
|
<AppRouter />
|
||||||
|
<Notification />
|
||||||
|
</Providers>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { QueryClientProvider } from "react-query";
|
import { QueryClientProvider } from "react-query";
|
||||||
import { WebAppProvider } from "@vkruglikov/react-telegram-web-app";
|
import { WebAppProvider } from "@vkruglikov/react-telegram-web-app";
|
||||||
|
import { TonConnectUIProvider } from "@tonconnect/ui-react";
|
||||||
|
|
||||||
import { queryClient } from "~/shared/libs";
|
import { queryClient } from "~/shared/libs";
|
||||||
|
|
||||||
|
|
@ -11,9 +12,15 @@ type ProvidersProps = {
|
||||||
export const Providers = ({ children }: ProvidersProps) => {
|
export const Providers = ({ children }: ProvidersProps) => {
|
||||||
return (
|
return (
|
||||||
<WebAppProvider options={{ smoothButtonsTransition: true }}>
|
<WebAppProvider options={{ smoothButtonsTransition: true }}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<TonConnectUIProvider
|
||||||
<main className="antialiased">{children}</main>
|
manifestUrl={
|
||||||
</QueryClientProvider>
|
"https://my-public-node-103.projscale.dev/api/tonconnect-manifest.json"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<main className="antialiased">{children}</main>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</TonConnectUIProvider>
|
||||||
</WebAppProvider>
|
</WebAppProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
export const Routes = {
|
export const Routes = {
|
||||||
Root: "/uploadContent",
|
Root: "/uploadContent",
|
||||||
|
ViewContent: "/viewContent",
|
||||||
|
SentryCheck: "/sentryCheck",
|
||||||
|
Admin: "/admin",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,69 @@ 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 { 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";
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{ path: Routes.Root, element: <RootPage /> },
|
{
|
||||||
|
element: <ProtectedLayout />,
|
||||||
|
children: [
|
||||||
|
{ path: Routes.Root, element: <RootPage /> },
|
||||||
|
{ path: Routes.ViewContent, element: <ViewContentPage /> },
|
||||||
|
{
|
||||||
|
path: Routes.SentryCheck,
|
||||||
|
element: (
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary border bg-red-900 p-2"
|
||||||
|
onClick={() => {
|
||||||
|
throw new Error("Sentry Test Error");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sentry Test Error
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 = () => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { useAuthTwa } from '~/shared/services/authTwa';
|
||||||
|
|
||||||
|
export const ProtectedLayout = () => {
|
||||||
|
const auth = useAuthTwa();
|
||||||
|
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void auth.mutateAsync().finally(() => {
|
||||||
|
setIsInitialLoad(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isInitialLoad || auth.isLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
};
|
||||||
|
|
@ -22,4 +22,121 @@
|
||||||
a {
|
a {
|
||||||
@apply transition-all active:opacity-60;
|
@apply transition-all active:opacity-60;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/*Input Range*/
|
||||||
|
/* Custom styles for the range input */
|
||||||
|
input[type="range"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
/* Remove default appearance */
|
||||||
|
width: 100%;
|
||||||
|
/* Full width */
|
||||||
|
height: 2px;
|
||||||
|
/* Track height */
|
||||||
|
background: linear-gradient(to right,
|
||||||
|
white 0%,
|
||||||
|
/* Start color of passed track */
|
||||||
|
white var(--value-percentage, 0%),
|
||||||
|
/* End color of passed track */
|
||||||
|
gray var(--value-percentage, 0%),
|
||||||
|
/* Start color of remaining track */
|
||||||
|
gray 100%
|
||||||
|
/* End color of remaining track */
|
||||||
|
);
|
||||||
|
border-radius: 9999px;
|
||||||
|
/* Rounded-full track */
|
||||||
|
outline: none;
|
||||||
|
/* Remove outline */
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style the thumb (toggle) */
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 9px;
|
||||||
|
/* Thumb width */
|
||||||
|
height: 9px;
|
||||||
|
/* Thumb height */
|
||||||
|
background: #fff;
|
||||||
|
/* Thumb color (blue-500) */
|
||||||
|
border-radius: 9999px;
|
||||||
|
/* Rounded-full thumb */
|
||||||
|
cursor: pointer;
|
||||||
|
/* Pointer cursor on hover */
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For Firefox */
|
||||||
|
input[type="range"]::-moz-range-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 9px;
|
||||||
|
/* Thumb width */
|
||||||
|
height: 9px;
|
||||||
|
/* Thumb height */
|
||||||
|
background: #fff;
|
||||||
|
/* Thumb color (blue-500) */
|
||||||
|
border-radius: 9999px;
|
||||||
|
/* Rounded-full thumb */
|
||||||
|
cursor: pointer;
|
||||||
|
/* Pointer cursor on hover */
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-track {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
/* Remove default appearance */
|
||||||
|
width: 100%;
|
||||||
|
/* Full width */
|
||||||
|
height: 2px;
|
||||||
|
/* Track height */
|
||||||
|
background: linear-gradient(to right,
|
||||||
|
white 0%,
|
||||||
|
/* Start color of passed track */
|
||||||
|
white var(--value-percentage, 0%),
|
||||||
|
/* End color of passed track */
|
||||||
|
gray var(--value-percentage, 0%),
|
||||||
|
/* Start color of remaining track */
|
||||||
|
gray 100%
|
||||||
|
/* End color of remaining track */
|
||||||
|
);
|
||||||
|
border-radius: 9999px;
|
||||||
|
/* Rounded-full track */
|
||||||
|
outline: none;
|
||||||
|
/* Remove outline */
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* English comment: override react-tag-input classes */
|
||||||
|
.ReactTags__tagInputField {
|
||||||
|
@apply bg-[#2B2B2B] outline-none w-full h-8 text-sm !important;
|
||||||
|
@apply border border-white px-[10px] py-[18px] !important;
|
||||||
|
@apply whitespace-pre !important;
|
||||||
|
/* English comment:
|
||||||
|
'bg-transparent' to blend with your dark background
|
||||||
|
'text-white' to have white text
|
||||||
|
'placeholder:text-gray-400' to see the placeholder text in a lighter gray
|
||||||
|
'outline-none' to remove default borders
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
/* English comment: style for the tag itself when it's rendered */
|
||||||
|
.ReactTags__selected .ReactTags__tag {
|
||||||
|
@apply bg-[#363636] text-white text-sm inline-flex items-center px-2 py-1 mb-2 rounded mr-1 !important;
|
||||||
|
/* English comment:
|
||||||
|
'bg-[#363636]' to have a dark gray background
|
||||||
|
'text-white' keeps the text white
|
||||||
|
'text-sm' smaller text
|
||||||
|
'inline-flex items-center' for better alignment
|
||||||
|
'px-2 py-1 rounded mr-1' for spacing and rounding
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
.ReactTags__selected .ReactTags__remove {
|
||||||
|
@apply ml-1 text-gray hover:text-white cursor-pointer !important;
|
||||||
|
/* English comment:
|
||||||
|
'ml-1' a small margin to separate the 'x' or close symbol
|
||||||
|
'text-gray-400' by default, and change to white on hover
|
||||||
|
'cursor-pointer' so it looks clickable
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import './shared/services/sentry/index.ts';
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_API_BASE_URL: string;
|
readonly VITE_SENTRY_DSN: string
|
||||||
|
readonly VITE_API_BASE_URL: string
|
||||||
|
readonly VITE_API_BASE_STORAGE_URL: string
|
||||||
|
readonly MODE: 'development' | 'production'
|
||||||
|
readonly PROD: boolean
|
||||||
|
readonly DEV: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export * from "./Section";
|
||||||
|
export * from "./Badge";
|
||||||
|
export * from "./PaginationControls";
|
||||||
|
export * from "./InfoRow";
|
||||||
|
export * from "./CopyButton";
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
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];
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,356 @@
|
||||||
|
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 />;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,307 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,385 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,329 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
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;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,227 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,251 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,706 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,406 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
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";
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
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>;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
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 };
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
import { WelcomeStep } from "./steps/welcome-step";
|
|
||||||
import { DataStep } from "./steps/data-step";
|
import { DataStep } from "./steps/data-step";
|
||||||
import { RoyaltyStep } from "./steps/royalty-step";
|
import { RoyaltyStep } from "./steps/royalty-step";
|
||||||
import { PresubmitStep } from "./steps/presubmit-step";
|
import { PresubmitStep } from "./steps/presubmit-step";
|
||||||
|
|
||||||
import { useSteps } from "~/shared/hooks/use-steps";
|
import { useSteps } from "~/shared/hooks/use-steps";
|
||||||
import { PriceStep } from "~/pages/root/steps/price-step";
|
import { PriceStep } from "~/pages/root/steps/price-step";
|
||||||
|
import { WelcomeStep } from "~/pages/root/steps/welcome-step";
|
||||||
|
|
||||||
export const RootPage = () => {
|
export const RootPage = () => {
|
||||||
const { ActiveSection } = useSteps(({ nextStep, prevStep }) => {
|
const { ActiveSection } = useSteps(({ nextStep, prevStep }) => {
|
||||||
return [
|
return [
|
||||||
<WelcomeStep nextStep={nextStep} />,
|
<WelcomeStep nextStep={nextStep} />,
|
||||||
<DataStep prevStep={prevStep} nextStep={nextStep} />,
|
<DataStep nextStep={nextStep} />,
|
||||||
// <AuthorsStep prevStep={prevStep} nextStep={nextStep} />,
|
// <AuthorsStep prevStep={prevStep} nextStep={nextStep} />,
|
||||||
<RoyaltyStep prevStep={prevStep} nextStep={nextStep} />,
|
<RoyaltyStep prevStep={prevStep} nextStep={nextStep} />,
|
||||||
<PriceStep prevStep={prevStep} nextStep={nextStep} />,
|
<PriceStep prevStep={prevStep} nextStep={nextStep} />,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useHapticFeedback } from "@vkruglikov/react-telegram-web-app";
|
import { useHapticFeedback } from "@vkruglikov/react-telegram-web-app";
|
||||||
|
import { Replace } from "~/shared/ui/icons/replace";
|
||||||
import { XMark } from "~/shared/ui/icons/x-mark.tsx";
|
|
||||||
|
|
||||||
type CoverButtonProps = {
|
type CoverButtonProps = {
|
||||||
src: string;
|
src: string;
|
||||||
|
|
@ -36,8 +35,8 @@ export const CoverButton = ({ src, onClick }: CoverButtonProps) => {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div />
|
<div />
|
||||||
<div className={"flex gap-2 text-sm"}>Удалить</div>
|
<div className={"flex gap-2 text-sm"}>Изменить</div>
|
||||||
<XMark />
|
<Replace />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { useHapticFeedback, useWebApp } from "@vkruglikov/react-telegram-web-app";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Button } from "~/shared/ui/button";
|
||||||
|
|
||||||
|
type DisclaimerModalProps = {
|
||||||
|
onConfirm(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DisclaimerModal = ({
|
||||||
|
onConfirm,
|
||||||
|
}: DisclaimerModalProps) => {
|
||||||
|
const [impactOccurred] = useHapticFeedback();
|
||||||
|
const WebApp = useWebApp();
|
||||||
|
useEffect(() => {
|
||||||
|
// Отключаем вертикальные свайпы при монтировании компонента
|
||||||
|
if (WebApp && WebApp.disableVerticalSwipes) {
|
||||||
|
WebApp.disableVerticalSwipes();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Включаем вертикальные свайпы обратно при размонтировании
|
||||||
|
return () => {
|
||||||
|
if (WebApp && WebApp.enableVerticalSwipes) {
|
||||||
|
WebApp.enableVerticalSwipes();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClick = (fn: () => void) => {
|
||||||
|
impactOccurred("light");
|
||||||
|
fn();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"fixed left-0 top-0 z-30 flex h-full w-full items-center justify-center bg-black/80 px-[15px]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={"flex flex-col max-h-[80vh] w-[85vw] max-w-md"}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"border border-white bg-[#1D1D1B] px-[15px] py-[16px] text-start flex flex-col gap-12 h-full overflow-y-auto"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<p className="mt-2">
|
||||||
|
Внимание!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
MY снимает с себя ответственность за правомерность загрузки контента пользователем.
|
||||||
|
</p>
|
||||||
|
<p className="flex flex-col">
|
||||||
|
<span className="tracking-[.25em] w-full">
|
||||||
|
Сервис исходит из личной
|
||||||
|
</span>
|
||||||
|
ответственности пользователя перед законом и третьими лицами. MY категорически не приемлет любые виды пиратства, но признает за Пользователем право принятия самостоятельных решений.
|
||||||
|
</p>
|
||||||
|
<p className="flex flex-col">
|
||||||
|
<span className="tracking-[.25em] w-full">
|
||||||
|
Перед загрузкой контента
|
||||||
|
</span>
|
||||||
|
необходимо убедиться, что первые 30 секунд контента, которые будут использоваться для превью, не содержат материалов, нарушающих возрастное ограничение 18+
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={"mt-[20px] sticky bottom-0"}
|
||||||
|
label={"Принять и продолжить"}
|
||||||
|
includeArrows={false}
|
||||||
|
onClick={() => handleClick(onConfirm)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
@ -12,19 +12,23 @@ import { CoverButton } from "~/pages/root/steps/data-step/components/cover-butto
|
||||||
import { HiddenFileInput } from "~/shared/ui/hidden-file-input";
|
import { HiddenFileInput } from "~/shared/ui/hidden-file-input";
|
||||||
import { useRootStore } from "~/shared/stores/root";
|
import { useRootStore } from "~/shared/stores/root";
|
||||||
import { Checkbox } from "~/shared/ui/checkbox";
|
import { Checkbox } from "~/shared/ui/checkbox";
|
||||||
import { BackButton } from "~/shared/ui/back-button";
|
import { AudioPlayer } from "~/shared/ui/audio-player";
|
||||||
|
import { HashtagInput } from "~/shared/ui/hashtag-input";
|
||||||
|
import { Replace } from "~/shared/ui/icons/replace";
|
||||||
|
import { DisclaimerModal } from "./components/disclaimer-modal";
|
||||||
|
|
||||||
type DataStepProps = {
|
type DataStepProps = {
|
||||||
prevStep(): void;
|
|
||||||
nextStep(): void;
|
nextStep(): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DataStep = ({ nextStep, prevStep }: DataStepProps) => {
|
export const DataStep = ({ nextStep }: DataStepProps) => {
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
|
const [disclaimerAccepted, setDisclaimerAccepted] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
const formSchema = useMemo(() => {
|
const formSchema = useMemo(() => {
|
||||||
return z.object({
|
return z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1, "Обязательное поле"),
|
||||||
author: z.string().optional(),
|
author: z.string().optional(),
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -57,9 +61,33 @@ export const DataStep = ({ nextStep, prevStep }: DataStepProps) => {
|
||||||
})();
|
})();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFileReset = () => {
|
||||||
|
rootStore.setFile(null);
|
||||||
|
rootStore.setFileSrc('');
|
||||||
|
rootStore.setFileType('');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedValue = localStorage.getItem('disclaimerAccepted');
|
||||||
|
if (storedValue === 'true') {
|
||||||
|
setDisclaimerAccepted(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConfirmDisclaimer = () => {
|
||||||
|
setDisclaimerAccepted(true);
|
||||||
|
localStorage.setItem('disclaimerAccepted', 'true');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={"mt-4 px-4 pb-8"}>
|
<section className={"mt-4 px-4 pb-8"}>
|
||||||
<BackButton onClick={prevStep} />
|
|
||||||
|
{(rootStore.fileSrc && !disclaimerAccepted) &&
|
||||||
|
(<DisclaimerModal
|
||||||
|
onConfirm={() => {
|
||||||
|
handleConfirmDisclaimer()}}
|
||||||
|
/>)}
|
||||||
|
|
||||||
<div className={"mb-[30px] flex flex-col text-sm"}>
|
<div className={"mb-[30px] flex flex-col text-sm"}>
|
||||||
<span className={"ml-4"}>/Заполните информацию о контенте</span>
|
<span className={"ml-4"}>/Заполните информацию о контенте</span>
|
||||||
|
|
@ -72,6 +100,7 @@ export const DataStep = ({ nextStep, prevStep }: DataStepProps) => {
|
||||||
<FormLabel label={"Название"}>
|
<FormLabel label={"Название"}>
|
||||||
<Input
|
<Input
|
||||||
placeholder={"[ Введите название ]"}
|
placeholder={"[ Введите название ]"}
|
||||||
|
error={form.formState.errors?.name}
|
||||||
{...form.register("name")}
|
{...form.register("name")}
|
||||||
/>
|
/>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
@ -79,22 +108,30 @@ export const DataStep = ({ nextStep, prevStep }: DataStepProps) => {
|
||||||
<FormLabel label={"Имя автора/исполнителя"}>
|
<FormLabel label={"Имя автора/исполнителя"}>
|
||||||
<Input
|
<Input
|
||||||
placeholder={"[ введите имя автора/исполнителя ]"}
|
placeholder={"[ введите имя автора/исполнителя ]"}
|
||||||
|
error={form.formState.errors?.author}
|
||||||
{...form.register("author")}
|
{...form.register("author")}
|
||||||
/>
|
/>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormLabel label={"Файл"}>
|
<FormLabel label={"Хэштеги"}>
|
||||||
<HiddenFileInput
|
<HashtagInput />
|
||||||
id={"file"}
|
</FormLabel>
|
||||||
shouldProcess={false}
|
|
||||||
accept={"video/mp4,video/x-m4v,video/*,audio/mp3,audio/*"}
|
|
||||||
onChange={(file) => {
|
|
||||||
rootStore.setFile(file);
|
|
||||||
rootStore.setFileSrc(URL.createObjectURL(file));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!rootStore.fileSrc && <FileButton htmlFor={"file"} />}
|
<FormLabel label={"Файл"}>
|
||||||
|
{!rootStore.fileSrc && <>
|
||||||
|
<HiddenFileInput
|
||||||
|
id={"file"}
|
||||||
|
shouldProcess={false}
|
||||||
|
accept={"video/mp4,video/x-m4v,video/*,audio/mp3,audio/*"}
|
||||||
|
onChange={(file) => {
|
||||||
|
rootStore.setFile(file);
|
||||||
|
rootStore.setFileSrc(URL.createObjectURL(file));
|
||||||
|
rootStore.setFileType(file.type); // Save the file type for conditional rendering
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FileButton htmlFor={"file"} />
|
||||||
|
</>}
|
||||||
|
|
||||||
{rootStore.fileSrc && (
|
{rootStore.fileSrc && (
|
||||||
<div
|
<div
|
||||||
|
|
@ -102,17 +139,42 @@ export const DataStep = ({ nextStep, prevStep }: DataStepProps) => {
|
||||||
"w-full border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm"
|
"w-full border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ReactPlayer
|
{rootStore.fileType?.startsWith("audio") ? (
|
||||||
playsinline={true}
|
<AudioPlayer src={rootStore.fileSrc} />
|
||||||
controls={true}
|
) : (
|
||||||
width={"100%"}
|
<ReactPlayer
|
||||||
url={rootStore.fileSrc}
|
playsinline={true}
|
||||||
/>
|
controls={true}
|
||||||
|
width="100%"
|
||||||
|
config={{ file: { attributes: { playsInline: true } } }}
|
||||||
|
url={rootStore.fileSrc}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleFileReset}
|
||||||
|
className={
|
||||||
|
"flex w-full items-center justify-between gap-1 border border-white px-[10px] py-[8px]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
<div className={"flex gap-2 text-sm"}>Изменить выбор</div>
|
||||||
|
<Replace />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<div className={"flex flex-col gap-2"}>
|
<div className={"flex flex-col gap-2"}>
|
||||||
|
<FormLabel
|
||||||
|
label={"Разрешить скачивание"}
|
||||||
|
labelClassName={"flex"}
|
||||||
|
formLabelAddon={
|
||||||
|
<Checkbox
|
||||||
|
onClick={() => rootStore.setAllowDwnld(!rootStore.allowDwnld)}
|
||||||
|
checked={rootStore.allowDwnld}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<FormLabel
|
<FormLabel
|
||||||
label={"Разрешить обложку"}
|
label={"Разрешить обложку"}
|
||||||
labelClassName={"flex"}
|
labelClassName={"flex"}
|
||||||
|
|
@ -126,13 +188,7 @@ export const DataStep = ({ nextStep, prevStep }: DataStepProps) => {
|
||||||
|
|
||||||
{rootStore.allowCover && (
|
{rootStore.allowCover && (
|
||||||
<FormLabel label={"Обложка"}>
|
<FormLabel label={"Обложка"}>
|
||||||
<HiddenFileInput
|
|
||||||
id={"cover"}
|
|
||||||
accept={"image/*"}
|
|
||||||
onChange={(cover) => {
|
|
||||||
rootStore.setCover(cover);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{rootStore.cover ? (
|
{rootStore.cover ? (
|
||||||
<CoverButton
|
<CoverButton
|
||||||
|
|
@ -142,7 +198,16 @@ export const DataStep = ({ nextStep, prevStep }: DataStepProps) => {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FileButton htmlFor={"cover"} />
|
<>
|
||||||
|
<HiddenFileInput
|
||||||
|
id={"cover"}
|
||||||
|
accept={"image/*"}
|
||||||
|
onChange={(cover) => {
|
||||||
|
rootStore.setCover(cover);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FileButton htmlFor={"cover"} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { useHapticFeedback, useWebApp } from "@vkruglikov/react-telegram-web-app";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Button } from "~/shared/ui/button";
|
||||||
|
|
||||||
|
type ErrorUploadProps = {
|
||||||
|
onConfirm(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ErrorUploadModal = ({
|
||||||
|
onConfirm,
|
||||||
|
}: ErrorUploadProps) => {
|
||||||
|
const [impactOccurred] = useHapticFeedback();
|
||||||
|
const WebApp = useWebApp();
|
||||||
|
useEffect(() => {
|
||||||
|
// Отключаем вертикальные свайпы при монтировании компонента
|
||||||
|
if (WebApp && WebApp.disableVerticalSwipes) {
|
||||||
|
WebApp.disableVerticalSwipes();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Включаем вертикальные свайпы обратно при размонтировании
|
||||||
|
return () => {
|
||||||
|
if (WebApp && WebApp.enableVerticalSwipes) {
|
||||||
|
WebApp.enableVerticalSwipes();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClick = (fn: () => void) => {
|
||||||
|
impactOccurred("light");
|
||||||
|
fn();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"fixed left-0 top-0 z-30 flex h-full w-full items-center justify-center bg-black/80 px-[15px]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={"flex flex-col max-h-[80vh] w-[85vw] max-w-md"}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"border border-white bg-[#1D1D1B] px-[15px] py-[16px] text-start flex flex-col gap-12 h-full overflow-y-auto"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<p className="mt-2">
|
||||||
|
Внимание!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Произошла ошибка при загрузке видео.
|
||||||
|
</p>
|
||||||
|
<p className="flex flex-col">
|
||||||
|
<span className="tracking-[.25em] w-full">
|
||||||
|
Загрузка не завершена
|
||||||
|
</span>
|
||||||
|
из-за технических проблем или превышения допустимых ограничений. Вы можете попробовать загрузить файл ещё раз или выбрать другой файл.
|
||||||
|
</p>
|
||||||
|
<p className="flex flex-col">
|
||||||
|
<span className="tracking-[.25em] w-full">
|
||||||
|
Если проблема повторяется
|
||||||
|
</span>
|
||||||
|
обратитесь в техническую поддержку сервиса MY, предоставив подробную информацию о загружаемом файле и возникшей ошибке.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={"mt-[20px] sticky bottom-0"}
|
||||||
|
label={"Понятно"}
|
||||||
|
includeArrows={false}
|
||||||
|
onClick={() => handleClick(onConfirm)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -2,16 +2,20 @@ import {
|
||||||
useHapticFeedback,
|
useHapticFeedback,
|
||||||
useWebApp,
|
useWebApp,
|
||||||
} from "@vkruglikov/react-telegram-web-app";
|
} from "@vkruglikov/react-telegram-web-app";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ReactPlayer from "react-player/lazy";
|
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 { useUploadFile } from "~/shared/services/file";
|
import { useLegacyUploadFile, useTusUpload } 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";
|
||||||
|
import { useTonConnectUI } from "@tonconnect/ui-react";
|
||||||
|
import { ErrorUploadModal } from "./components/error-upload-modal";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
type PresubmitStepProps = {
|
type PresubmitStepProps = {
|
||||||
prevStep(): void;
|
prevStep(): void;
|
||||||
|
|
@ -20,46 +24,119 @@ type PresubmitStepProps = {
|
||||||
export const PresubmitStep = ({ prevStep }: PresubmitStepProps) => {
|
export const PresubmitStep = ({ prevStep }: PresubmitStepProps) => {
|
||||||
const WebApp = useWebApp();
|
const WebApp = useWebApp();
|
||||||
|
|
||||||
|
const [tonConnectUI] = useTonConnectUI();
|
||||||
|
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
const [impactOccurred] = useHapticFeedback();
|
const [impactOccurred] = useHapticFeedback();
|
||||||
|
|
||||||
const [isCoverExpanded, setCoverExpanded] = useState(false);
|
const [isCoverExpanded, setCoverExpanded] = useState(false);
|
||||||
|
|
||||||
const uploadCover = useUploadFile();
|
const uploadCover = useLegacyUploadFile();
|
||||||
const uploadFile = useUploadFile();
|
const uploadFile = useTusUpload();
|
||||||
|
|
||||||
const createContent = useCreateNewContent();
|
const createContent = useCreateNewContent();
|
||||||
|
|
||||||
|
const [isErrorUploadModal, setIsErrorUploadModal] = useState(false);
|
||||||
|
|
||||||
|
const handleErrorUploadModal = () => {
|
||||||
|
setIsErrorUploadModal(false);
|
||||||
|
uploadFile.resetUploadError();
|
||||||
|
uploadCover.resetUploadError();
|
||||||
|
console.info("[App2Client][Presubmit] Пользователь закрыл модалку ошибок загрузки");
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (uploadFile.uploadError || uploadCover.uploadError) {
|
||||||
|
console.error("[App2Client][Presubmit] Обнаружены ошибки загрузки", {
|
||||||
|
fileError: uploadFile.uploadError,
|
||||||
|
coverError: uploadCover.uploadError,
|
||||||
|
});
|
||||||
|
setIsErrorUploadModal(true);
|
||||||
|
}
|
||||||
|
}, [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: "" };
|
||||||
|
|
||||||
const fileUploadResult = await uploadFile.mutateAsync(
|
console.info("[App2Client][Presubmit] Загружаем основной файл через tus");
|
||||||
rootStore.file as File,
|
const fileUploadResult = await uploadFile.mutateAsync({
|
||||||
);
|
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 (rootStore.allowCover && rootStore.cover) {
|
if (!fileUploadResult.encryptedCid) {
|
||||||
coverUploadResult = await uploadCover.mutateAsync(
|
throw new Error("Tus upload did not return encrypted cid");
|
||||||
rootStore.cover as File,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await createContent.mutateAsync({
|
console.info("[App2Client][Presubmit] Основной файл загружен", {
|
||||||
|
encryptedCid: fileUploadResult.encryptedCid,
|
||||||
|
uploadId: fileUploadResult.uploadId,
|
||||||
|
state: fileUploadResult.state,
|
||||||
|
sizeBytes: fileUploadResult.sizeBytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
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({
|
||||||
title: rootStore.name,
|
title: rootStore.name,
|
||||||
|
|
||||||
// Это для одного автора
|
// Это для одного автора
|
||||||
...(rootStore.author
|
...(rootStore.author
|
||||||
? { authors: [rootStore.author] }
|
? { authors: [rootStore.author] }
|
||||||
: { authors: [] }),
|
: { authors: [] }),
|
||||||
|
|
||||||
// Откомментировать при условии того что вы принимаете много авторов
|
// Откомментировать при условии того что вы принимаете много авторов
|
||||||
// следует отметить что вы должны еще откомментровать AuthorsStep в RootPage
|
// следует отметить что вы должны еще откомментровать AuthorsStep в RootPage
|
||||||
// authors: rootStore.authors,
|
// authors: rootStore.authors,
|
||||||
|
downloadable: rootStore.allowDwnld,
|
||||||
content: fileUploadResult.content_id_v1,
|
content: fileUploadResult.encryptedCid,
|
||||||
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,
|
||||||
royaltyParams: rootStore.royalty.map((member) => ({
|
royaltyParams: rootStore.royalty.map((member) => ({
|
||||||
...member,
|
...member,
|
||||||
value: member.value * 100,
|
value: member.value * 100,
|
||||||
|
|
@ -69,234 +146,300 @@ export const PresubmitStep = ({ prevStep }: PresubmitStepProps) => {
|
||||||
// Если откомментировать поле resaleLicensePrice в price-step то
|
// Если откомментировать поле resaleLicensePrice в price-step то
|
||||||
// это отработает как надо
|
// это отработает как надо
|
||||||
...(rootStore.allowResale
|
...(rootStore.allowResale
|
||||||
? {
|
? {
|
||||||
allowResale: true,
|
allowResale: true,
|
||||||
resaleLicensePrice: String(
|
resaleLicensePrice: String(
|
||||||
rootStore.licenseResalePrice * 10 ** 9,
|
rootStore.licenseResalePrice * 10 ** 9,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: { allowResale: false, resaleLicensePrice: "0" }),
|
: { allowResale: false, resaleLicensePrice: "0" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (createContentResponse.data) {
|
||||||
|
if (createContentResponse.data.address != "free") {
|
||||||
|
console.info("[App2Client][Presubmit] Отправляем транзакцию через TonConnect", {
|
||||||
|
address: createContentResponse.data.address,
|
||||||
|
amount: createContentResponse.data.amount,
|
||||||
|
});
|
||||||
|
const transactionResponse = await tonConnectUI.sendTransaction({
|
||||||
|
validUntil: Math.floor(Date.now() / 1000) + 120,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
amount: createContentResponse.data.amount,
|
||||||
|
address: createContentResponse.data.address,
|
||||||
|
payload: createContentResponse.data.payload,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (transactionResponse.boc) {
|
||||||
|
console.info("[App2Client][Presubmit] Транзакция успешно отправлена", {
|
||||||
|
bocLength: transactionResponse.boc.length,
|
||||||
|
});
|
||||||
|
WebApp.close();
|
||||||
|
} else {
|
||||||
|
console.error("Transaction failed:", transactionResponse);
|
||||||
|
console.error("[App2Client][Presubmit] Транзакция не отправлена", {
|
||||||
|
response: transactionResponse,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info("[App2Client][Presubmit] Завершаем процесс и закрываем WebApp");
|
||||||
WebApp.close();
|
WebApp.close();
|
||||||
} catch (error) {
|
// @ts-expect-error Type issues
|
||||||
|
} catch (error: never) {
|
||||||
|
|
||||||
|
|
||||||
console.error("An error occurred during the submission process:", error);
|
console.error("An error occurred during the submission process:", error);
|
||||||
alert(`Возникла ошибка, ${JSON.stringify(error)}`);
|
console.error("[App2Client][Presubmit] Ошибка во время отправки", {
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error?.status === 400) {
|
||||||
|
alert(
|
||||||
|
"Введенные данные неверные, проверьте правильность введенных данных.",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={"mt-4 px-4 pb-8"}>
|
<section className={"mt-4 px-4 pb-8"}>
|
||||||
<BackButton onClick={prevStep} />
|
{isErrorUploadModal && (
|
||||||
|
<ErrorUploadModal
|
||||||
|
onConfirm={() => {
|
||||||
|
handleErrorUploadModal()}}
|
||||||
|
/>)}
|
||||||
|
<BackButton onClick={prevStep} />
|
||||||
|
|
||||||
<div className={"mb-[30px] flex flex-col text-sm"}>
|
<div className={"mb-[30px] flex flex-col text-sm"}>
|
||||||
<span className={"ml-4"}>/Подтвердите правильность данных</span>
|
<span className={"ml-4"}>/Подтвердите правильность данных</span>
|
||||||
<div>
|
<div>
|
||||||
5/<span className={"text-[#7B7B7B]"}>5</span>
|
5/<span className={"text-[#7B7B7B]"}>5</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className={"flex flex-col gap-2"}>
|
<section className={"flex flex-col gap-2"}>
|
||||||
<FormLabel label={"Название"}>
|
<FormLabel label={"Название"}>
|
||||||
<div
|
|
||||||
className={
|
|
||||||
"w-full border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm font-bold"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{rootStore.name}
|
|
||||||
</div>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
{rootStore.author && (
|
|
||||||
<FormLabel label={"Имя автора/исполнителя"}>
|
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"w-full border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm font-bold"
|
"w-full border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm font-bold"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{rootStore.author}
|
{rootStore.name}
|
||||||
</div>
|
</div>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
)}
|
|
||||||
|
|
||||||
<FormLabel label={"Цена"}>
|
{rootStore.author && (
|
||||||
<div
|
<FormLabel label={"Имя автора/исполнителя"}>
|
||||||
className={
|
<div
|
||||||
"w-full border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm font-bold"
|
className={
|
||||||
}
|
"w-full border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm font-bold"
|
||||||
>
|
}
|
||||||
{rootStore.price} TON
|
>
|
||||||
</div>
|
{rootStore.author}
|
||||||
</FormLabel>
|
</div>
|
||||||
|
</FormLabel>
|
||||||
{rootStore.allowResale && (
|
|
||||||
<FormLabel label={"Цена копии"}>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
"w-full border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm font-bold"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{rootStore.licenseResalePrice} TON
|
|
||||||
</div>
|
|
||||||
</FormLabel>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormLabel label={"Файл"}>
|
|
||||||
{rootStore.fileSrc && !uploadFile.isUploading && (
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
"w-full border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ReactPlayer
|
|
||||||
playsinline={true}
|
|
||||||
controls={true}
|
|
||||||
width={"100%"}
|
|
||||||
url={rootStore.fileSrc}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{uploadFile.isUploading && uploadFile.isLoading && (
|
{rootStore.author && (
|
||||||
<Progress value={uploadFile.uploadProgress} />
|
<FormLabel label={"Теги"}>
|
||||||
)}
|
<div
|
||||||
</FormLabel>
|
className="flex flex-wrap gap-1">
|
||||||
|
{rootStore.hashtags.map((tag, index) => (
|
||||||
{rootStore.allowCover && (
|
<div
|
||||||
<FormLabel label={"Обложка"}>
|
key={index}
|
||||||
<div
|
className={
|
||||||
className={
|
"bg-[#363636] text-white text-sm inline-flex items-center px-2 py-1 rounded mr-1"
|
||||||
"flex w-full items-center border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm font-bold"
|
}
|
||||||
}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: isCoverExpanded ? "261px" : "68px",
|
|
||||||
}}
|
|
||||||
className={"bg-bg w-full"}
|
|
||||||
>
|
>
|
||||||
{rootStore.cover && !uploadCover.isUploading && (
|
{tag}
|
||||||
<img
|
|
||||||
src={URL.createObjectURL(rootStore.cover)}
|
|
||||||
alt={"cover"}
|
|
||||||
className={"h-full w-full object-cover object-center"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{uploadCover.isUploading && uploadCover.isLoading && (
|
|
||||||
<Progress value={uploadCover.uploadProgress} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FormLabel>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<FormLabel label={"Цена"}>
|
||||||
onClick={() => {
|
<div
|
||||||
impactOccurred("light");
|
className={
|
||||||
setCoverExpanded((p) => !p);
|
"w-full border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm font-bold"
|
||||||
}}
|
}
|
||||||
style={{
|
>
|
||||||
height: isCoverExpanded ? "261px" : "68px",
|
{rootStore.price} TON
|
||||||
}}
|
|
||||||
className={"flex w-[45px] items-center justify-center"}
|
|
||||||
>
|
|
||||||
{!isCoverExpanded && (
|
|
||||||
<svg
|
|
||||||
width="45"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 45 14"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M31.2963 5.18519H29.2222V3.11111H28.1852V5.18519H26.1111V6.22222H28.1852V8.2963H29.2222V6.22222H31.2963V5.18519Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M33.0841 9.33333C33.9396 8.31744 34.4083 7.0318 34.4074 5.7037C34.4074 4.57562 34.0729 3.47287 33.4462 2.5349C32.8194 1.59693 31.9286 0.865871 30.8864 0.434171C29.8442 0.00247134 28.6974 -0.110481 27.591 0.109598C26.4846 0.329676 25.4683 0.872901 24.6706 1.67058C23.8729 2.46826 23.3297 3.48456 23.1096 4.59097C22.8895 5.69738 23.0025 6.8442 23.4342 7.88642C23.8659 8.92863 24.5969 9.81943 25.5349 10.4462C26.4729 11.0729 27.5756 11.4074 28.7037 11.4074C30.0318 11.4083 31.3174 10.9396 32.3333 10.0841L36.2668 14L37 13.2668L33.0841 9.33333ZM28.7037 10.3704C27.7807 10.3704 26.8785 10.0967 26.111 9.5839C25.3436 9.07112 24.7455 8.34228 24.3923 7.48956C24.0391 6.63684 23.9466 5.69853 24.1267 4.79328C24.3068 3.88804 24.7512 3.05652 25.4039 2.40387C26.0565 1.75123 26.888 1.30677 27.7933 1.12671C28.6985 0.946644 29.6368 1.03906 30.4896 1.39227C31.3423 1.74548 32.0711 2.34362 32.5839 3.11104C33.0967 3.87847 33.3704 4.78073 33.3704 5.7037C33.369 6.94096 32.8769 8.12715 32.002 9.00202C31.1271 9.87689 29.941 10.369 28.7037 10.3704Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isCoverExpanded && (
|
|
||||||
<svg
|
|
||||||
width="45"
|
|
||||||
height="15"
|
|
||||||
viewBox="0 0 45 15"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M31.2963 5.68519H26.1111V6.72222L31.2963 6.72222L31.2963 5.68519Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M33.0841 9.83333C33.9396 8.81744 34.4083 7.5318 34.4074 6.20371C34.4074 5.07562 34.0729 3.97287 33.4462 3.0349C32.8194 2.09693 31.9286 1.36587 30.8864 0.934171C29.8442 0.502471 28.6974 0.389519 27.591 0.609598C26.4846 0.829676 25.4683 1.3729 24.6706 2.17058C23.8729 2.96826 23.3297 3.98456 23.1096 5.09097C22.8895 6.19738 23.0025 7.3442 23.4342 8.38642C23.8659 9.42863 24.5969 10.3194 25.5349 10.9462C26.4729 11.5729 27.5756 11.9074 28.7037 11.9074C30.0318 11.9083 31.3174 11.4396 32.3333 10.5841L36.2668 14.5L37 13.7668L33.0841 9.83333ZM28.7037 10.8704C27.7807 10.8704 26.8785 10.5967 26.111 10.0839C25.3436 9.57112 24.7455 8.84228 24.3923 7.98956C24.0391 7.13684 23.9466 6.19853 24.1267 5.29328C24.3068 4.38804 24.7512 3.55652 25.4039 2.90387C26.0565 2.25123 26.888 1.80677 27.7933 1.62671C28.6985 1.44664 29.6368 1.53906 30.4896 1.89227C31.3423 2.24548 32.0711 2.84362 32.5839 3.61104C33.0967 4.37847 33.3704 5.28073 33.3704 6.20371C33.369 7.44096 32.8769 8.62715 32.002 9.50202C31.1271 10.3769 29.941 10.869 28.7037 10.8704Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
)}
|
|
||||||
|
|
||||||
{rootStore.royalty.map((royalty, index) => (
|
{rootStore.allowResale && (
|
||||||
<div key={index} className={"flex flex-col gap-[20px]"}>
|
<FormLabel label={"Цена копии"}>
|
||||||
<div className={"flex w-full items-center gap-1"}>
|
<div
|
||||||
<div className={"w-[83%]"}>
|
className={
|
||||||
<FormLabel
|
"w-full border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm font-bold"
|
||||||
labelClassName={"flex"}
|
}
|
||||||
label={`Роялти_${index + 1}`}
|
>
|
||||||
|
{rootStore.licenseResalePrice} TON
|
||||||
|
</div>
|
||||||
|
</FormLabel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormLabel label={"Файл"}>
|
||||||
|
{rootStore.fileSrc && !uploadFile.isUploading && (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"w-full border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ReactPlayer
|
||||||
|
playsinline={true}
|
||||||
|
controls={true}
|
||||||
|
width="100%"
|
||||||
|
config={{ file: { attributes: { playsInline: true } } }}
|
||||||
|
url={rootStore.fileSrc}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadFile.isUploading && uploadFile.isLoading && (
|
||||||
|
<Progress value={uploadFile.uploadProgress} />
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
{rootStore.allowCover && (
|
||||||
|
<FormLabel label={"Обложка"}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"flex w-full items-center border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm font-bold"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={
|
style={{
|
||||||
"break-all border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm font-bold"
|
height: isCoverExpanded ? "261px" : "68px",
|
||||||
}
|
}}
|
||||||
|
className={"bg-bg w-full"}
|
||||||
>
|
>
|
||||||
{royalty.address}
|
{rootStore.cover && !uploadCover.isUploading && (
|
||||||
</div>
|
<img
|
||||||
</FormLabel>
|
src={URL.createObjectURL(rootStore.cover)}
|
||||||
</div>
|
alt={"cover"}
|
||||||
|
className={"h-full w-full object-cover object-center"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={"h-auto w-[18%]"}>
|
{uploadCover.isUploading && uploadCover.isLoading && (
|
||||||
<FormLabel labelClassName={"text-center"} label={"%"}>
|
<Progress value={uploadCover.uploadProgress} />
|
||||||
<div
|
)}
|
||||||
className={
|
</div>
|
||||||
"flex items-center justify-center border border-white bg-[#2B2B2B] py-[8px] text-sm font-bold"
|
|
||||||
}
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
impactOccurred("light");
|
||||||
|
setCoverExpanded((p) => !p);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
height: isCoverExpanded ? "261px" : "68px",
|
||||||
|
}}
|
||||||
|
className={"flex w-[45px] items-center justify-center"}
|
||||||
>
|
>
|
||||||
{royalty.value.toString()}
|
{!isCoverExpanded && (
|
||||||
</div>
|
<svg
|
||||||
</FormLabel>
|
width="45"
|
||||||
</div>
|
height="14"
|
||||||
</div>
|
viewBox="0 0 45 14"
|
||||||
</div>
|
fill="none"
|
||||||
))}
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M31.2963 5.18519H29.2222V3.11111H28.1852V5.18519H26.1111V6.22222H28.1852V8.2963H29.2222V6.22222H31.2963V5.18519Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M33.0841 9.33333C33.9396 8.31744 34.4083 7.0318 34.4074 5.7037C34.4074 4.57562 34.0729 3.47287 33.4462 2.5349C32.8194 1.59693 31.9286 0.865871 30.8864 0.434171C29.8442 0.00247134 28.6974 -0.110481 27.591 0.109598C26.4846 0.329676 25.4683 0.872901 24.6706 1.67058C23.8729 2.46826 23.3297 3.48456 23.1096 4.59097C22.8895 5.69738 23.0025 6.8442 23.4342 7.88642C23.8659 8.92863 24.5969 9.81943 25.5349 10.4462C26.4729 11.0729 27.5756 11.4074 28.7037 11.4074C30.0318 11.4083 31.3174 10.9396 32.3333 10.0841L36.2668 14L37 13.2668L33.0841 9.33333ZM28.7037 10.3704C27.7807 10.3704 26.8785 10.0967 26.111 9.5839C25.3436 9.07112 24.7455 8.34228 24.3923 7.48956C24.0391 6.63684 23.9466 5.69853 24.1267 4.79328C24.3068 3.88804 24.7512 3.05652 25.4039 2.40387C26.0565 1.75123 26.888 1.30677 27.7933 1.12671C28.6985 0.946644 29.6368 1.03906 30.4896 1.39227C31.3423 1.74548 32.0711 2.34362 32.5839 3.11104C33.0967 3.87847 33.3704 4.78073 33.3704 5.7037C33.369 6.94096 32.8769 8.12715 32.002 9.00202C31.1271 9.87689 29.941 10.369 28.7037 10.3704Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
{/*{rootStore.authors.map((author, index) => (*/}
|
{isCoverExpanded && (
|
||||||
{/* <FormLabel key={index} label={`Автор_${index + 1}`}>*/}
|
<svg
|
||||||
{/* <div*/}
|
width="45"
|
||||||
{/* className={*/}
|
height="15"
|
||||||
{/* "break-all border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm font-bold"*/}
|
viewBox="0 0 45 15"
|
||||||
{/* }*/}
|
fill="none"
|
||||||
{/* >*/}
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
{/* {author}*/}
|
>
|
||||||
{/* </div>*/}
|
<path
|
||||||
{/* </FormLabel>*/}
|
d="M31.2963 5.68519H26.1111V6.72222L31.2963 6.72222L31.2963 5.68519Z"
|
||||||
{/*))}*/}
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M33.0841 9.83333C33.9396 8.81744 34.4083 7.5318 34.4074 6.20371C34.4074 5.07562 34.0729 3.97287 33.4462 3.0349C32.8194 2.09693 31.9286 1.36587 30.8864 0.934171C29.8442 0.502471 28.6974 0.389519 27.591 0.609598C26.4846 0.829676 25.4683 1.3729 24.6706 2.17058C23.8729 2.96826 23.3297 3.98456 23.1096 5.09097C22.8895 6.19738 23.0025 7.3442 23.4342 8.38642C23.8659 9.42863 24.5969 10.3194 25.5349 10.9462C26.4729 11.5729 27.5756 11.9074 28.7037 11.9074C30.0318 11.9083 31.3174 11.4396 32.3333 10.5841L36.2668 14.5L37 13.7668L33.0841 9.83333ZM28.7037 10.8704C27.7807 10.8704 26.8785 10.5967 26.111 10.0839C25.3436 9.57112 24.7455 8.84228 24.3923 7.98956C24.0391 7.13684 23.9466 6.19853 24.1267 5.29328C24.3068 4.38804 24.7512 3.55652 25.4039 2.90387C26.0565 2.25123 26.888 1.80677 27.7933 1.62671C28.6985 1.44664 29.6368 1.53906 30.4896 1.89227C31.3423 2.24548 32.0711 2.84362 32.5839 3.61104C33.0967 4.37847 33.3704 5.28073 33.3704 6.20371C33.369 7.44096 32.8769 8.62715 32.002 9.50202C31.1271 10.3769 29.941 10.869 28.7037 10.8704Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</FormLabel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rootStore.royalty.map((royalty, index) => (
|
||||||
|
<div key={index} className={"flex flex-col gap-[20px]"}>
|
||||||
|
<div className={"flex w-full items-center gap-1"}>
|
||||||
|
<div className={"w-[83%]"}>
|
||||||
|
<FormLabel
|
||||||
|
labelClassName={"flex"}
|
||||||
|
label={`Роялти_${index + 1}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"break-all border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm font-bold"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{royalty.address}
|
||||||
|
</div>
|
||||||
|
</FormLabel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={"h-auto w-[18%]"}>
|
||||||
|
<FormLabel labelClassName={"text-center"} label={"%"}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"flex items-center justify-center border border-white bg-[#2B2B2B] py-[8px] text-sm font-bold"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{royalty.value.toString()}
|
||||||
|
</div>
|
||||||
|
</FormLabel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/*{rootStore.authors.map((author, index) => (*/}
|
||||||
|
{/* <FormLabel key={index} label={`Автор_${index + 1}`}>*/}
|
||||||
|
{/* <div*/}
|
||||||
|
{/* className={*/}
|
||||||
|
{/* "break-all border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm font-bold"*/}
|
||||||
|
{/* }*/}
|
||||||
|
{/* >*/}
|
||||||
|
{/* {author}*/}
|
||||||
|
{/* </div>*/}
|
||||||
|
{/* </FormLabel>*/}
|
||||||
|
{/*))}*/}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
isLoading={
|
||||||
|
uploadFile.isLoading ||
|
||||||
|
uploadCover.isLoading ||
|
||||||
|
createContent.isLoading
|
||||||
|
}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
label={"Все верно!"}
|
||||||
|
className={"mt-[30px] py-[16px]"}
|
||||||
|
includeArrows={true}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Button
|
|
||||||
isLoading={
|
|
||||||
uploadFile.isLoading ||
|
|
||||||
uploadCover.isLoading ||
|
|
||||||
createContent.isLoading
|
|
||||||
}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
label={"Все верно!"}
|
|
||||||
className={"mt-[30px] py-[16px]"}
|
|
||||||
includeArrows={true}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ import { Button } from "~/shared/ui/button";
|
||||||
import { useRootStore } from "~/shared/stores/root";
|
import { useRootStore } from "~/shared/stores/root";
|
||||||
import { BackButton } from "~/shared/ui/back-button";
|
import { BackButton } from "~/shared/ui/back-button";
|
||||||
|
|
||||||
const MIN_PRICE = 0.07;
|
const MIN_PRICE = 0.15;
|
||||||
const MIN_RESALE_PRICE = 0.07;
|
const MIN_RESALE_PRICE = 0.15;
|
||||||
|
|
||||||
const RECOMMENDED_PRICE = 0.15;
|
// const RECOMMENDED_PRICE = 0.15;
|
||||||
// const RECOMMENDED_RESALE_PRICE = 0.15;
|
// const RECOMMENDED_RESALE_PRICE = 0.15;
|
||||||
|
|
||||||
type PriceStepProps = {
|
type PriceStepProps = {
|
||||||
|
|
@ -24,48 +24,31 @@ export const PriceStep = ({ nextStep, prevStep }: PriceStepProps) => {
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
|
|
||||||
const formSchema = useMemo(() => {
|
const formSchema = useMemo(() => {
|
||||||
|
const parsePrice = (value: unknown) => {
|
||||||
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
|
const stringValue = value.toString().replace(",", ".");
|
||||||
|
const parsedValue = parseFloat(stringValue);
|
||||||
|
return isNaN(parsedValue) ? undefined : parsedValue;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
if (rootStore.allowResale) {
|
if (rootStore.allowResale) {
|
||||||
return z.object({
|
return z.object({
|
||||||
price: z.preprocess(
|
price: z.preprocess(
|
||||||
(value) => {
|
parsePrice,
|
||||||
const parsed = parseFloat(value as string);
|
z.number({required_error: 'Цена не соответствует требованиям'}).min(MIN_PRICE, `Цена должна быть минимум ${MIN_PRICE} TON.`)
|
||||||
|
|
||||||
return isNaN(parsed) ? undefined : parsed;
|
|
||||||
},
|
|
||||||
z
|
|
||||||
.number()
|
|
||||||
.min(MIN_PRICE, `Цена должна быть минимум ${MIN_PRICE} TON.`),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
resaleLicensePrice: z
|
resaleLicensePrice: z
|
||||||
.preprocess(
|
.preprocess(parsePrice, z.number({required_error: 'Цена не соответствует требованиям'}).min(MIN_RESALE_PRICE, `Цена копии должна быть минимум ${MIN_RESALE_PRICE} TON.`))
|
||||||
(value) => {
|
.optional(),
|
||||||
if (value === undefined || value === "" || value === 0)
|
|
||||||
return undefined;
|
|
||||||
|
|
||||||
const parsed = parseFloat(value as string);
|
|
||||||
return isNaN(parsed) ? undefined : parsed;
|
|
||||||
},
|
|
||||||
|
|
||||||
z
|
|
||||||
.number()
|
|
||||||
.min(
|
|
||||||
MIN_RESALE_PRICE,
|
|
||||||
`Цена копии должна быть минимум ${MIN_RESALE_PRICE} TON.`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return z.object({
|
return z.object({
|
||||||
price: z.preprocess(
|
price: z.preprocess(
|
||||||
(value) => {
|
parsePrice,
|
||||||
const parsed = parseFloat(value as string);
|
z.number({required_error: 'Цена не соответствует требованиям'}).min(MIN_PRICE, `Цена должна быть минимум ${MIN_PRICE} TON.`)
|
||||||
|
|
||||||
return isNaN(parsed) ? undefined : parsed;
|
|
||||||
},
|
|
||||||
z.number().min(MIN_PRICE, `Цена должна быть минимум ${MIN_PRICE} TON.`),
|
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}, [rootStore.allowResale]);
|
}, [rootStore.allowResale]);
|
||||||
|
|
@ -75,12 +58,10 @@ export const PriceStep = ({ nextStep, prevStep }: PriceStepProps) => {
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
|
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
price: rootStore.price,
|
price: rootStore.price || MIN_PRICE,
|
||||||
|
|
||||||
//@ts-expect-error Fix typings
|
//@ts-expect-error Fix typings
|
||||||
resaleLicensePrice: rootStore?.licenseResalePrice,
|
resaleLicensePrice: rootStore?.licenseResalePrice || MIN_RESALE_PRICE,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -92,13 +73,11 @@ export const PriceStep = ({ nextStep, prevStep }: PriceStepProps) => {
|
||||||
form.handleSubmit(async (values: FormValues) => {
|
form.handleSubmit(async (values: FormValues) => {
|
||||||
try {
|
try {
|
||||||
rootStore.setPrice(values.price);
|
rootStore.setPrice(values.price);
|
||||||
|
|
||||||
//@ts-expect-error Fix typings
|
//@ts-expect-error Fix typings
|
||||||
if (values?.resaleLicensePrice) {
|
if (values?.resaleLicensePrice) {
|
||||||
//@ts-expect-error Fix typings
|
//@ts-expect-error Fix typings
|
||||||
rootStore.setLicenseResalePrice(values?.resaleLicensePrice);
|
rootStore.setLicenseResalePrice(values?.resaleLicensePrice);
|
||||||
}
|
}
|
||||||
|
|
||||||
nextStep();
|
nextStep();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error: ", error);
|
console.error("Error: ", error);
|
||||||
|
|
@ -107,81 +86,43 @@ export const PriceStep = ({ nextStep, prevStep }: PriceStepProps) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={"mt-4 px-4 pb-8"}>
|
<section className={"mt-4 px-4 pb-8"}>
|
||||||
<BackButton onClick={prevStep} />
|
<BackButton onClick={prevStep} />
|
||||||
|
<div className={"mb-[30px] flex flex-col text-sm"}>
|
||||||
<div className={"mb-[30px] flex flex-col text-sm"}>
|
<span className={"ml-4"}>/Укажите цену</span>
|
||||||
<span className={"ml-4"}>/Укажите цену</span>
|
<div>
|
||||||
<div>
|
4/<span className={"text-[#7B7B7B]"}>5</span>
|
||||||
4/<span className={"text-[#7B7B7B]"}>5</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={"flex flex-col gap-[20px]"}>
|
|
||||||
<FormLabel label={"Цена TON"}>
|
|
||||||
<div className={"my-2 flex flex-col gap-1.5"}>
|
|
||||||
<p className={"text-xs"}>Минимальная стоимость {MIN_PRICE} TON.</p>
|
|
||||||
<p className={"text-xs"}>
|
|
||||||
Рекомендуемая стоимость {RECOMMENDED_PRICE} TON.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<Input
|
<div className={"flex flex-col gap-[20px]"}>
|
||||||
error={form.formState.errors?.price}
|
<FormLabel label={"Цена продажи TON"}>
|
||||||
placeholder={"[ Введите цену ]"}
|
<div className={"my-2 flex flex-col gap-1.5"}>
|
||||||
{...form.register("price")}
|
<p className={"text-xs"}>Минимальная стоимость {MIN_PRICE} TON.</p>
|
||||||
/>
|
{/* <p className={"text-xs"}>Рекомендуемая стоимость {RECOMMENDED_PRICE} TON.</p> */}
|
||||||
</FormLabel>
|
</div>
|
||||||
|
<Input
|
||||||
{/*<div className={"flex flex-col gap-2"}>*/}
|
error={form.formState.errors?.price}
|
||||||
{/* <FormLabel*/}
|
placeholder={"[ Введите цену ]"}
|
||||||
{/* labelClassName={"flex"}*/}
|
inputMode="decimal"
|
||||||
{/* label={"Разрешить копии"}*/}
|
pattern="[0-9]*[.,]?[0-9]*"
|
||||||
{/* formLabelAddon={*/}
|
{...form.register("price", {
|
||||||
{/* <Checkbox*/}
|
onChange: (e) => {
|
||||||
{/* checked={rootStore.allowResale}*/}
|
const value = e.target.value;
|
||||||
{/* onClick={() => {*/}
|
if (!/^\d*[.,]?\d*$/.test(value)) {
|
||||||
{/* rootStore.setAllowResale(!rootStore.allowResale);*/}
|
e.target.value = value.replace(/[^\d.,]/g, '');
|
||||||
{/* }}*/}
|
}
|
||||||
{/* />*/}
|
}
|
||||||
{/* }*/}
|
})}
|
||||||
{/* />*/}
|
/>
|
||||||
|
</FormLabel>
|
||||||
{/* {rootStore.allowResale && (*/}
|
</div>
|
||||||
{/* <FormLabel label={"Цена копии TON"}>*/}
|
<Button
|
||||||
{/* <div className={"my-2 flex flex-col gap-1.5"}>*/}
|
className={"mt-[30px]"}
|
||||||
{/* <p className={"text-xs"}>*/}
|
onClick={handleSubmit}
|
||||||
{/* Это цена, по которой пользователи будут покупать и*/}
|
includeArrows={true}
|
||||||
{/* перепродавать ваш контент.*/}
|
label={"Далее"}
|
||||||
{/* </p>*/}
|
disabled={!form.formState.isValid}
|
||||||
|
/>
|
||||||
{/* <p className={"text-xs"}>*/}
|
</section>
|
||||||
{/* Минимальная стоимость {MIN_RESALE_PRICE} TON.*/}
|
|
||||||
{/* </p>*/}
|
|
||||||
{/* <p className={"text-xs"}>*/}
|
|
||||||
{/* Рекомендуемая стоимость {RECOMMENDED_RESALE_PRICE} TON.*/}
|
|
||||||
{/* </p>*/}
|
|
||||||
{/* </div>*/}
|
|
||||||
|
|
||||||
{/* <Input*/}
|
|
||||||
{/* //@ts-expect-error Fix typings*/}
|
|
||||||
{/* error={form.formState.errors?.resaleLicensePrice}*/}
|
|
||||||
{/* placeholder={"[ Введите цену копии ]"}*/}
|
|
||||||
{/* //@ts-expect-error Fix typings*/}
|
|
||||||
{/* {...form.register("resaleLicensePrice")}*/}
|
|
||||||
{/* />*/}
|
|
||||||
{/* </FormLabel>*/}
|
|
||||||
{/* )}*/}
|
|
||||||
{/*</div>*/}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
className={"mt-[30px]"}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
includeArrows={true}
|
|
||||||
label={"Далее"}
|
|
||||||
disabled={!form.formState.isValid}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useHapticFeedback } from "@vkruglikov/react-telegram-web-app";
|
import { useHapticFeedback } from "@vkruglikov/react-telegram-web-app";
|
||||||
|
|
||||||
import { Input } from "~/shared/ui/input";
|
import { Input } from "~/shared/ui/input";
|
||||||
|
|
@ -11,17 +11,31 @@ import { Spread } from "~/shared/ui/icons/spread.tsx";
|
||||||
import { ConfirmModal } from "~/pages/root/steps/royalty-step/components/confirm-modal";
|
import { ConfirmModal } from "~/pages/root/steps/royalty-step/components/confirm-modal";
|
||||||
import { useRootStore } from "~/shared/stores/root";
|
import { useRootStore } from "~/shared/stores/root";
|
||||||
import { BackButton } from "~/shared/ui/back-button";
|
import { BackButton } from "~/shared/ui/back-button";
|
||||||
|
import { useTonConnectUI } from "@tonconnect/ui-react";
|
||||||
|
import { Address } from "@ton/core";
|
||||||
|
import { FieldError } from "react-hook-form";
|
||||||
|
|
||||||
type RoyaltyStepProps = {
|
type RoyaltyStepProps = {
|
||||||
prevStep(): void;
|
prevStep(): void;
|
||||||
nextStep(): void;
|
nextStep(): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isValidTonAddress = (address: string): boolean => {
|
||||||
|
try {
|
||||||
|
if (!address) return false;
|
||||||
|
Address.parse(address);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const RoyaltyStep = ({ nextStep, prevStep }: RoyaltyStepProps) => {
|
export const RoyaltyStep = ({ nextStep, prevStep }: RoyaltyStepProps) => {
|
||||||
const [impactOccurred] = useHapticFeedback();
|
const [impactOccurred] = useHapticFeedback();
|
||||||
|
|
||||||
const [isDeleteAllOpen, setDeleteAllOpen] = useState(false);
|
const [isDeleteAllOpen, setDeleteAllOpen] = useState(false);
|
||||||
const [isSpreadOpen, setSpreadOpen] = useState(false);
|
const [isSpreadOpen, setSpreadOpen] = useState(false);
|
||||||
|
const [addressErrors, setAddressErrors] = useState<Record<number, FieldError | undefined>>({});
|
||||||
|
|
||||||
const { royalty, setRoyalty, isPercentHintOpen, setPercentHintOpen } =
|
const { royalty, setRoyalty, isPercentHintOpen, setPercentHintOpen } =
|
||||||
useRootStore();
|
useRootStore();
|
||||||
|
|
@ -41,11 +55,23 @@ export const RoyaltyStep = ({ nextStep, prevStep }: RoyaltyStepProps) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWalletChange = (index: number, address: string) => {
|
const handleWalletChange = (index: number, address: string) => {
|
||||||
|
const isValid = isValidTonAddress(address);
|
||||||
|
setAddressErrors({
|
||||||
|
...addressErrors,
|
||||||
|
[index]: !isValid
|
||||||
|
? {
|
||||||
|
type: 'validation',
|
||||||
|
message: 'Неверный адрес TON'
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
});
|
||||||
|
|
||||||
const newRoyalty = royalty.map((member, i) =>
|
const newRoyalty = royalty.map((member, i) =>
|
||||||
i === index ? { ...member, address } : member,
|
i === index ? { ...member, address } : member
|
||||||
);
|
);
|
||||||
setRoyalty(newRoyalty);
|
setRoyalty(newRoyalty);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handlePercentChange = (index: number, value: string) => {
|
const handlePercentChange = (index: number, value: string) => {
|
||||||
const percentNumber = parseInt(value, 10) || 0;
|
const percentNumber = parseInt(value, 10) || 0;
|
||||||
|
|
@ -66,11 +92,29 @@ export const RoyaltyStep = ({ nextStep, prevStep }: RoyaltyStepProps) => {
|
||||||
|
|
||||||
const isValid = useMemo(() => {
|
const isValid = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
royalty.every((member) => member.address && member.value >= 0) &&
|
royalty.every((member) => isValidTonAddress(member.address) && member.value >= 0) &&
|
||||||
royalty.reduce((acc, curr) => acc + curr.value, 0) === 100
|
royalty.reduce((acc, curr) => acc + curr.value, 0) === 100
|
||||||
);
|
);
|
||||||
}, [royalty]);
|
}, [royalty]);
|
||||||
|
|
||||||
|
const [tonConnectUI] = useTonConnectUI();
|
||||||
|
// Устанавливаем адрес из tonConnectUI.account при загрузке страницы
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tonConnectUI.account) return;
|
||||||
|
|
||||||
|
if (royalty.length === 0) {
|
||||||
|
// First initialization with 100%
|
||||||
|
setRoyalty([{
|
||||||
|
address: Address.parse(tonConnectUI.account.address).toString({
|
||||||
|
bounceable: false,
|
||||||
|
urlSafe: true,
|
||||||
|
testOnly: false,
|
||||||
|
}),
|
||||||
|
value: 100
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
}, [tonConnectUI.account, setRoyalty, royalty]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={"mt-4 px-4 pb-8"}>
|
<section className={"mt-4 px-4 pb-8"}>
|
||||||
{isPercentHintOpen && (
|
{isPercentHintOpen && (
|
||||||
|
|
@ -117,7 +161,7 @@ export const RoyaltyStep = ({ nextStep, prevStep }: RoyaltyStepProps) => {
|
||||||
<section className={"flex flex-col gap-1.5"}>
|
<section className={"flex flex-col gap-1.5"}>
|
||||||
{royalty.map((member, index) => (
|
{royalty.map((member, index) => (
|
||||||
<div key={index} className={"flex flex-col gap-[20px]"}>
|
<div key={index} className={"flex flex-col gap-[20px]"}>
|
||||||
<div className={"flex w-full items-center gap-1"}>
|
<div className={"flex w-full items-start gap-1"}>
|
||||||
<div className={"w-[83%]"}>
|
<div className={"w-[83%]"}>
|
||||||
<FormLabel
|
<FormLabel
|
||||||
labelClassName={"flex"}
|
labelClassName={"flex"}
|
||||||
|
|
@ -134,7 +178,8 @@ export const RoyaltyStep = ({ nextStep, prevStep }: RoyaltyStepProps) => {
|
||||||
value={member.address}
|
value={member.address}
|
||||||
onChange={(e) => handleWalletChange(index, e.target.value)}
|
onChange={(e) => handleWalletChange(index, e.target.value)}
|
||||||
placeholder={"[ Введите адрес криптокошелька TON ]"}
|
placeholder={"[ Введите адрес криптокошелька TON ]"}
|
||||||
/>
|
error={addressErrors[index]}
|
||||||
|
/>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,181 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTonConnectUI } from "@tonconnect/ui-react";
|
||||||
|
|
||||||
import { Button } from "~/shared/ui/button";
|
import { Button } from "~/shared/ui/button";
|
||||||
import { useAuth } from "~/shared/services/auth";
|
import { useAuth } from "~/shared/services/auth";
|
||||||
|
import { Address } from "@ton/core";
|
||||||
|
|
||||||
type WelcomeStepProps = {
|
type WelcomeStepProps = {
|
||||||
nextStep(): void;
|
nextStep(): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WelcomeStep = ({ nextStep }: WelcomeStepProps) => {
|
export const WelcomeStep = ({ nextStep }: WelcomeStepProps) => {
|
||||||
|
const [tonConnectUI] = useTonConnectUI();
|
||||||
|
const [isLoaded, setLoaded] = useState(false);
|
||||||
|
const [isConnected, setIsConnected] = useState(tonConnectUI.connected);
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
console.log("💩💩💩 enter WelcomeStep");
|
||||||
|
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
|
||||||
|
console.log("💩💩💩 after useAuth");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('disclaimerAccepted', 'false');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = tonConnectUI.onStatusChange((wallet) => {
|
||||||
|
setIsConnected(!!wallet);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [tonConnectUI]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if(tonConnectUI.account){
|
||||||
|
setAddress(Address.parse(tonConnectUI.account?.address).toString({
|
||||||
|
bounceable: false,
|
||||||
|
urlSafe: true,
|
||||||
|
testOnly: false,
|
||||||
|
}),)
|
||||||
|
}
|
||||||
|
}, [tonConnectUI.account]);
|
||||||
|
|
||||||
const handleNextClick = async () => {
|
const handleNextClick = async () => {
|
||||||
const res = await auth.mutateAsync();
|
if (tonConnectUI.connected) {
|
||||||
sessionStorage.setItem("auth_v1_token", res.data.auth_v1_token);
|
await auth.mutateAsync();
|
||||||
nextStep();
|
nextStep();
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await tonConnectUI.openModal();
|
||||||
|
await auth.mutateAsync();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to connect or authenticate:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = async () => {
|
||||||
|
if (isConnected){
|
||||||
|
try {
|
||||||
|
await tonConnectUI.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to disconnect:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// useEffect(() => {
|
||||||
|
// const first = setTimeout(async () => {
|
||||||
|
// console.log("💩💩💩 call auth");
|
||||||
|
// await auth.mutateAsync();
|
||||||
|
// }, 1000);
|
||||||
|
//
|
||||||
|
// const second = setTimeout(() => {
|
||||||
|
// setLoaded(true);
|
||||||
|
//
|
||||||
|
// if (tonConnectUI.connected) {
|
||||||
|
// nextStep();
|
||||||
|
// }
|
||||||
|
// }, 4000);
|
||||||
|
//
|
||||||
|
// return () => {
|
||||||
|
// clearTimeout(first);
|
||||||
|
// clearTimeout(second);
|
||||||
|
// };
|
||||||
|
// }, [tonConnectUI.connected]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setLoaded(true)
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isLoaded) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={"relative flex h-[100vh] items-center justify-center"}
|
||||||
|
>
|
||||||
|
<img alt={"splash"} className={"mb-20 w-[50%]"} src={"/splash.gif"} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={"mt-4 px-4"}>
|
<section className={"mt-4 flex flex-col justify-between min-h-[calc(100vh-32px)] px-4"}>
|
||||||
<div className={"flex gap-2 text-sm"}>
|
<div className="flex items-center justify-center overflow-hidden w-[100%] h-[400px]">
|
||||||
<span>/ Добро пожаловать в MY</span>
|
<img
|
||||||
|
alt={"splash"}
|
||||||
|
className={"w-[50%] shrink-0"}
|
||||||
|
src={"/splash.gif"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={"flex items-center gap-0"}>
|
<div className={"flex flex-col mt-4"}>
|
||||||
<span>[</span>
|
<div className={"flex gap-2 text-sm"}>
|
||||||
<div className={"mb-0.5 h-3 w-3 rounded-full bg-primary"} />
|
<span>/ Добро пожаловать в MY</span>
|
||||||
<span>]:</span>
|
|
||||||
|
<div className={"flex items-center gap-0"}>
|
||||||
|
<span>[</span>
|
||||||
|
<div className={"mb-0.5 h-3 w-3 rounded-full bg-primary"} />
|
||||||
|
<span>]:</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={"mt-2"}>
|
|
||||||
<p className={"text-sm"}>
|
{isConnected ?
|
||||||
децентрализованную систему монетизации контента. для продолжения
|
<>
|
||||||
необходимо подключить криптокошелек TON
|
<div className={"mt-2"}>
|
||||||
</p>
|
<p className={"text-sm"}>
|
||||||
|
Вы зарегистрированы под кошельком: <br/>
|
||||||
|
<span className={"font-bold"}>{address}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Button
|
||||||
|
className={"mt-[30px]"}
|
||||||
|
label={"Продолжить"}
|
||||||
|
includeArrows={false}
|
||||||
|
isLoading={auth.isLoading}
|
||||||
|
onClick={handleNextClick}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className={"mt-[20px] bg-inherit"}
|
||||||
|
label={"Изменить кошелек"}
|
||||||
|
includeArrows={false}
|
||||||
|
isLoading={false}
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
:
|
||||||
|
<>
|
||||||
|
<div className={"mt-2"}>
|
||||||
|
<p className={"text-sm"}>
|
||||||
|
Здесь вы можете загрузить свой контент. <br/> Для продолжения подключите кошелек.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={"mt-2"}>
|
||||||
|
<p className={"text-sm"}>
|
||||||
|
Не волнуйтесь. Вы сможете поменять свой выбор в любой момент.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={"mt-[30px]"}
|
||||||
|
label={"Подключить криптокошелёк TON"}
|
||||||
|
includeArrows={true}
|
||||||
|
isLoading={auth.isLoading}
|
||||||
|
onClick={handleNextClick}
|
||||||
|
/>
|
||||||
|
</>}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
|
||||||
label={"Подключить криптокошелёк TON"}
|
|
||||||
className={"mt-[30px]"}
|
|
||||||
includeArrows={true}
|
|
||||||
isLoading={auth.isLoading}
|
|
||||||
onClick={handleNextClick}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { useHapticFeedback, useWebApp } from "@vkruglikov/react-telegram-web-app";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Button } from "~/shared/ui/button";
|
||||||
|
|
||||||
|
type CongratsModalProps = {
|
||||||
|
onConfirm(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CongratsModal = ({
|
||||||
|
onConfirm,
|
||||||
|
}: CongratsModalProps) => {
|
||||||
|
const [impactOccurred] = useHapticFeedback();
|
||||||
|
const WebApp = useWebApp();
|
||||||
|
|
||||||
|
const handleClick = (fn: () => void) => {
|
||||||
|
impactOccurred("light");
|
||||||
|
fn();
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
// Отключаем вертикальные свайпы при монтировании компонента
|
||||||
|
if (WebApp && WebApp.disableVerticalSwipes) {
|
||||||
|
WebApp.disableVerticalSwipes();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Включаем вертикальные свайпы обратно при размонтировании
|
||||||
|
return () => {
|
||||||
|
if (WebApp && WebApp.enableVerticalSwipes) {
|
||||||
|
WebApp.enableVerticalSwipes();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"fixed left-0 top-0 z-30 flex h-full w-full items-center justify-center bg-black/80 px-[15px]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={"flex flex-col max-h-[80vh] w-[85vw] max-w-md"}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"border border-white bg-[#1D1D1B] px-[15px] py-[16px] text-start flex flex-col gap-6 h-full overflow-y-auto"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="mt-4">
|
||||||
|
<span className="text-xl">🎉</span>
|
||||||
|
<span className="px-1 font-bold">Поздравляем с покупкой!</span>
|
||||||
|
<span className="text-xl">🎉</span>
|
||||||
|
</p>
|
||||||
|
<p className="">
|
||||||
|
Ваш контент уже в пути! В ближайшее время в чат-боте <strong>MY Player</strong> вам придёт сообщение с доступом к купленному материалу. В этом чат-боте будет храниться весь ваш приобретённый контент.
|
||||||
|
</p>
|
||||||
|
<p className="flex flex-col">
|
||||||
|
<strong className="w-full">Теперь вы можете самостоятельно осуществить продажу купленного контента.</strong>
|
||||||
|
<span>
|
||||||
|
Перешлите сообщение из чата My-Player друзьям или опубликуйте в своём Telegram-канале. Каждый, кто купит контент по вашей ссылке, принесёт вам доход, а автору — заслуженное вознаграждение.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="flex flex-col">
|
||||||
|
Спасибо, что выбираете MY!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={"mt-[20px]"}
|
||||||
|
label={"Ок"}
|
||||||
|
includeArrows={false}
|
||||||
|
onClick={() => handleClick(onConfirm)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { useHapticFeedback, useWebApp } from "@vkruglikov/react-telegram-web-app";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Button } from "~/shared/ui/button";
|
||||||
|
|
||||||
|
type ErrorModalProps = {
|
||||||
|
onConfirm(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ErrorModal = ({
|
||||||
|
onConfirm,
|
||||||
|
}: ErrorModalProps) => {
|
||||||
|
const [impactOccurred] = useHapticFeedback();
|
||||||
|
const WebApp = useWebApp();
|
||||||
|
|
||||||
|
const handleClick = (fn: () => void) => {
|
||||||
|
impactOccurred("light");
|
||||||
|
fn();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Отключаем вертикальные свайпы при монтировании компонента
|
||||||
|
if (WebApp && WebApp.disableVerticalSwipes) {
|
||||||
|
WebApp.disableVerticalSwipes();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Включаем вертикальные свайпы обратно при размонтировании
|
||||||
|
return () => {
|
||||||
|
if (WebApp && WebApp.enableVerticalSwipes) {
|
||||||
|
WebApp.enableVerticalSwipes();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"fixed left-0 top-0 z-30 flex h-full w-full items-center justify-center bg-black/80 px-[15px]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={"flex flex-col max-h-[80vh] w-[85vw] max-w-md"}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"border border-white bg-[#1D1D1B] px-[15px] py-[16px] text-start flex flex-col gap-6 h-full overflow-y-auto"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="mt-4">
|
||||||
|
<span className="font-bold">Ошибка запроса транзакции</span>
|
||||||
|
</p>
|
||||||
|
<p className="">
|
||||||
|
Не удалось отправить запрос на выполнение транзакции.
|
||||||
|
</p>
|
||||||
|
<p className="flex flex-col">
|
||||||
|
<span>
|
||||||
|
Попробуйте переподключить кошелек и повторить попытку. Если ошибка сохраняется, попробуйте запросить транзакцию еще раз.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="flex flex-col">
|
||||||
|
Если проблема не исчезает, убедитесь, что ваш кошелек работает корректно, или свяжитесь с поддержкой.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={"mt-[20px]"}
|
||||||
|
label={"Ок"}
|
||||||
|
includeArrows={false}
|
||||||
|
onClick={() => handleClick(onConfirm)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,408 @@
|
||||||
|
import ReactPlayer from 'react-player/lazy';
|
||||||
|
import { useTonConnectUI } from '@tonconnect/ui-react';
|
||||||
|
import { useWebApp } from '@vkruglikov/react-telegram-web-app';
|
||||||
|
|
||||||
|
import { Button } from '~/shared/ui/button';
|
||||||
|
import { usePurchaseContent, useViewContent } from '~/shared/services/content';
|
||||||
|
import { fromNanoTON } from '~/shared/utils';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { AudioPlayer } from '~/shared/ui/audio-player';
|
||||||
|
import { useAuth } from '~/shared/services/auth';
|
||||||
|
import { CongratsModal } from './components/congrats-modal';
|
||||||
|
import { ErrorModal } from './components/error-modal';
|
||||||
|
import { resolveStartPayload } from '~/shared/utils/start-payload';
|
||||||
|
|
||||||
|
type InvoiceStatus = 'paid' | 'failed' | 'cancelled' | 'pending';
|
||||||
|
|
||||||
|
// Add type for invoice event
|
||||||
|
interface InvoiceEvent {
|
||||||
|
url: string;
|
||||||
|
status: InvoiceStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ViewContentPage = () => {
|
||||||
|
const WebApp = useWebApp();
|
||||||
|
const { contentId } = resolveStartPayload();
|
||||||
|
|
||||||
|
const { data: content, refetch: refetchContent } = useViewContent(
|
||||||
|
contentId
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutateAsync: purchaseContent } = usePurchaseContent();
|
||||||
|
|
||||||
|
const [tonConnectUI] = useTonConnectUI();
|
||||||
|
|
||||||
|
const auth = useAuth();
|
||||||
|
const [isCongratsModal, setIsCongratsModal] = 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 () => {
|
||||||
|
if (!contentId) {
|
||||||
|
console.error('No content identifier available for purchase');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Если не подключен, начинаем процесс подключения через auth
|
||||||
|
if (!tonConnectUI.connected) {
|
||||||
|
console.log('DEBUG: Wallet not connected, using auth flow first');
|
||||||
|
|
||||||
|
// Вызываем auth.mutateAsync() до открытия модального окна
|
||||||
|
// Это настроит параметры подключения с TonProof
|
||||||
|
try {
|
||||||
|
await auth.mutateAsync();
|
||||||
|
|
||||||
|
// Проверяем, установилось ли подключение после auth
|
||||||
|
if (!tonConnectUI.connected) {
|
||||||
|
console.log('DEBUG: Auth did not establish connection, returning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('DEBUG: Connection and authentication successful');
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Ошибка подключения кошелька', 'danger');
|
||||||
|
console.error('DEBUG: Auth failed during connection:', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Если уже подключены, просто аутентифицируемся
|
||||||
|
console.log('DEBUG: Already connected, authenticating');
|
||||||
|
await auth.mutateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка наличия TonProof
|
||||||
|
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');
|
||||||
|
const contentResponse = await purchaseContent({
|
||||||
|
content_address: contentId,
|
||||||
|
license_type: 'resale',
|
||||||
|
});
|
||||||
|
|
||||||
|
const transactionResponse = await tonConnectUI.sendTransaction({
|
||||||
|
validUntil: Math.floor(Date.now() / 1000) + 86400, // 24 hours
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
amount: contentResponse.data.amount,
|
||||||
|
address: contentResponse.data.address,
|
||||||
|
payload: contentResponse.data.payload,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (transactionResponse.boc) {
|
||||||
|
void refetchContent();
|
||||||
|
setIsCongratsModal(true);
|
||||||
|
console.log(transactionResponse.boc, 'PURCHASED');
|
||||||
|
} else {
|
||||||
|
setIsErrorModal(true);
|
||||||
|
console.error('Transaction failed:', transactionResponse);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setIsErrorModal(true);
|
||||||
|
console.error('Error handling Ton Connect subscription:', error);
|
||||||
|
}
|
||||||
|
}, [auth, contentId, purchaseContent, refetchContent, tonConnectUI]);
|
||||||
|
|
||||||
|
const handleBuyContentStars = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (!content?.data?.invoice.url) {
|
||||||
|
console.error('No invoice URL available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for invoice closing with typed event
|
||||||
|
const handleInvoiceClosed = (event: InvoiceEvent) => {
|
||||||
|
if (event.url === content.data.invoice.url) {
|
||||||
|
if (event.status === 'paid') {
|
||||||
|
void refetchContent();
|
||||||
|
setIsCongratsModal(true);
|
||||||
|
} else if (event.status === 'failed' || event.status === 'cancelled') {
|
||||||
|
// setIsErrorModal(true); // Turn on if need in error modal. Update text in it to match both way of payment errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
WebApp.onEvent('invoiceClosed', handleInvoiceClosed);
|
||||||
|
|
||||||
|
await WebApp.openInvoice(content.data.invoice.url, (status: InvoiceStatus) => {
|
||||||
|
console.log('Invoice status:', status);
|
||||||
|
if (status === 'paid') {
|
||||||
|
void refetchContent();
|
||||||
|
setIsCongratsModal(true);
|
||||||
|
} else if (status === 'failed' || status === 'cancelled') {
|
||||||
|
// setIsErrorModal(true); // Turn on if need in error modal. Update text in it to match both way of payment errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
WebApp.offEvent('invoiceClosed', handleInvoiceClosed);
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Payment failed:', error);
|
||||||
|
// setIsErrorModal(true); // Turn on if need in error modal. Update text in it to match both way of payment errors
|
||||||
|
}
|
||||||
|
}, [content, refetchContent]);
|
||||||
|
|
||||||
|
const hadLicenseRef = useRef<boolean>(haveLicense);
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
void refetchContent();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [contentId, refetchContent]);
|
||||||
|
|
||||||
|
const handleConfirmCongrats = () => {
|
||||||
|
setIsCongratsModal(!isCongratsModal);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleErrorModal = () => {
|
||||||
|
setIsErrorModal(!isErrorModal);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDwnldContent = async () => {
|
||||||
|
try {
|
||||||
|
const fileUrl = content?.data?.display_options?.content_url;
|
||||||
|
if (!fileUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fileName = content?.data?.display_options?.metadata?.name || 'content';
|
||||||
|
const rawExt = content?.data?.content_ext ?? 'bin';
|
||||||
|
const normalizedExt = rawExt.replace(/^\.+/, '');
|
||||||
|
await WebApp.downloadFile({
|
||||||
|
url: fileUrl,
|
||||||
|
file_name: `${fileName}.${normalizedExt}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading content:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className={'min-h-screen flex w-full flex-col gap-[40px] px-4 '}>
|
||||||
|
{isCongratsModal && <CongratsModal onConfirm={handleConfirmCongrats} />}
|
||||||
|
{isErrorModal && <ErrorModal onConfirm={handleErrorModal} />}
|
||||||
|
{coverImage && (
|
||||||
|
<div className="mt-[30px] flex w-full justify-center">
|
||||||
|
<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">
|
||||||
|
<img
|
||||||
|
alt={'content cover'}
|
||||||
|
className={'max-h-full max-w-full object-contain'}
|
||||||
|
src={coverImage}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isReadyState ? (
|
||||||
|
<>
|
||||||
|
{hasInlinePlayer && (
|
||||||
|
isAudio ? (
|
||||||
|
<AudioPlayer src={mediaUrl ?? ''} />
|
||||||
|
) : (
|
||||||
|
<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 && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
tonConnectUI.disconnect();
|
||||||
|
}}
|
||||||
|
className={'h-[48px] bg-darkred mt-4'}
|
||||||
|
label={`Отключить кошелек`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center py-16">
|
||||||
|
<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">
|
||||||
|
<h1 className="text-lg font-semibold text-slate-100">
|
||||||
|
{isFailed ? 'Не удалось подготовить контент' : 'Контент скоро будет здесь'}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-sm text-slate-300">
|
||||||
|
{isFailed
|
||||||
|
? 'При обработке файла произошла ошибка. Попробуйте обновить страницу или повторно загрузить контент.'
|
||||||
|
: 'Мы уже обрабатываем загруженный файл и обновим страницу автоматически, как только появится доступ к полному контенту.'}
|
||||||
|
</p>
|
||||||
|
{statusMessage && (
|
||||||
|
<p className="mt-4 text-[12px] text-slate-500">
|
||||||
|
Текущее состояние: {statusMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{processingDetails?.conversion && (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import { ReactNode, useMemo, useState } from "react";
|
import { useTonConnectUI } from "@tonconnect/ui-react";
|
||||||
|
import { ReactNode, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
const CHECK_INTERVAL = 20000;
|
||||||
|
|
||||||
|
|
||||||
export const useSteps = (
|
export const useSteps = (
|
||||||
sections: ({
|
sections: ({
|
||||||
|
|
@ -9,15 +13,24 @@ export const useSteps = (
|
||||||
prevStep(): void;
|
prevStep(): void;
|
||||||
}) => ReactNode[],
|
}) => ReactNode[],
|
||||||
) => {
|
) => {
|
||||||
|
|
||||||
|
const [tonConnectUI] = useTonConnectUI();
|
||||||
|
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
|
|
||||||
const nextStep = () => {
|
// If connection is lost, reset the step
|
||||||
return setStep((s) => s + 1);
|
useEffect(() => {
|
||||||
};
|
const interval = setInterval(() => {
|
||||||
|
if (!tonConnectUI.connected && step !== 0) {
|
||||||
const prevStep = () => {
|
setStep(0);
|
||||||
return setStep((s) => s - 1);
|
}
|
||||||
};
|
}, CHECK_INTERVAL);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const nextStep = () => setStep((s) => s + 1);
|
||||||
|
const prevStep = () => setStep((s) => s - 1);
|
||||||
|
|
||||||
const ActiveSection = useMemo(() => {
|
const ActiveSection = useMemo(() => {
|
||||||
return sections({ nextStep, prevStep })[step];
|
return sections({ nextStep, prevStep })[step];
|
||||||
|
|
@ -28,4 +41,4 @@ export const useSteps = (
|
||||||
setStep,
|
setStep,
|
||||||
step,
|
step,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
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('; ');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
import { Buffer } from 'buffer';
|
||||||
|
globalThis.Buffer = Buffer;
|
||||||
|
|
@ -1,17 +1,67 @@
|
||||||
import axios from "axios";
|
import axios, { InternalAxiosRequestConfig } from 'axios';
|
||||||
|
|
||||||
export const APP_API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string;
|
import { getAdminAuthSnapshot } from '~/shared/libs/admin-auth';
|
||||||
|
|
||||||
export const request = axios.create({
|
const API_BASE_PATH = '/api/v1';
|
||||||
baseURL: APP_API_BASE_URL,
|
|
||||||
|
const isBrowser = typeof window !== 'undefined';
|
||||||
|
|
||||||
|
const readStorage = (key: string) => {
|
||||||
|
if (!isBrowser) {
|
||||||
|
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;
|
||||||
});
|
});
|
||||||
|
|
||||||
request.interceptors.request.use((config) => {
|
request.interceptors.response.use((response) => response);
|
||||||
const auth_v1_token = sessionStorage.getItem("auth_v1_token");
|
|
||||||
|
|
||||||
if (auth_v1_token) {
|
|
||||||
config.headers.Authorization = auth_v1_token;
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,22 +1,246 @@
|
||||||
import { useMutation } from "react-query";
|
import { useRef } from 'react';
|
||||||
import { useWebApp } from "@vkruglikov/react-telegram-web-app";
|
import { useTonConnectUI } from '@tonconnect/ui-react';
|
||||||
|
import { useMutation } from 'react-query';
|
||||||
|
import { request } from '~/shared/libs';
|
||||||
|
import { useWebApp } from '@vkruglikov/react-telegram-web-app';
|
||||||
|
import { appendReferral } from '~/shared/utils/start-payload';
|
||||||
|
|
||||||
import { request } from "~/shared/libs";
|
const sessionStorageKey = 'auth_v1_token';
|
||||||
|
const payloadTTLMS = 1000 * 60 * 20;
|
||||||
|
|
||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
const WebApp = useWebApp();
|
const WebApp = useWebApp();
|
||||||
|
const [tonConnectUI] = useTonConnectUI();
|
||||||
|
const interval = useRef<ReturnType<typeof setInterval> | undefined>();
|
||||||
|
|
||||||
return useMutation(["auth"], () => {
|
const makeAuthRequest = async (params: {
|
||||||
return request.post<{
|
twa_data: string;
|
||||||
connected_wallet: null | {
|
ton_proof?: {
|
||||||
version: string;
|
account: any;
|
||||||
address: string;
|
ton_proof: any;
|
||||||
ton_balance: string;
|
};
|
||||||
};
|
}) => {
|
||||||
|
try {
|
||||||
|
const res = await request.post<{
|
||||||
|
connected_wallet: null | {
|
||||||
|
version: string;
|
||||||
|
address: string;
|
||||||
|
ton_balance: string;
|
||||||
|
};
|
||||||
|
auth_v1_token: string;
|
||||||
|
}>('/auth.twa', appendReferral(params));
|
||||||
|
|
||||||
auth_v1_token: string;
|
if (res?.data?.auth_v1_token) {
|
||||||
}>("/auth.twa", {
|
localStorage.setItem(sessionStorageKey, res.data.auth_v1_token);
|
||||||
twa_data: WebApp.initData,
|
|
||||||
|
// If we sent a proof, it was accepted, so keep record of that
|
||||||
|
if (params.ton_proof) {
|
||||||
|
console.log('DEBUG: Auth with proof successful');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to get auth token');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeSelectWalletRequest = async (params: { wallet_address: string }) => {
|
||||||
|
try {
|
||||||
|
const res = await request.post('/auth.selectWallet', params);
|
||||||
|
return res;
|
||||||
|
} catch (error: any) {
|
||||||
|
// Check for 404 error (wallet not found or invalid)
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
console.log('DEBUG: Wallet selection failed with 404, disconnecting');
|
||||||
|
await tonConnectUI.disconnect();
|
||||||
|
localStorage.removeItem(sessionStorageKey);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to prepare the connection parameters with proof requirements
|
||||||
|
const prepareConnectParams = async () => {
|
||||||
|
console.log('DEBUG: Preparing connect parameters');
|
||||||
|
|
||||||
|
// Set to loading state first
|
||||||
|
tonConnectUI.setConnectRequestParameters({ state: 'loading' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the payload/token from backend
|
||||||
|
const value = await request.post<{ auth_v1_token: string }>('/auth.twa', appendReferral({
|
||||||
|
twa_data: WebApp.initData,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (value?.data?.auth_v1_token) {
|
||||||
|
console.log('DEBUG: Got token for connect params');
|
||||||
|
|
||||||
|
// Set the parameters to ready with tonProof requirement
|
||||||
|
tonConnectUI.setConnectRequestParameters({
|
||||||
|
state: 'ready',
|
||||||
|
value: { tonProof: value.data.auth_v1_token },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.log('DEBUG: No token received for connect params');
|
||||||
|
tonConnectUI.setConnectRequestParameters(null);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('DEBUG: Error preparing connect params:', error);
|
||||||
|
tonConnectUI.setConnectRequestParameters(null);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to check if connection is capable of transactions
|
||||||
|
const isConnectionValid = () => {
|
||||||
|
if (!tonConnectUI.connected || !tonConnectUI.wallet) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
clearInterval(interval.current);
|
||||||
|
let authResult;
|
||||||
|
console.log('DEBUG: Starting auth flow');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Case 1: Not connected - need to connect and get proof
|
||||||
|
if (!tonConnectUI.connected) {
|
||||||
|
console.log('DEBUG: No wallet connection, starting flow');
|
||||||
|
localStorage.removeItem(sessionStorageKey);
|
||||||
|
|
||||||
|
// Prepare connection parameters (this sets up the proof requirement)
|
||||||
|
const prepared = await prepareConnectParams();
|
||||||
|
|
||||||
|
if (!prepared) {
|
||||||
|
throw new Error('Failed to prepare connection parameters');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start periodic refresh of the payload
|
||||||
|
interval.current = setInterval(prepareConnectParams, payloadTTLMS);
|
||||||
|
|
||||||
|
// Open the modal and wait for connection
|
||||||
|
try {
|
||||||
|
console.log('DEBUG: Opening wallet connect modal');
|
||||||
|
tonConnectUI.openModal();
|
||||||
|
|
||||||
|
// Ждем подключения кошелька
|
||||||
|
const connected = await waitForConnection();
|
||||||
|
|
||||||
|
if (!connected || !tonConnectUI.connected) {
|
||||||
|
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
|
||||||
|
if (
|
||||||
|
tonConnectUI.wallet?.connectItems?.tonProof &&
|
||||||
|
!('error' in tonConnectUI.wallet.connectItems.tonProof)
|
||||||
|
) {
|
||||||
|
console.log('DEBUG: Got proof after connection');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try auth with the proof
|
||||||
|
authResult = await makeAuthRequest({
|
||||||
|
twa_data: WebApp.initData,
|
||||||
|
ton_proof: {
|
||||||
|
account: tonConnectUI.wallet.account,
|
||||||
|
ton_proof: tonConnectUI.wallet.connectItems.tonProof.proof,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// If auth with proof fails, throw the error
|
||||||
|
console.error('DEBUG: Auth with fresh proof failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('DEBUG: No proof available after connection');
|
||||||
|
// If we can't get proof but we're connected, try auth without it
|
||||||
|
authResult = await makeAuthRequest({
|
||||||
|
twa_data: WebApp.initData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Case 2: 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.
|
||||||
|
authResult = await makeAuthRequest({
|
||||||
|
twa_data: WebApp.initData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always try to select wallet after auth (this validates the connection)
|
||||||
|
if (tonConnectUI.wallet?.account?.address) {
|
||||||
|
console.log('DEBUG: Selecting wallet', tonConnectUI.wallet.account.address);
|
||||||
|
try {
|
||||||
|
await makeSelectWalletRequest({
|
||||||
|
wallet_address: tonConnectUI.wallet.account.address,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Additional validation check
|
||||||
|
if (!isConnectionValid()) {
|
||||||
|
console.log('DEBUG: Connection validation failed, disconnecting');
|
||||||
|
await tonConnectUI.disconnect();
|
||||||
|
localStorage.removeItem(sessionStorageKey);
|
||||||
|
throw new Error('Connection validation failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Errors from makeSelectWalletRequest are already handled
|
||||||
|
console.error('DEBUG: Wallet selection failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return authResult;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('DEBUG: Auth flow failed:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearInterval(interval.current);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { useMutation } from "react-query";
|
||||||
|
import { request } from "~/shared/libs";
|
||||||
|
import { useWebApp } from "@vkruglikov/react-telegram-web-app";
|
||||||
|
import { appendReferral } from "~/shared/utils/start-payload";
|
||||||
|
|
||||||
|
const sessionStorageKey = "auth_v1_token";
|
||||||
|
|
||||||
|
export const useAuthTwa = () => {
|
||||||
|
const WebApp = useWebApp();
|
||||||
|
|
||||||
|
const makeAuthRequest = async () => {
|
||||||
|
const res = await request.post<{
|
||||||
|
auth_v1_token: string;
|
||||||
|
}>("/auth.twa", appendReferral({
|
||||||
|
twa_data: WebApp.initData,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (res?.data?.auth_v1_token) {
|
||||||
|
localStorage.setItem(sessionStorageKey, res.data.auth_v1_token);
|
||||||
|
} else {
|
||||||
|
throw new Error("Failed to get auth token");
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
return useMutation(["auth"], makeAuthRequest);
|
||||||
|
};
|
||||||
|
|
@ -1,38 +1,147 @@
|
||||||
import { useMutation } from "react-query";
|
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;
|
||||||
image: string;
|
image: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
hashtags: string[];
|
||||||
price: string;
|
price: string;
|
||||||
resaleLicensePrice: string; // nanoTON bignum (default = 0)
|
resaleLicensePrice: string; // nanoTON bignum (default = 0)
|
||||||
allowResale: boolean;
|
allowResale: boolean;
|
||||||
authors: string[];
|
authors: string[];
|
||||||
royaltyParams: Royalty[];
|
royaltyParams: Royalty[];
|
||||||
|
downloadable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useCreateNewContent = () => {
|
export const useCreateNewContent = () => {
|
||||||
return useMutation(
|
return useMutation(
|
||||||
["create-new-content"],
|
["create-new-content"],
|
||||||
(payload: UseCreateNewContentPayload) => {
|
async (payload: UseCreateNewContentPayload) => {
|
||||||
return request.post<{
|
console.info("[App2Client][Content] Отправляем контент на создание", {
|
||||||
message: string;
|
title: payload.title,
|
||||||
}>("/blockchain.sendNewContentMessage", payload);
|
hasImage: Boolean(payload.image),
|
||||||
},
|
contentCid: payload.content,
|
||||||
|
hashtags: payload.hashtags,
|
||||||
|
authorsCount: payload.authors.length,
|
||||||
|
royaltyCount: payload.royaltyParams.length,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// export const usePurchaseContent = () => {
|
||||||
|
// return useMutation(
|
||||||
|
// ["purchase-content"],
|
||||||
|
// (payload: { content_address: string; price: string }) => {
|
||||||
|
// return request.post<{
|
||||||
|
// message: string;
|
||||||
|
// }>("/blockchain.sendPurchaseContentMessage", payload);
|
||||||
|
// },
|
||||||
|
// );
|
||||||
|
// };
|
||||||
|
|
||||||
|
export const useViewContent = (contentId: string | null | undefined) => {
|
||||||
|
return useQuery(
|
||||||
|
["view", "content", contentId],
|
||||||
|
async () => {
|
||||||
|
if (!contentId) {
|
||||||
|
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 = () => {
|
||||||
return useMutation(
|
return useMutation(
|
||||||
["purchase-content"],
|
["purchase", "content"],
|
||||||
(payload: { content_address: string; price: string }) => {
|
({
|
||||||
return request.post<{
|
content_address,
|
||||||
message: string;
|
license_type,
|
||||||
}>("/blockchain.sendPurchaseContentMessage", payload);
|
}: {
|
||||||
},
|
content_address: string;
|
||||||
|
license_type: "listen" | "resale";
|
||||||
|
}) => {
|
||||||
|
return request.post<{
|
||||||
|
address: string;
|
||||||
|
amount: string;
|
||||||
|
payload: string;
|
||||||
|
}>("/blockchain.sendPurchaseContentMessage", {
|
||||||
|
content_address,
|
||||||
|
license_type,
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
Authorization: localStorage.getItem('auth_v1_token') ?? ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,722 @@
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "react-query";
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { Upload } from "tus-js-client";
|
||||||
|
|
||||||
import { request } from "~/shared/libs";
|
import { request } from "~/shared/libs";
|
||||||
|
|
||||||
export const useUploadFile = () => {
|
const resolveApiBaseUrl = () => {
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const envBase = (import.meta.env.VITE_API_BASE_URL ?? "").trim();
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
if (envBase) {
|
||||||
|
return envBase.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
const mutation = useMutation(["upload-file"], (file: File) => {
|
if (typeof window !== "undefined") {
|
||||||
setIsUploading(true);
|
return `${window.location.origin.replace(/\/$/, "")}/api/v1`;
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
return "/api/v1";
|
||||||
formData.append("file", file);
|
};
|
||||||
|
|
||||||
return request
|
const DEFAULT_API_BASE_URL = resolveApiBaseUrl();
|
||||||
.post<{
|
|
||||||
content_sha256: string;
|
|
||||||
content_id_v1: string;
|
|
||||||
content_url: string;
|
|
||||||
}>("/storage", formData, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "multipart/form-data",
|
|
||||||
},
|
|
||||||
|
|
||||||
onUploadProgress: (progressEvent) => {
|
const resolveUploadEndpoints = () => {
|
||||||
const percentCompleted = Math.round(
|
try {
|
||||||
(progressEvent.loaded * 100) / (progressEvent?.total as number) ??
|
const apiUrl = new URL(DEFAULT_API_BASE_URL);
|
||||||
0,
|
const origin = apiUrl.origin.replace(/\/$/, "");
|
||||||
);
|
return {
|
||||||
setUploadProgress(percentCompleted);
|
storage: `${origin}/api/v1.5/storage`,
|
||||||
},
|
tus: `${origin}/tus/files`,
|
||||||
})
|
};
|
||||||
.then((response) => {
|
} catch (error) {
|
||||||
setIsUploading(false);
|
if (typeof window !== "undefined") {
|
||||||
return response.data;
|
const origin = window.location.origin.replace(/\/$/, "");
|
||||||
})
|
return {
|
||||||
.catch((error) => {
|
storage: `${origin}/api/v1.5/storage`,
|
||||||
setIsUploading(false);
|
tus: `${origin}/tus/files`,
|
||||||
throw error;
|
};
|
||||||
});
|
}
|
||||||
|
|
||||||
|
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 TUS_STATUS_POLL_INTERVAL_MS = 2000;
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ...mutation, uploadProgress, isUploading };
|
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 [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState<Error | null>(null);
|
||||||
|
const activeUploadRef = useRef<Upload | null>(null);
|
||||||
|
const lastLoggedProgressRef = useRef(-10);
|
||||||
|
|
||||||
|
const mutation = useMutation<TusUploadResult, Error, TusUploadArgs>(
|
||||||
|
["upload-file", "tus"],
|
||||||
|
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);
|
||||||
|
setUploadProgress(0);
|
||||||
|
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 {
|
||||||
|
if (file.size <= MAX_CHUNK_SIZE) {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"X-File-Name": btoa(unescape(encodeURIComponent(file.name))),
|
||||||
|
"X-Chunk-Start": "0",
|
||||||
|
"Content-Type": file.type || "application/octet-stream",
|
||||||
|
"X-Last-Chunk": "1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const authToken = localStorage.getItem("auth_v1_token");
|
||||||
|
if (authToken) {
|
||||||
|
headers.Authorization = authToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request.post<{
|
||||||
|
upload_id?: string;
|
||||||
|
content_sha256?: string;
|
||||||
|
content_id?: string;
|
||||||
|
content_id_v1?: string;
|
||||||
|
content_url?: string;
|
||||||
|
}>("", file, {
|
||||||
|
baseURL: STORAGE_API_URL,
|
||||||
|
headers,
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
const total = progressEvent?.total ?? file.size;
|
||||||
|
const percentCompleted = Math.round(
|
||||||
|
(progressEvent.loaded * 100) / total,
|
||||||
|
);
|
||||||
|
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: 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.data.content_id) {
|
||||||
|
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 {
|
||||||
|
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.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = new Error(
|
||||||
|
"All chunks uploaded but server did not return content_id",
|
||||||
|
);
|
||||||
|
setUploadError(error);
|
||||||
|
lastError = error;
|
||||||
|
console.error("[App2Client][LegacyUpload] Все чанки отправлены, но content_id отсутствует", {
|
||||||
|
fileName: file.name,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
} catch (error) {
|
||||||
|
const normalized = normalizeError(
|
||||||
|
error,
|
||||||
|
"Unknown error during legacy upload",
|
||||||
|
);
|
||||||
|
setUploadError(normalized);
|
||||||
|
setIsUploading(false);
|
||||||
|
lastError = normalized;
|
||||||
|
console.error("[App2Client][LegacyUpload] Ошибка во время загрузки", {
|
||||||
|
fileName: file.name,
|
||||||
|
error: normalized,
|
||||||
|
});
|
||||||
|
throw normalized;
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
console.info("[App2Client][LegacyUpload] Завершили работу загрузчика", {
|
||||||
|
fileName: file.name,
|
||||||
|
hasError: Boolean(lastError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetUploadError = () => setUploadError(null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mutation,
|
||||||
|
uploadProgress,
|
||||||
|
isUploading,
|
||||||
|
uploadError,
|
||||||
|
resetUploadError,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export { useTusUpload as useUploadFile };
|
||||||
|
export type { TusUploadArgs, TusUploadResult, UploadSessionState };
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import * as Sentry from "@sentry/react";
|
||||||
|
import {
|
||||||
|
createRoutesFromChildren,
|
||||||
|
matchRoutes,
|
||||||
|
useLocation,
|
||||||
|
useNavigationType,
|
||||||
|
} from "react-router-dom";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: import.meta.env.VITE_SENTRY_DSN,
|
||||||
|
integrations: [
|
||||||
|
// See docs for support of different versions of variation of react router
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/react/configuration/integrations/react-router/
|
||||||
|
Sentry.reactRouterV6BrowserTracingIntegration({
|
||||||
|
useEffect,
|
||||||
|
useLocation,
|
||||||
|
useNavigationType,
|
||||||
|
createRoutesFromChildren,
|
||||||
|
matchRoutes,
|
||||||
|
}),
|
||||||
|
Sentry.replayIntegration(),
|
||||||
|
],
|
||||||
|
enabled: import.meta.env.PROD, // Only enable in production
|
||||||
|
environment: import.meta.env.MODE, // Set the environment
|
||||||
|
// Set tracesSampleRate to 1.0 to capture 100%
|
||||||
|
// of transactions for tracing.
|
||||||
|
// Learn more at
|
||||||
|
// https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate
|
||||||
|
tracesSampleRate: 1,
|
||||||
|
|
||||||
|
// Set `tracePropagationTargets` to control for which URLs trace propagation should be enabled
|
||||||
|
tracePropagationTargets: [
|
||||||
|
// /^\//,
|
||||||
|
// new RegExp(`^${import.meta.env.VITE_API_BASE_URL}`),
|
||||||
|
],
|
||||||
|
// Capture Replay for 10% of all sessions,
|
||||||
|
// plus for 100% of sessions with an error
|
||||||
|
// Learn more at
|
||||||
|
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
|
||||||
|
replaysSessionSampleRate: 0,
|
||||||
|
replaysOnErrorSampleRate: 0,
|
||||||
|
});
|
||||||
|
|
@ -1,82 +1,135 @@
|
||||||
import { create } from "zustand";
|
import { create } from 'zustand';
|
||||||
|
|
||||||
export type Royalty = {
|
export type Royalty = {
|
||||||
address: string;
|
address: string;
|
||||||
value: number;
|
value: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NotificationType = 'success' | 'danger' | 'warning' | 'info';
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
type: NotificationType;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
type RootStore = {
|
type RootStore = {
|
||||||
name: string;
|
name: string;
|
||||||
setName: (name: string) => void;
|
setName: (name: string) => void;
|
||||||
|
|
||||||
author: string;
|
author: string;
|
||||||
setAuthor: (author: string) => void;
|
setAuthor: (author: string) => void;
|
||||||
|
|
||||||
file: File | null;
|
file: File | null;
|
||||||
setFile: (file: File) => void;
|
setFile: (file: File | null) => void;
|
||||||
|
|
||||||
fileSrc: string;
|
fileType: string;
|
||||||
setFileSrc: (fileSrc: string) => void;
|
setFileType: (type: string) => void;
|
||||||
|
|
||||||
allowCover: boolean;
|
fileSrc: string;
|
||||||
setAllowCover: (allowCover: boolean) => void;
|
setFileSrc: (fileSrc: string) => void;
|
||||||
|
|
||||||
cover: File | null;
|
allowCover: boolean;
|
||||||
setCover: (cover: File | null) => void;
|
setAllowCover: (allowCover: boolean) => void;
|
||||||
|
|
||||||
isPercentHintOpen: boolean;
|
allowDwnld: boolean;
|
||||||
setPercentHintOpen: (isPercentHintOpen: boolean) => void;
|
setAllowDwnld: (allowDwnld: boolean) => void;
|
||||||
|
|
||||||
authors: string[];
|
cover: File | null;
|
||||||
setAuthors: (authors: string[]) => void;
|
setCover: (cover: File | null) => void;
|
||||||
|
|
||||||
royalty: Royalty[];
|
isPercentHintOpen: boolean;
|
||||||
setRoyalty: (authors: Royalty[]) => void;
|
setPercentHintOpen: (isPercentHintOpen: boolean) => void;
|
||||||
|
|
||||||
price: number;
|
authors: string[];
|
||||||
setPrice: (price: number) => void;
|
setAuthors: (authors: string[]) => void;
|
||||||
|
|
||||||
allowResale: boolean;
|
royalty: Royalty[];
|
||||||
setAllowResale: (allowResale: boolean) => void;
|
setRoyalty: (authors: Royalty[]) => void;
|
||||||
|
|
||||||
licenseResalePrice: number;
|
price: number;
|
||||||
setLicenseResalePrice: (licenseResalePrice: number) => void;
|
setPrice: (price: number) => void;
|
||||||
|
|
||||||
|
allowResale: boolean;
|
||||||
|
setAllowResale: (allowResale: boolean) => void;
|
||||||
|
|
||||||
|
licenseResalePrice: number;
|
||||||
|
setLicenseResalePrice: (licenseResalePrice: number) => void;
|
||||||
|
|
||||||
|
hashtags: string[];
|
||||||
|
setHashtags: (hashtags: string[]) => void;
|
||||||
|
|
||||||
|
notifications: Notification[];
|
||||||
|
addNotification: (message: string, type: NotificationType) => void;
|
||||||
|
removeNotification: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useRootStore = create<RootStore>((set) => ({
|
export const useRootStore = create<RootStore>((set) => ({
|
||||||
name: "",
|
name: '',
|
||||||
setName: (name) => set({ name }),
|
setName: (name) => set({ name }),
|
||||||
|
|
||||||
author: "",
|
author: '',
|
||||||
setAuthor: (author) => set({ author }),
|
setAuthor: (author) => set({ author }),
|
||||||
|
|
||||||
file: null,
|
file: null,
|
||||||
setFile: (file) => set({ file }),
|
setFile: (file) => set({ file }),
|
||||||
|
|
||||||
fileSrc: "",
|
fileType: '',
|
||||||
setFileSrc: (fileSrc) => set({ fileSrc }),
|
setFileType: (fileType) => set({ fileType }),
|
||||||
|
|
||||||
allowCover: false,
|
fileSrc: '',
|
||||||
setAllowCover: (allowCover) => set({ allowCover }),
|
setFileSrc: (fileSrc) => set({ fileSrc }),
|
||||||
|
|
||||||
cover: null,
|
allowCover: false,
|
||||||
setCover: (cover) => set({ cover }),
|
setAllowCover: (allowCover) => set({ allowCover }),
|
||||||
|
|
||||||
isPercentHintOpen: true,
|
allowDwnld: false,
|
||||||
setPercentHintOpen: (isPercentHintOpen) => set({ isPercentHintOpen }),
|
setAllowDwnld: (allowDwnld) => set({ allowDwnld }),
|
||||||
|
|
||||||
authors: [],
|
cover: null,
|
||||||
setAuthors: (authors) => set({ authors }),
|
setCover: (cover) => set({ cover }),
|
||||||
|
|
||||||
royalty: [{ address: "", value: 100 }],
|
isPercentHintOpen: true,
|
||||||
setRoyalty: (royalty) => set({ royalty }),
|
setPercentHintOpen: (isPercentHintOpen) => set({ isPercentHintOpen }),
|
||||||
|
|
||||||
price: 0,
|
authors: [],
|
||||||
setPrice: (price: number) => set({ price }),
|
setAuthors: (authors) => set({ authors }),
|
||||||
|
|
||||||
allowResale: false,
|
royalty: [],
|
||||||
setAllowResale: (allowResale) => set({ allowResale }),
|
setRoyalty: (royalty) => set({ royalty }),
|
||||||
|
|
||||||
licenseResalePrice: 0,
|
price: 0.15,
|
||||||
setLicenseResalePrice: (licenseResalePrice) => set({ licenseResalePrice }),
|
setPrice: (price: number) => set({ price }),
|
||||||
|
|
||||||
|
allowResale: false,
|
||||||
|
setAllowResale: (allowResale) => set({ allowResale }),
|
||||||
|
|
||||||
|
licenseResalePrice: 0,
|
||||||
|
setLicenseResalePrice: (licenseResalePrice) => set({ licenseResalePrice }),
|
||||||
|
|
||||||
|
hashtags: [],
|
||||||
|
setHashtags: (hashtags: string[]) => set({ hashtags }),
|
||||||
|
|
||||||
|
notifications: [],
|
||||||
|
|
||||||
|
addNotification: (message: string, type: NotificationType = 'info') => {
|
||||||
|
const id = Date.now().toString();
|
||||||
|
const notification = {
|
||||||
|
id,
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
notifications: [...state.notifications, notification],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
removeNotification: (id: string) => {
|
||||||
|
set((state) => ({
|
||||||
|
notifications: state.notifications.filter((notification) => notification.id !== id),
|
||||||
|
}));
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { FC, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface AudioPlayerProps {
|
||||||
|
src: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AudioPlayer: FC<AudioPlayerProps> = ({ src }) => {
|
||||||
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||||
|
const [duration, setDuration] = useState<number>(0);
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const rangeRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
|
||||||
|
if (!audio) return;
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
setDuration(audio.duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
setCurrentTime(audio.currentTime);
|
||||||
|
updateRangeBackground(audio.currentTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.addEventListener("loadedmetadata", handleLoadedMetadata);
|
||||||
|
audio.addEventListener("timeupdate", handleTimeUpdate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||||||
|
audio.removeEventListener("timeupdate", handleTimeUpdate);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePlayPause = () => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
audio.pause();
|
||||||
|
} else {
|
||||||
|
audio.play();
|
||||||
|
}
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
|
||||||
|
const newTime = parseFloat(e.target.value);
|
||||||
|
audio.currentTime = newTime;
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
updateRangeBackground(newTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (time: number): string => {
|
||||||
|
const minutes = Math.floor(time / 60);
|
||||||
|
const seconds = Math.floor(time % 60);
|
||||||
|
return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRangeBackground = (currentTime: number) => {
|
||||||
|
if (rangeRef.current) {
|
||||||
|
const percentage = (currentTime / duration) * 100;
|
||||||
|
rangeRef.current.style.setProperty(
|
||||||
|
"--value-percentage",
|
||||||
|
`${percentage}%`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<audio ref={audioRef} autoPlay={true} src={src} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={togglePlayPause}
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white text-gray focus:outline-none"
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="size-6"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M6.75 5.25a.75.75 0 0 1 .75-.75H9a.75.75 0 0 1 .75.75v13.5a.75.75 0 0 1-.75.75H7.5a.75.75 0 0 1-.75-.75V5.25Zm7.5 0A.75.75 0 0 1 15 4.5h1.5a.75.75 0 0 1 .75.75v13.5a.75.75 0 0 1-.75.75H15a.75.75 0 0 1-.75-.75V5.25Z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="size-6"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M4.5 5.653c0-1.427 1.529-2.33 2.779-1.643l11.54 6.347c1.295.712 1.295 2.573 0 3.286L7.28 19.99c-1.25.687-2.779-.217-2.779-1.643V5.653Z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={"flex w-full flex-col gap-2"}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
ref={rangeRef}
|
||||||
|
min="0"
|
||||||
|
max={duration}
|
||||||
|
value={currentTime}
|
||||||
|
onChange={handleSliderChange}
|
||||||
|
className="flex-grow"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span>{formatTime(currentTime)}</span>
|
||||||
|
<span>{formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { WithContext as ReactTags, SEPARATORS } from "react-tag-input";
|
||||||
|
import { useRootStore } from "~/shared/stores/root";
|
||||||
|
// English comment: no extra Tag interface, just cast to any
|
||||||
|
export const HashtagInput = () => {
|
||||||
|
const { hashtags, setHashtags } = useRootStore();
|
||||||
|
|
||||||
|
// English comment: local state as string[] for simplicity
|
||||||
|
const [tags, setTags] = useState<string[]>(hashtags);
|
||||||
|
|
||||||
|
const separators = [SEPARATORS.ENTER, SEPARATORS.COMMA]
|
||||||
|
const handleDelete = (i: number) => {
|
||||||
|
const newTags = tags.filter((_, index) => index !== i);
|
||||||
|
setTags(newTags);
|
||||||
|
setHashtags(newTags);
|
||||||
|
};
|
||||||
|
|
||||||
|
// English comment: pass "any" to the function
|
||||||
|
const handleAddition = (newTag: any) => {
|
||||||
|
// Clean up text from commas and trim whitespace
|
||||||
|
const text = newTag?.text || newTag?.id || "";
|
||||||
|
const cleanText = text.replace(/,/g, '').trim();
|
||||||
|
|
||||||
|
// Skip empty tags
|
||||||
|
if (!cleanText) return;
|
||||||
|
|
||||||
|
const updatedTags = [...tags, cleanText];
|
||||||
|
setTags(updatedTags);
|
||||||
|
setHashtags(updatedTags);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simulate Enter keypress for Android
|
||||||
|
const enterEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Enter',
|
||||||
|
code: 'Enter',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
which: 13,
|
||||||
|
keyCode: 13,
|
||||||
|
});
|
||||||
|
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Unidentified') {
|
||||||
|
const lastChar = e.currentTarget.value.slice(-1);
|
||||||
|
if (lastChar === ',') {
|
||||||
|
e.currentTarget.dispatchEvent(enterEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactTags
|
||||||
|
tags={tags.map((t) => ({ id: t, text: t })) as any}
|
||||||
|
separators={separators as any}
|
||||||
|
handleDelete={handleDelete as any}
|
||||||
|
handleAddition={handleAddition as any}
|
||||||
|
allowDragDrop={false}
|
||||||
|
placeholder="[ введите тэги через запятую ]"
|
||||||
|
inputProps={{
|
||||||
|
onKeyUp: handleKeyUp as any
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export const Replace = () => {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g opacity="1">
|
||||||
|
<path d="M15.75 6.01C15.9293 6.01 16.0939 6.07292 16.2229 6.17788C17.3108 7.09184 18.0024 8.46516 18.0024 10C18.0024 12.6888 15.8801 14.8818 13.2193 14.9954L13.0024 15H8.56057L9.78032 16.2197C10.0466 16.4859 10.0708 16.9026 9.85294 17.1962L9.78032 17.2803C9.51406 17.5466 9.09739 17.5708 8.80378 17.3529L8.71966 17.2803L6.21966 14.7803C5.9534 14.5141 5.92919 14.0974 6.14704 13.8038L6.21966 13.7197L8.71966 11.2197C9.01256 10.9268 9.48743 10.9268 9.78032 11.2197C10.0466 11.4859 10.0708 11.9026 9.85294 12.1962L9.78032 12.2803L8.56057 13.5H13.0024C14.871 13.5 16.3975 12.0357 16.4972 10.192L16.5024 10C16.5024 8.91885 16.0122 7.95219 15.2419 7.31017C15.0935 7.17538 15 6.97861 15 6.76C15 6.34579 15.3358 6.01 15.75 6.01ZM10.2197 2.71967C10.4859 2.4534 10.9026 2.4292 11.1962 2.64705L11.2803 2.71967L13.7803 5.21967L13.8529 5.30379C14.0466 5.56478 14.049 5.92299 13.8601 6.18638L13.7803 6.28033L11.2803 8.78033L11.1962 8.85295C10.9352 9.0466 10.577 9.04899 10.3136 8.86012L10.2197 8.78033L10.147 8.69621C9.9534 8.43522 9.951 8.07701 10.1399 7.81362L10.2197 7.71967L11.4386 6.5H6.99757C5.12901 6.5 3.60245 7.96428 3.50275 9.80796L3.49757 10C3.49757 11.0831 3.98958 12.0514 4.7623 12.6934C4.9085 12.8289 4.99999 13.0238 4.99999 13.24C4.99999 13.6542 4.66421 13.99 4.24999 13.99C4.05871 13.99 3.88415 13.9184 3.75166 13.8005C2.67865 12.8872 1.99757 11.5232 1.99757 10C1.99757 7.31124 4.11988 5.11818 6.78068 5.00462L6.99757 5H11.4386L10.2197 3.78033L10.147 3.69621C9.92919 3.4026 9.9534 2.98594 10.2197 2.71967Z" fill="white" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRootStore } from '~/shared/stores/root';
|
||||||
|
|
||||||
|
interface AnimatedNotification {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
type: 'success' | 'danger' | 'warning' | 'info';
|
||||||
|
isExiting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Notification = () => {
|
||||||
|
const { notifications, removeNotification } = useRootStore();
|
||||||
|
const [animatedNotifications, setAnimatedNotifications] = useState<AnimatedNotification[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentIds = animatedNotifications.map((n) => n.id);
|
||||||
|
const newNotifications = notifications
|
||||||
|
.filter((n) => !currentIds.includes(n.id))
|
||||||
|
.map((n) => ({ ...n, isExiting: false }));
|
||||||
|
|
||||||
|
if (newNotifications.length > 0) {
|
||||||
|
newNotifications.forEach((notification) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
handleRemove(notification.id);
|
||||||
|
}, 4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
setAnimatedNotifications((prev) => [...prev, ...newNotifications]);
|
||||||
|
}
|
||||||
|
}, [notifications]);
|
||||||
|
|
||||||
|
// Функция для начала анимации удаления
|
||||||
|
const handleRemove = (id: string) => {
|
||||||
|
setAnimatedNotifications((prev) =>
|
||||||
|
prev.map((n) => (n.id === id ? { ...n, isExiting: true } : n))
|
||||||
|
);
|
||||||
|
setTimeout(() => {
|
||||||
|
setAnimatedNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||||
|
removeNotification(id);
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-x-0 top-2 z-50 flex flex-col items-center gap-2 pointer-events-none">
|
||||||
|
{animatedNotifications.map((notification) => (
|
||||||
|
<div
|
||||||
|
key={notification.id}
|
||||||
|
onClick={() => handleRemove(notification.id)}
|
||||||
|
className={`
|
||||||
|
p-2 rounded shadow-md relative transition-all duration-300 max-w-sm
|
||||||
|
transform origin-top pointer-events-auto cursor-pointer
|
||||||
|
${notification.isExiting ? 'animate-fade-out' : 'animate-slide-in-top'}
|
||||||
|
${getNotificationClass(notification.type)}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="text-center">{notification.message}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNotificationClass = (type: 'success' | 'danger' | 'warning' | 'info'): string => {
|
||||||
|
const baseStyle = 'border border-white bg-opacity-70 backdrop-blur-sm';
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'success':
|
||||||
|
return `${baseStyle} bg-primary text-white`;
|
||||||
|
case 'danger':
|
||||||
|
return `${baseStyle} bg-primary text-white`;
|
||||||
|
case 'warning':
|
||||||
|
return `${baseStyle} bg-primary text-white`;
|
||||||
|
case 'info':
|
||||||
|
default:
|
||||||
|
return `${baseStyle} bg-primary text-white`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -17,3 +17,5 @@ export const processFile = async (file: File) => {
|
||||||
|
|
||||||
export const getIndexArray = (len: number) =>
|
export const getIndexArray = (len: number) =>
|
||||||
new Array(len).fill("").map((_, i) => i);
|
new Array(len).fill("").map((_, i) => i);
|
||||||
|
|
||||||
|
export const fromNanoTON = (amount: string) => Number(amount) / 10 ** 9;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
@ -1,13 +1,33 @@
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
gray: "#1d1d1b",
|
gray: '#1d1d1b',
|
||||||
primary: "#e40615",
|
primary: '#e40615',
|
||||||
},
|
},
|
||||||
|
keyframes: {
|
||||||
|
'slide-in-top': {
|
||||||
|
'0%': {
|
||||||
|
transform: 'translateY(-1rem)',
|
||||||
|
opacity: '0',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
transform: 'translateY(0)',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'fade-out': {
|
||||||
|
'0%': { opacity: '1' },
|
||||||
|
'100%': { opacity: '0' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'slide-in-top': 'slide-in-top 0.3s ease-out forwards',
|
||||||
|
'fade-out': 'fade-out 0.3s ease-out forwards',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
plugins: [],
|
||||||
plugins: [],
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"rewrites": [{ "source": "/(.*)", "destination": "/" }]
|
||||||
|
}
|
||||||
|
|
@ -4,4 +4,19 @@ import TSPaths from "vite-tsconfig-paths";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), TSPaths()],
|
plugins: [react(), TSPaths()],
|
||||||
});
|
define: {
|
||||||
|
global: 'globalThis',
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
buffer: 'buffer/',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
esbuildOptions: {
|
||||||
|
define: {
|
||||||
|
global: 'globalThis'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
463
yarn.lock
463
yarn.lock
|
|
@ -184,7 +184,7 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/helper-plugin-utils" "^7.22.5"
|
"@babel/helper-plugin-utils" "^7.22.5"
|
||||||
|
|
||||||
"@babel/runtime@^7.23.7", "@babel/runtime@^7.23.8", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2":
|
"@babel/runtime@^7.23.7", "@babel/runtime@^7.23.8", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.9.2":
|
||||||
version "7.24.0"
|
version "7.24.0"
|
||||||
resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.0.tgz"
|
resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.0.tgz"
|
||||||
integrity sha512-Chk32uHMg6TnQdvw2e9IlqPpFX/6NLuK0Ys2PqLb7/gL5uFn9mXvK715FGLlOLQrcO4qIkNHkvPGktzzXexsFw==
|
integrity sha512-Chk32uHMg6TnQdvw2e9IlqPpFX/6NLuK0Ys2PqLb7/gL5uFn9mXvK715FGLlOLQrcO4qIkNHkvPGktzzXexsFw==
|
||||||
|
|
@ -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/darwin-arm64@0.19.12":
|
"@esbuild/linux-x64@0.19.12":
|
||||||
version "0.19.12"
|
version "0.19.12"
|
||||||
resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz"
|
resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz"
|
||||||
integrity sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==
|
integrity sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==
|
||||||
|
|
||||||
"@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"
|
||||||
|
|
@ -361,15 +361,162 @@
|
||||||
resolved "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz"
|
resolved "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz"
|
||||||
integrity sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==
|
integrity sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==
|
||||||
|
|
||||||
|
"@react-dnd/asap@^4.0.0":
|
||||||
|
version "4.0.1"
|
||||||
|
resolved "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz"
|
||||||
|
integrity sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==
|
||||||
|
|
||||||
|
"@react-dnd/invariant@^2.0.0":
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz"
|
||||||
|
integrity sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==
|
||||||
|
|
||||||
|
"@react-dnd/shallowequal@^2.0.0":
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz"
|
||||||
|
integrity sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==
|
||||||
|
|
||||||
"@remix-run/router@1.15.2":
|
"@remix-run/router@1.15.2":
|
||||||
version "1.15.2"
|
version "1.15.2"
|
||||||
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-darwin-arm64@4.12.0":
|
"@rollup/rollup-linux-x64-gnu@4.12.0":
|
||||||
version "4.12.0"
|
version "4.12.0"
|
||||||
resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.12.0.tgz"
|
resolved "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.12.0.tgz"
|
||||||
integrity sha512-X64tZd8dRE/QTrBIEs63kaOBG0b5GVEd3ccoLtyf6IdXtHdh8h+I56C2yC3PtC9Ucnv0CpNFJLqKFVgCYe0lOQ==
|
integrity sha512-TenQhZVOtw/3qKOPa7d+QgkeM6xY0LtwzR8OplmyL5LrgTWIXpTQg2Q2ycBf8jm+SFW2Wt/DTn1gf7nFp3ssVA==
|
||||||
|
|
||||||
|
"@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":
|
||||||
|
version "9.1.0"
|
||||||
|
resolved "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.1.0.tgz"
|
||||||
|
integrity sha512-S1uT+kkFlstWpwnaBTIJSwwAID8PS3aA0fIidOjNezeoUE5gOvpsjDATo9q+sl6FbGWynxMz6EnYSrq/5tuaBQ==
|
||||||
|
dependencies:
|
||||||
|
"@sentry/core" "9.1.0"
|
||||||
|
|
||||||
|
"@sentry-internal/feedback@9.1.0":
|
||||||
|
version "9.1.0"
|
||||||
|
resolved "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.1.0.tgz"
|
||||||
|
integrity sha512-jTDCqkqH3QDC8m9WO4mB06hqnBRsl3p7ozoh0E774UvNB6blOEZjShhSGMMEy5jbbJajPWsOivCofUtFAwbfGw==
|
||||||
|
dependencies:
|
||||||
|
"@sentry/core" "9.1.0"
|
||||||
|
|
||||||
|
"@sentry-internal/replay-canvas@9.1.0":
|
||||||
|
version "9.1.0"
|
||||||
|
resolved "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.1.0.tgz"
|
||||||
|
integrity sha512-gxredVe+mOgfNqDJ3dTLiRON3FK1rZ8d0LHp7TICK/umLkWFkuso0DbNeyKU+3XCEjCr9VM7ZRqTDMzmY6zyVg==
|
||||||
|
dependencies:
|
||||||
|
"@sentry-internal/replay" "9.1.0"
|
||||||
|
"@sentry/core" "9.1.0"
|
||||||
|
|
||||||
|
"@sentry-internal/replay@9.1.0":
|
||||||
|
version "9.1.0"
|
||||||
|
resolved "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.1.0.tgz"
|
||||||
|
integrity sha512-E2xrUoms90qvm0BVOuaZ8QfkMoTUEgoIW/35uOeaqNcL7uOIj8c5cSEQQKit2Dr7CL6W+Ci5c6Khdyd5C0NL5w==
|
||||||
|
dependencies:
|
||||||
|
"@sentry-internal/browser-utils" "9.1.0"
|
||||||
|
"@sentry/core" "9.1.0"
|
||||||
|
|
||||||
|
"@sentry/browser@9.1.0":
|
||||||
|
version "9.1.0"
|
||||||
|
resolved "https://registry.npmjs.org/@sentry/browser/-/browser-9.1.0.tgz"
|
||||||
|
integrity sha512-G55e5j77DqRW3LkalJLAjRRfuyKrjHaKTnwIYXa6ycO+Q1+l14pEUxu+eK5Abu2rtSdViwRSb5/G6a/miSUlYA==
|
||||||
|
dependencies:
|
||||||
|
"@sentry-internal/browser-utils" "9.1.0"
|
||||||
|
"@sentry-internal/feedback" "9.1.0"
|
||||||
|
"@sentry-internal/replay" "9.1.0"
|
||||||
|
"@sentry-internal/replay-canvas" "9.1.0"
|
||||||
|
"@sentry/core" "9.1.0"
|
||||||
|
|
||||||
|
"@sentry/core@9.1.0":
|
||||||
|
version "9.1.0"
|
||||||
|
resolved "https://registry.npmjs.org/@sentry/core/-/core-9.1.0.tgz"
|
||||||
|
integrity sha512-djWEzSBpMgqdF3GQuxO+kXCUX+Mgq42G4Uah/HSUBvPDHKipMmyWlutGRoFyVPPOnCDgpHu3wCt83wbpEyVmDw==
|
||||||
|
|
||||||
|
"@sentry/react@^9.1.0":
|
||||||
|
version "9.1.0"
|
||||||
|
resolved "https://registry.npmjs.org/@sentry/react/-/react-9.1.0.tgz"
|
||||||
|
integrity sha512-aP2sXHH+erbomuzU762ktg340IGDh8zD7ueuqwBwGu98fhCpTYsLXiS85I29tUvPLljwNU9puLPmxbgW4iZ2tQ==
|
||||||
|
dependencies:
|
||||||
|
"@sentry/browser" "9.1.0"
|
||||||
|
"@sentry/core" "9.1.0"
|
||||||
|
hoist-non-react-statics "^3.3.2"
|
||||||
|
|
||||||
|
"@ton/core@^0.59.1":
|
||||||
|
version "0.59.1"
|
||||||
|
resolved "https://registry.npmjs.org/@ton/core/-/core-0.59.1.tgz"
|
||||||
|
integrity sha512-SxFBAvutYJaIllTkv82vbHTJhJI6NxzqUhi499CDEjJEZ9i6i9lHJiK2df4dlLAb/4SiWX6+QUzESkK4DEdnCw==
|
||||||
|
dependencies:
|
||||||
|
symbol.inspect "1.0.1"
|
||||||
|
|
||||||
|
"@ton/crypto-primitives@2.1.0":
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.npmjs.org/@ton/crypto-primitives/-/crypto-primitives-2.1.0.tgz"
|
||||||
|
integrity sha512-PQesoyPgqyI6vzYtCXw4/ZzevePc4VGcJtFwf08v10OevVJHVfW238KBdpj1kEDQkxWLeuNHEpTECNFKnP6tow==
|
||||||
|
dependencies:
|
||||||
|
jssha "3.2.0"
|
||||||
|
|
||||||
|
"@ton/crypto@>=3.2.0":
|
||||||
|
version "3.3.0"
|
||||||
|
resolved "https://registry.npmjs.org/@ton/crypto/-/crypto-3.3.0.tgz"
|
||||||
|
integrity sha512-/A6CYGgA/H36OZ9BbTaGerKtzWp50rg67ZCH2oIjV1NcrBaCK9Z343M+CxedvM7Haf3f/Ee9EhxyeTp0GKMUpA==
|
||||||
|
dependencies:
|
||||||
|
"@ton/crypto-primitives" "2.1.0"
|
||||||
|
jssha "3.2.0"
|
||||||
|
tweetnacl "1.0.3"
|
||||||
|
|
||||||
|
"@tonconnect/isomorphic-eventsource@^0.0.2":
|
||||||
|
version "0.0.2"
|
||||||
|
resolved "https://registry.npmjs.org/@tonconnect/isomorphic-eventsource/-/isomorphic-eventsource-0.0.2.tgz"
|
||||||
|
integrity sha512-B4UoIjPi0QkvIzZH5fV3BQLWrqSYABdrzZQSI9sJA9aA+iC0ohOzFwVVGXanlxeDAy1bcvPbb29f6sVUk0UnnQ==
|
||||||
|
dependencies:
|
||||||
|
eventsource "^2.0.2"
|
||||||
|
|
||||||
|
"@tonconnect/isomorphic-fetch@^0.0.3":
|
||||||
|
version "0.0.3"
|
||||||
|
resolved "https://registry.npmjs.org/@tonconnect/isomorphic-fetch/-/isomorphic-fetch-0.0.3.tgz"
|
||||||
|
integrity sha512-jIg5nTrDwnite4fXao3dD83eCpTvInTjZon/rZZrIftIegh4XxyVb5G2mpMqXrVGk1e8SVXm3Kj5OtfMplQs0w==
|
||||||
|
dependencies:
|
||||||
|
node-fetch "^2.6.9"
|
||||||
|
|
||||||
|
"@tonconnect/protocol@^2.2.6":
|
||||||
|
version "2.2.6"
|
||||||
|
resolved "https://registry.npmjs.org/@tonconnect/protocol/-/protocol-2.2.6.tgz"
|
||||||
|
integrity sha512-kyoDz5EqgsycYP+A+JbVsAUYHNT059BCrK+m0pqxykMODwpziuSAXfwAZmHcg8v7NB9VKYbdFY55xKeXOuEd0w==
|
||||||
|
dependencies:
|
||||||
|
tweetnacl "^1.0.3"
|
||||||
|
tweetnacl-util "^0.15.1"
|
||||||
|
|
||||||
|
"@tonconnect/sdk@3.0.6":
|
||||||
|
version "3.0.6"
|
||||||
|
resolved "https://registry.npmjs.org/@tonconnect/sdk/-/sdk-3.0.6.tgz"
|
||||||
|
integrity sha512-dJipe0Cw43p/7o3Pa6Y6h0QMDtY2V2YKzwdCqcYvmyCYadBNmvA+8ScH9QK5GpkngRJnYaWq+321lAaQTFpUwA==
|
||||||
|
dependencies:
|
||||||
|
"@tonconnect/isomorphic-eventsource" "^0.0.2"
|
||||||
|
"@tonconnect/isomorphic-fetch" "^0.0.3"
|
||||||
|
"@tonconnect/protocol" "^2.2.6"
|
||||||
|
|
||||||
|
"@tonconnect/ui-react@^2.0.2":
|
||||||
|
version "2.0.11"
|
||||||
|
resolved "https://registry.npmjs.org/@tonconnect/ui-react/-/ui-react-2.0.11.tgz"
|
||||||
|
integrity sha512-h8E4zlbdNBJCAPgg6+O5ZkVDZh8mvnc82VRmhInSPLFAr6qDZbE+qSjRVm4lkuN1N/m24lhkDXFCFjvJ9CgCow==
|
||||||
|
dependencies:
|
||||||
|
"@tonconnect/ui" "2.0.11"
|
||||||
|
|
||||||
|
"@tonconnect/ui@2.0.11":
|
||||||
|
version "2.0.11"
|
||||||
|
resolved "https://registry.npmjs.org/@tonconnect/ui/-/ui-2.0.11.tgz"
|
||||||
|
integrity sha512-5TOhfEDeyY8R9oyEGavLU+DRmDW3wSGyxVshWhHisi8597cZIuG39HHNbP05WJBBjmVm/Kh/bhcHtfx7lQp/Ig==
|
||||||
|
dependencies:
|
||||||
|
"@tonconnect/sdk" "3.0.6"
|
||||||
|
classnames "^2.3.2"
|
||||||
|
csstype "^3.1.1"
|
||||||
|
deepmerge "^4.2.2"
|
||||||
|
ua-parser-js "^1.0.35"
|
||||||
|
|
||||||
"@types/babel__core@^7.20.5":
|
"@types/babel__core@^7.20.5":
|
||||||
version "7.20.5"
|
version "7.20.5"
|
||||||
|
|
@ -419,7 +566,19 @@
|
||||||
resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz"
|
resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz"
|
||||||
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
|
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
|
||||||
|
|
||||||
"@types/node@^18.0.0 || >=20.0.0", "@types/node@^20.11.24":
|
"@types/lodash-es@^4.17.12":
|
||||||
|
version "4.17.12"
|
||||||
|
resolved "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz"
|
||||||
|
integrity sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==
|
||||||
|
dependencies:
|
||||||
|
"@types/lodash" "*"
|
||||||
|
|
||||||
|
"@types/lodash@*":
|
||||||
|
version "4.17.15"
|
||||||
|
resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz"
|
||||||
|
integrity sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==
|
||||||
|
|
||||||
|
"@types/node@^18.0.0 || >=20.0.0", "@types/node@^20.11.24", "@types/node@>= 12":
|
||||||
version "20.11.24"
|
version "20.11.24"
|
||||||
resolved "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz"
|
resolved "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz"
|
||||||
integrity sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==
|
integrity sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==
|
||||||
|
|
@ -438,7 +597,7 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/react" "*"
|
"@types/react" "*"
|
||||||
|
|
||||||
"@types/react@*", "@types/react@^18.2.56", "@types/react@>=16.8":
|
"@types/react@*", "@types/react@^18.2.56", "@types/react@>= 16", "@types/react@>=16.8":
|
||||||
version "18.2.61"
|
version "18.2.61"
|
||||||
resolved "https://registry.npmjs.org/@types/react/-/react-18.2.61.tgz"
|
resolved "https://registry.npmjs.org/@types/react/-/react-18.2.61.tgz"
|
||||||
integrity sha512-NURTN0qNnJa7O/k4XUkEW2yfygA+NxS0V5h1+kp9jPwhzZy95q3ADoGMP0+JypMhrZBTTgjKAUlTctde1zzeQA==
|
integrity sha512-NURTN0qNnJa7O/k4XUkEW2yfygA+NxS0V5h1+kp9jPwhzZy95q3ADoGMP0+JypMhrZBTTgjKAUlTctde1zzeQA==
|
||||||
|
|
@ -761,6 +920,11 @@ balanced-match@^1.0.0:
|
||||||
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz"
|
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz"
|
||||||
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
||||||
|
|
||||||
|
base64-js@^1.3.1:
|
||||||
|
version "1.5.1"
|
||||||
|
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
|
||||||
|
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||||
|
|
||||||
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"
|
||||||
|
|
@ -817,6 +981,19 @@ 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:
|
||||||
|
version "6.0.3"
|
||||||
|
resolved "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz"
|
||||||
|
integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==
|
||||||
|
dependencies:
|
||||||
|
base64-js "^1.3.1"
|
||||||
|
ieee754 "^1.2.1"
|
||||||
|
|
||||||
call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7:
|
call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7:
|
||||||
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"
|
||||||
|
|
@ -875,6 +1052,16 @@ chokidar@^3.5.3:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents "~2.3.2"
|
fsevents "~2.3.2"
|
||||||
|
|
||||||
|
classnames@^2.3.2:
|
||||||
|
version "2.5.1"
|
||||||
|
resolved "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz"
|
||||||
|
integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==
|
||||||
|
|
||||||
|
classnames@~2.3.1:
|
||||||
|
version "2.3.3"
|
||||||
|
resolved "https://registry.npmjs.org/classnames/-/classnames-2.3.3.tgz"
|
||||||
|
integrity sha512-1inzZmicIFcmUya7PGtUQeXtcF7zZpPnxtQoYOrz0uiOBGlLFa4ik4361seYL2JCcRDIyfdFHiwQolESFlw+Og==
|
||||||
|
|
||||||
clsx@^2.1.0:
|
clsx@^2.1.0:
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz"
|
resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz"
|
||||||
|
|
@ -904,6 +1091,14 @@ 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"
|
||||||
|
|
@ -940,11 +1135,16 @@ cssesc@^3.0.0:
|
||||||
resolved "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz"
|
resolved "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz"
|
||||||
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
|
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
|
||||||
|
|
||||||
csstype@^3.0.2:
|
csstype@^3.0.2, csstype@^3.1.1:
|
||||||
version "3.1.3"
|
version "3.1.3"
|
||||||
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"
|
||||||
|
|
@ -964,7 +1164,7 @@ deep-is@^0.1.3:
|
||||||
resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz"
|
resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz"
|
||||||
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
|
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
|
||||||
|
|
||||||
deepmerge@^4.0.0:
|
deepmerge@^4.0.0, deepmerge@^4.2.2:
|
||||||
version "4.3.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz"
|
resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz"
|
||||||
integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
|
integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
|
||||||
|
|
@ -1014,6 +1214,15 @@ dlv@^1.1.3:
|
||||||
resolved "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz"
|
resolved "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz"
|
||||||
integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==
|
integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==
|
||||||
|
|
||||||
|
dnd-core@14.0.1:
|
||||||
|
version "14.0.1"
|
||||||
|
resolved "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz"
|
||||||
|
integrity sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==
|
||||||
|
dependencies:
|
||||||
|
"@react-dnd/asap" "^4.0.0"
|
||||||
|
"@react-dnd/invariant" "^2.0.0"
|
||||||
|
redux "^4.1.1"
|
||||||
|
|
||||||
doctrine@^2.1.0:
|
doctrine@^2.1.0:
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz"
|
resolved "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz"
|
||||||
|
|
@ -1333,6 +1542,11 @@ esutils@^2.0.2:
|
||||||
resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz"
|
resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz"
|
||||||
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
|
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
|
||||||
|
|
||||||
|
eventsource@^2.0.2:
|
||||||
|
version "2.0.2"
|
||||||
|
resolved "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz"
|
||||||
|
integrity sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==
|
||||||
|
|
||||||
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
|
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
|
||||||
version "3.1.3"
|
version "3.1.3"
|
||||||
resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
|
resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
|
||||||
|
|
@ -1446,11 +1660,6 @@ fs.realpath@^1.0.0:
|
||||||
resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
|
resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
|
||||||
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
|
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
|
||||||
|
|
||||||
fsevents@~2.3.2, fsevents@~2.3.3:
|
|
||||||
version "2.3.3"
|
|
||||||
resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz"
|
|
||||||
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
|
|
||||||
|
|
||||||
function-bind@^1.1.2:
|
function-bind@^1.1.2:
|
||||||
version "1.1.2"
|
version "1.1.2"
|
||||||
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"
|
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"
|
||||||
|
|
@ -1583,6 +1792,11 @@ 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"
|
||||||
|
|
@ -1634,6 +1848,18 @@ hasown@^2.0.0, hasown@^2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
function-bind "^1.1.2"
|
function-bind "^1.1.2"
|
||||||
|
|
||||||
|
hoist-non-react-statics@^3.3.2:
|
||||||
|
version "3.3.2"
|
||||||
|
resolved "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz"
|
||||||
|
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
|
||||||
|
dependencies:
|
||||||
|
react-is "^16.7.0"
|
||||||
|
|
||||||
|
ieee754@^1.2.1:
|
||||||
|
version "1.2.1"
|
||||||
|
resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz"
|
||||||
|
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
|
||||||
|
|
||||||
ignore@^5.2.0, ignore@^5.2.4:
|
ignore@^5.2.0, ignore@^5.2.4:
|
||||||
version "5.3.1"
|
version "5.3.1"
|
||||||
resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz"
|
resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz"
|
||||||
|
|
@ -1777,6 +2003,11 @@ 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"
|
||||||
|
|
@ -1829,6 +2060,11 @@ 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"
|
||||||
|
|
@ -1878,6 +2114,11 @@ json5@^2.2.3:
|
||||||
resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz"
|
resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz"
|
||||||
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
|
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
|
||||||
|
|
||||||
|
jssha@3.2.0:
|
||||||
|
version "3.2.0"
|
||||||
|
resolved "https://registry.npmjs.org/jssha/-/jssha-3.2.0.tgz"
|
||||||
|
integrity sha512-QuruyBENDWdN4tZwJbQq7/eAK85FqrI4oDbXjy5IBhYD+2pTJyBUWZe8ctWaCkrV0gy6AaelgOZZBMeswEa/6Q==
|
||||||
|
|
||||||
keyv@^4.5.3:
|
keyv@^4.5.3:
|
||||||
version "4.5.4"
|
version "4.5.4"
|
||||||
resolved "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz"
|
resolved "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz"
|
||||||
|
|
@ -1920,11 +2161,66 @@ locate-path@^6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-locate "^5.0.0"
|
p-locate "^5.0.0"
|
||||||
|
|
||||||
|
lodash-es@^4.17.21:
|
||||||
|
version "4.17.21"
|
||||||
|
resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz"
|
||||||
|
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"
|
||||||
|
|
@ -2063,6 +2359,13 @@ natural-compare@^1.4.0:
|
||||||
resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz"
|
resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz"
|
||||||
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
||||||
|
|
||||||
|
node-fetch@^2.6.9:
|
||||||
|
version "2.7.0"
|
||||||
|
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz"
|
||||||
|
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
|
||||||
|
dependencies:
|
||||||
|
whatwg-url "^5.0.0"
|
||||||
|
|
||||||
node-releases@^2.0.14:
|
node-releases@^2.0.14:
|
||||||
version "2.0.14"
|
version "2.0.14"
|
||||||
resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz"
|
resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz"
|
||||||
|
|
@ -2324,6 +2627,15 @@ 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"
|
||||||
|
|
@ -2334,12 +2646,35 @@ 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"
|
||||||
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
|
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
|
||||||
|
|
||||||
react-dom@^18, react-dom@^18.2.0, react-dom@>=16.8:
|
react-dnd-html5-backend@^14.0.0:
|
||||||
|
version "14.1.0"
|
||||||
|
resolved "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz"
|
||||||
|
integrity sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==
|
||||||
|
dependencies:
|
||||||
|
dnd-core "14.0.1"
|
||||||
|
|
||||||
|
react-dnd@^14.0.2:
|
||||||
|
version "14.0.5"
|
||||||
|
resolved "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz"
|
||||||
|
integrity sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==
|
||||||
|
dependencies:
|
||||||
|
"@react-dnd/invariant" "^2.0.0"
|
||||||
|
"@react-dnd/shallowequal" "^2.0.0"
|
||||||
|
dnd-core "14.0.1"
|
||||||
|
fast-deep-equal "^3.1.3"
|
||||||
|
hoist-non-react-statics "^3.3.2"
|
||||||
|
|
||||||
|
react-dom@^18, react-dom@^18.2.0, react-dom@>=16.8, react-dom@>=17.0.0:
|
||||||
version "18.2.0"
|
version "18.2.0"
|
||||||
resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz"
|
resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz"
|
||||||
integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
|
integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
|
||||||
|
|
@ -2357,7 +2692,7 @@ react-hook-form@^7.0.0, react-hook-form@^7.51.0:
|
||||||
resolved "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.0.tgz"
|
resolved "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.0.tgz"
|
||||||
integrity sha512-BggOy5j58RdhdMzzRUHGOYhSz1oeylFAv6jUSG86OvCIvlAvS7KvnRY7yoAf2pfEiPN7BesnR0xx73nEk3qIiw==
|
integrity sha512-BggOy5j58RdhdMzzRUHGOYhSz1oeylFAv6jUSG86OvCIvlAvS7KvnRY7yoAf2pfEiPN7BesnR0xx73nEk3qIiw==
|
||||||
|
|
||||||
react-is@^16.13.1:
|
react-is@^16.13.1, react-is@^16.7.0:
|
||||||
version "16.13.1"
|
version "16.13.1"
|
||||||
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
|
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
|
||||||
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||||
|
|
@ -2402,7 +2737,16 @@ react-router@6.22.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
"@remix-run/router" "1.15.2"
|
"@remix-run/router" "1.15.2"
|
||||||
|
|
||||||
"react@^16.8.0 || ^17 || ^18", "react@^16.8.0 || ^17.0.0 || ^18.0.0", react@^18, react@^18.2.0, react@>=16.6.0, react@>=16.8:
|
react-tag-input@^6.10.3:
|
||||||
|
version "6.10.3"
|
||||||
|
resolved "https://registry.npmjs.org/react-tag-input/-/react-tag-input-6.10.3.tgz"
|
||||||
|
integrity sha512-IVneBnT0/7N8FktHsFUA2UVDvcwLSJoWDTdEm/Ei/r5O6iBABaVw1RMh6SlG+P7oummvzSY6EBjGCXr/Ntq8rQ==
|
||||||
|
dependencies:
|
||||||
|
"@types/lodash-es" "^4.17.12"
|
||||||
|
classnames "~2.3.1"
|
||||||
|
lodash-es "^4.17.21"
|
||||||
|
|
||||||
|
"react@^16.14.0 || 17.x || 18.x || 19.x", "react@^16.8.0 || ^17 || ^18", "react@^16.8.0 || ^17.0.0 || ^18.0.0", react@^18, react@^18.2.0, "react@>= 16.14", react@>=16.6.0, react@>=16.8, react@>=17.0.0:
|
||||||
version "18.2.0"
|
version "18.2.0"
|
||||||
resolved "https://registry.npmjs.org/react/-/react-18.2.0.tgz"
|
resolved "https://registry.npmjs.org/react/-/react-18.2.0.tgz"
|
||||||
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
|
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
|
||||||
|
|
@ -2423,6 +2767,13 @@ readdirp@~3.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
picomatch "^2.2.1"
|
picomatch "^2.2.1"
|
||||||
|
|
||||||
|
redux@^4.1.1:
|
||||||
|
version "4.2.1"
|
||||||
|
resolved "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz"
|
||||||
|
integrity sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.9.2"
|
||||||
|
|
||||||
regenerator-runtime@^0.14.0:
|
regenerator-runtime@^0.14.0:
|
||||||
version "0.14.1"
|
version "0.14.1"
|
||||||
resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz"
|
resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz"
|
||||||
|
|
@ -2443,6 +2794,11 @@ 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"
|
||||||
|
|
@ -2457,6 +2813,11 @@ 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"
|
||||||
|
|
@ -2580,6 +2941,11 @@ 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"
|
||||||
|
|
@ -2712,6 +3078,11 @@ supports-preserve-symlinks-flag@^1.0.0:
|
||||||
resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz"
|
resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz"
|
||||||
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
|
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
|
||||||
|
|
||||||
|
symbol.inspect@1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.npmjs.org/symbol.inspect/-/symbol.inspect-1.0.1.tgz"
|
||||||
|
integrity sha512-YQSL4duoHmLhsTD1Pw8RW6TZ5MaTX5rXJnqacJottr2P2LZBF/Yvrc3ku4NUpMOm8aM0KOCqM+UAkMA5HWQCzQ==
|
||||||
|
|
||||||
synckit@^0.8.6:
|
synckit@^0.8.6:
|
||||||
version "0.8.8"
|
version "0.8.8"
|
||||||
resolved "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz"
|
resolved "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz"
|
||||||
|
|
@ -2786,6 +3157,11 @@ to-regex-range@^5.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-number "^7.0.0"
|
is-number "^7.0.0"
|
||||||
|
|
||||||
|
tr46@~0.0.3:
|
||||||
|
version "0.0.3"
|
||||||
|
resolved "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz"
|
||||||
|
integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
|
||||||
|
|
||||||
ts-api-utils@^1.0.1:
|
ts-api-utils@^1.0.1:
|
||||||
version "1.2.1"
|
version "1.2.1"
|
||||||
resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.2.1.tgz"
|
resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.2.1.tgz"
|
||||||
|
|
@ -2816,6 +3192,29 @@ 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:
|
||||||
|
version "0.15.1"
|
||||||
|
resolved "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz"
|
||||||
|
integrity sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==
|
||||||
|
|
||||||
|
tweetnacl@^1.0.3, tweetnacl@1.0.3:
|
||||||
|
version "1.0.3"
|
||||||
|
resolved "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz"
|
||||||
|
integrity sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==
|
||||||
|
|
||||||
type-check@^0.4.0, type-check@~0.4.0:
|
type-check@^0.4.0, type-check@~0.4.0:
|
||||||
version "0.4.0"
|
version "0.4.0"
|
||||||
resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz"
|
resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz"
|
||||||
|
|
@ -2877,6 +3276,11 @@ typescript@^5.0.0, typescript@^5.2.2, typescript@>=4.2.0:
|
||||||
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==
|
||||||
|
|
||||||
|
ua-parser-js@^1.0.35:
|
||||||
|
version "1.0.40"
|
||||||
|
resolved "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.40.tgz"
|
||||||
|
integrity sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==
|
||||||
|
|
||||||
unbox-primitive@^1.0.2:
|
unbox-primitive@^1.0.2:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz"
|
resolved "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz"
|
||||||
|
|
@ -2915,6 +3319,14 @@ 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"
|
||||||
|
|
@ -2945,6 +3357,19 @@ vite@*, "vite@^4.2.0 || ^5.0.0", vite@^5.1.4:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents "~2.3.3"
|
fsevents "~2.3.3"
|
||||||
|
|
||||||
|
webidl-conversions@^3.0.0:
|
||||||
|
version "3.0.1"
|
||||||
|
resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz"
|
||||||
|
integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
|
||||||
|
|
||||||
|
whatwg-url@^5.0.0:
|
||||||
|
version "5.0.0"
|
||||||
|
resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz"
|
||||||
|
integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==
|
||||||
|
dependencies:
|
||||||
|
tr46 "~0.0.3"
|
||||||
|
webidl-conversions "^3.0.0"
|
||||||
|
|
||||||
which-boxed-primitive@^1.0.2:
|
which-boxed-primitive@^1.0.2:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz"
|
resolved "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue