Locazia: v2.0 (dev beta)

This commit is contained in:
user 2024-03-04 23:36:45 +03:00
parent a08f742237
commit 88f8efa389
64 changed files with 7020 additions and 14269 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
.idea
node_modules
.DS_Store

View File

@ -1,70 +1,30 @@
# Getting Started with Create React App
# React + TypeScript + Vite
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
## Available Scripts
Currently, two official plugins are available:
In the project directory, you can run:
- [@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
### `npm start`
## Expanding the ESLint configuration
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
The page will reload when you make changes.\
You may also see any lint errors in the console.
- Configure the top-level `parserOptions` property like this:
### `npm test`
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
```
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

23
index.html Normal file
View File

@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link href="/vite.svg" rel="icon" type="image/svg+xml" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<meta
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
name="viewport"
/>
<meta content="true" name="HandheldFriendly" />
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<title>MyMusic</title>
</head>
<body>
<div id="root"></div>
<script src="/src/entry.tsx" type="module"></script>
</body>
</html>

15804
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,43 +1,49 @@
{
"name": "web2-client",
"version": "0.1.0",
"name": "my-music",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"react-router-dom": "^6.21.1",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
"version": "0.0.0",
"type": "module",
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"dependencies": {
"@hookform/resolvers": "^3.3.4",
"@vkruglikov/react-telegram-web-app": "^2.1.9",
"axios": "^1.6.7",
"clsx": "^2.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwindcss": "^3.4.1"
"react-hook-form": "^7.51.0",
"react-player": "^2.15.1",
"react-query": "^3.39.3",
"react-router-dom": "^6.22.2",
"tailwind-merge": "^2.2.1",
"zod": "^3.22.4",
"zustand": "^4.5.2"
},
"devDependencies": {
"@types/node": "^20.11.24",
"@types/react": "^18.2.56",
"@types/react-dom": "^18.2.19",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.18",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.35",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.1",
"typescript": "^5.2.2",
"vite": "^5.1.4",
"vite-tsconfig-paths": "^4.3.1"
}
}

View File

@ -1,6 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

6
prettier.config.cjs Normal file
View File

@ -0,0 +1,6 @@
/** @type {import("prettier").Config} */
const config = {
plugins: [require.resolve("prettier-plugin-tailwindcss")],
};
module.exports = config;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -1,44 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>MY</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -1,25 +0,0 @@
{
"short_name": "Web2MYClient",
"name": "MY Web2 Client",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,8 +0,0 @@
import { render, screen } from '@testing-library/react';
import App from './oldapp';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@ -1,77 +0,0 @@
import {useEffect, useState} from 'react';
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import Blank from './pages/Blank';
import { apiEndpoint } from "./constantsGlob";
import UploadContentPage from './pages/UploadContentPage';
import './index.css';
function App() {
const [apiToken, setApiToken] = useState('');
useEffect(() => {
if (window.Telegram && window.Telegram.WebApp) {
console.log(`Requesting API token from ${apiEndpoint}/auth.twa`);
fetch(`${apiEndpoint}/auth.twa`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
"twa_data": window.Telegram.WebApp.initData
}),
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
if (data["auth_v1_token"] === undefined) {
window.location.href = "/unauthorized";
return;
}
setApiToken(data["auth_v1_token"]);
})
.catch(error => {
console.error('There was a problem with your fetch operation:', error);
});
fetch(`${apiEndpoint}/account`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': apiToken,
}
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
console.log(data);
})
.catch(error => {
console.error('There was a problem with your fetch operation:', error);
});
} else {
console.log("Telegram WebApp not found");
window.location.href = "/unauthorized";
}
}, []);
return (
<div className="App">
<Router>
<Routes>
<Route path="/" element={<Blank />} />
<Route path="/uploadContent" element={<UploadContentPage />} />
</Routes>
</Router>
</div>
)
}
export default App;

26
src/app/index.tsx Normal file
View File

@ -0,0 +1,26 @@
import "~/app/styles/globals.css";
import { useEffect } from "react";
import { useExpand, useWebApp } from "@vkruglikov/react-telegram-web-app";
import { Providers } from "~/app/providers";
import { AppRouter } from "~/app/router";
export const App = () => {
const WebApp = useWebApp();
const [, expand] = useExpand();
useEffect(() => {
WebApp.enableClosingConfirmation();
expand();
WebApp.setHeaderColor("#1d1d1b");
WebApp.setBackgroundColor("#1d1d1b");
}, []);
return (
<Providers>
<AppRouter />
</Providers>
);
};

View File

@ -0,0 +1,19 @@
import { ReactNode } from "react";
import { QueryClientProvider } from "react-query";
import { WebAppProvider } from "@vkruglikov/react-telegram-web-app";
import { queryClient } from "~/shared/libs";
type ProvidersProps = {
children: ReactNode;
};
export const Providers = ({ children }: ProvidersProps) => {
return (
<WebAppProvider options={{ smoothButtonsTransition: true }}>
<QueryClientProvider client={queryClient}>
<main className="antialiased">{children}</main>
</QueryClientProvider>
</WebAppProvider>
);
};

View File

@ -0,0 +1,3 @@
export const Routes = {
Root: "/uploadContent",
};

12
src/app/router/index.tsx Normal file
View File

@ -0,0 +1,12 @@
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { Routes } from "~/app/router/constants";
import { RootPage } from "~/pages/root";
const router = createBrowserRouter([
{ path: Routes.Root, element: <RootPage /> },
]);
export const AppRouter = () => {
return <RouterProvider router={router} />;
};

View File

@ -0,0 +1,25 @@
@import url("https://fonts.cdnfonts.com/css/menlo");
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer {
* {
@apply caret-primary select-none;
}
body,
html {
@apply bg-gray text-white;
font-family: "Menlo", sans-serif;
}
button {
@apply transition-all active:opacity-60;
}
a {
@apply transition-all active:opacity-60;
}
}

View File

@ -1,8 +0,0 @@
console.log('process.env.REACT_APP_API_ENDPOINT', process.env.REACT_APP_API_ENDPOINT);
const apiEndpoint: string = 'https://music-gateway.letsw.app/api/v1';
export {
apiEndpoint
};

10
src/entry.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "~/app";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@ -1,17 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@ -1,12 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from "./App";
import './index.css';
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -1,11 +0,0 @@
const Blank = () => {
return (
<div className="text-center">
<p className="text-2xl text-gray-800 mono-font">Web2 Client: Page not found</p>
</div>
)
}
export default Blank;

View File

@ -1,168 +0,0 @@
import React, {useEffect, useState} from 'react';
interface RoyaltyRecipient {
address: string;
percentage: string;
}
const UploadContentPage = () => {
const [title, setTitle] = useState('');
const [image, setImage] = useState<File | null>(null);
const [content, setContent] = useState<File | null>(null);
const [authors, setAuthors] = useState('');
const [description, setDescription] = useState('');
const [price, setPrice] = useState('');
const [allowResale, setAllowResale] = useState(false);
const [royaltyRecipients, setRoyaltyRecipients] = useState<RoyaltyRecipient[]>([{ address: '', percentage: '0' }]);
const [totalPercentage, setTotalPercentage] = useState(0);
useEffect(() => {
let total = royaltyRecipients.reduce((acc, curr) => acc + parseFloat(curr.percentage), 0);
if (isNaN(total)) { total = 0; }
setTotalPercentage(total);
}, [royaltyRecipients]);
const addRecipient = () => {
setRoyaltyRecipients([...royaltyRecipients, { address: '', percentage: '0' }]);
};
const updateRecipient = (index: number, field: 'address' | 'percentage', value: string) => {
const updatedRecipients = [...royaltyRecipients];
if (field === 'percentage') {
updatedRecipients[index].percentage = value;
} else {
updatedRecipients[index][field] = value;
}
setRoyaltyRecipients(updatedRecipients);
};
const removeRecipient = (index: number) => {
if (royaltyRecipients.length > 1) {
const updatedRecipients = [...royaltyRecipients];
updatedRecipients.splice(index, 1);
setRoyaltyRecipients(updatedRecipients);
}
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
console.log(title, image, content, authors, description, price, allowResale, royaltyRecipients);
}
return (
<div className="max-w-4xl mx-auto p-5 bg-white shadow-md rounded-lg">
<h1 className="text-xl font-semibold text-gray-800 mb-6">Загрузка контента</h1>
<form onSubmit={handleSubmit} className="grid grid-cols-1 gap-4">
<input
type="text"
placeholder="Название"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="input border-gray-300 rounded-md p-2 w-full"
/>
<input
type="text"
placeholder="Авторы"
value={title}
onChange={(e) => setAuthors(e.target.value)}
className="input border-gray-300 rounded-md p-2 w-full"
/>
<textarea
placeholder="Описание"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="textarea border-gray-300 rounded-md p-2 w-full h-32"
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Обложка (1x1)</label>
<input
type="file"
onChange={(e) => setImage(e.target.files ? e.target.files[0] : null)}
className="file-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Контент (mp3, wav, ogg)</label>
<input
type="file"
onChange={(e) => setContent(e.target.files ? e.target.files[0] : null)}
className="file-input"
/>
</div>
<hr/>
<table className="table-auto flex-wrap border-solid border-2 border-indigo-600 divide-y-2 divide-indigo-600">
{royaltyRecipients.map((recipient, index) => (
<tr>
<td className="w-4/6 p-0">
<input
type="text"
placeholder="Адрес получателя"
value={recipient.address}
onChange={(e) => updateRecipient(index, 'address', e.target.value)}
className="border-indigo-600 border-e-2 w-full flex-grow"
/>
</td>
<td className="p-0">
<input
type="text"
placeholder="Процент роялти"
value={recipient.percentage}
onChange={(e) => updateRecipient(index, 'percentage', e.target.value)}
className="border-indigo-600 border-s-0 w-full flex-grow"
min="0"
max="100"
step="0.1"
/>
</td>
<td className="p-0">
{index >= 1 && (
<button
type="button"
onClick={() => removeRecipient(index)}
className="btn w-full bg-red-600 hover:text-red-700 px-4 py-2 text-white hover:bg-red-800 transition duration-150"
>
X
</button>
)}
</td>
</tr>
))}
</table>
<button type="button" onClick={addRecipient} className="btn text-right">
Добавить получателя роялти
</button>
{totalPercentage !== 100 && (
<p className="text-red-500">Сумма процентов должна равняться 100%</p>
)}
<div>Сумма процентов: {totalPercentage}%</div>
<hr/>
<input
type="text"
placeholder="Цена в TON"
value={price}
onChange={(e) => setPrice(e.target.value)}
className="input border-gray-300 rounded-md p-2 w-full"
/>
<div className="flex items-center">
<input
id="allowResale"
type="checkbox"
checked={allowResale}
onChange={(e) => setAllowResale(e.target.checked)}
className="checkbox w-4 h-4 text-blue-600 border-gray-300 rounded"
/>
<label htmlFor="allowResale" className="ml-2 text-sm text-gray-700">Разрешить перепродажу с отчислением
реферального роялти?</label>
</div>
<button type="submit"
className="btn bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded h-14 bg-gradient-to-r from-purple-500 to-pink-500">
Загрузить контент
</button>
</form>
</div>
);
};
export default UploadContentPage;

22
src/pages/root/index.tsx Normal file
View File

@ -0,0 +1,22 @@
import { WelcomeStep } from "./steps/welcome-step";
import { DataStep } from "./steps/data-step";
import { RoyaltyStep } from "./steps/royalty-step";
import { PresubmitStep } from "./steps/presubmit-step";
import { useSteps } from "~/shared/hooks/use-steps";
import { PriceStep } from "~/pages/root/steps/price-step";
export const RootPage = () => {
const { ActiveSection } = useSteps(({ nextStep, prevStep }) => {
return [
<WelcomeStep nextStep={nextStep} />,
<DataStep prevStep={prevStep} nextStep={nextStep} />,
// <AuthorsStep prevStep={prevStep} nextStep={nextStep} />,
<RoyaltyStep prevStep={prevStep} nextStep={nextStep} />,
<PriceStep prevStep={prevStep} nextStep={nextStep} />,
<PresubmitStep prevStep={prevStep} />,
];
});
return ActiveSection;
};

View File

@ -0,0 +1,89 @@
import { useHapticFeedback } from "@vkruglikov/react-telegram-web-app";
import { Input } from "~/shared/ui/input";
import { FormLabel } from "~/shared/ui/form-label";
import { XMark } from "~/shared/ui/icons/x-mark.tsx";
import { Button } from "~/shared/ui/button";
import { useRootStore } from "~/shared/stores/root";
import { BackButton } from "~/shared/ui/back-button";
type AuthorsStepProps = {
prevStep(): void;
nextStep(): void;
};
export const AuthorsStep = ({ nextStep, prevStep }: AuthorsStepProps) => {
const [impactOccurred] = useHapticFeedback();
const { authors, setAuthors } = useRootStore();
const handleAdd = () => {
impactOccurred("light");
setAuthors([...authors, ""]);
};
const handleRemove = (index: number) => {
impactOccurred("light");
const newAuthors = authors.filter((_, i) => i !== index);
setAuthors(newAuthors);
};
const handleChange = (index: number, value: string) => {
const newAuthors = authors.map((member, i) =>
i === index ? value : member,
);
setAuthors(newAuthors);
};
return (
<section className={"mt-4 px-4 pb-8"}>
<BackButton onClick={prevStep} />
<div className={"mb-[30px] flex flex-col text-sm"}>
<span className={"ml-4"}>/Заполните информацию об авторах</span>
<div>
2/<span className={"text-[#7B7B7B]"}>5</span>
</div>
</div>
<section className={"flex flex-col gap-1.5"}>
{authors.map((member, index) => (
<FormLabel
key={index}
labelClassName={"flex"}
formLabelAddon={
<button onClick={() => handleRemove(index)}>
<XMark />
</button>
}
label={`Автор_${index + 1}`}
>
<Input
value={member}
onChange={(e) => handleChange(index, e.target.value)}
placeholder={"[ Введите_имя ]"}
/>
</FormLabel>
))}
</section>
<button
onClick={handleAdd}
className={
"mt-[30px] flex w-full items-center justify-center border border-white py-[8px] text-sm"
}
>
Добавить_автора
</button>
<Button
label={"Готово"}
className={"mt-[30px]"}
includeArrows={true}
onClick={nextStep}
/>
</section>
);
};

View File

@ -0,0 +1,44 @@
import { useHapticFeedback } from "@vkruglikov/react-telegram-web-app";
import { XMark } from "~/shared/ui/icons/x-mark.tsx";
type CoverButtonProps = {
src: string;
onClick(): void;
};
export const CoverButton = ({ src, onClick }: CoverButtonProps) => {
const [impactOccurred] = useHapticFeedback();
const handleClick = () => {
impactOccurred("light");
onClick();
};
return (
<div
className={
"flex w-full items-center gap-2 border border-white px-[10px] py-[8px]"
}
>
<div className={"bg-primary h-[50px] w-[50px] shrink-0 overflow-hidden"}>
<img
src={src}
className={"h-full w-full object-cover object-center"}
alt={"cover-image"}
/>
</div>
<button
onClick={handleClick}
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>
<XMark />
</button>
</div>
);
};

View File

@ -0,0 +1,165 @@
import { useMemo } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import ReactPlayer from "react-player/lazy";
import { FormLabel } from "~/shared/ui/form-label";
import { Input } from "~/shared/ui/input";
import { FileButton } from "~/shared/ui/file-button";
import { Button } from "~/shared/ui/button";
import { CoverButton } from "~/pages/root/steps/data-step/components/cover-button";
import { HiddenFileInput } from "~/shared/ui/hidden-file-input";
import { useRootStore } from "~/shared/stores/root";
import { Checkbox } from "~/shared/ui/checkbox";
import { BackButton } from "~/shared/ui/back-button";
type DataStepProps = {
prevStep(): void;
nextStep(): void;
};
export const DataStep = ({ nextStep, prevStep }: DataStepProps) => {
const rootStore = useRootStore();
const formSchema = useMemo(() => {
return z.object({
name: z.string().min(1),
author: z.string().optional(),
});
}, []);
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
name: rootStore.name,
author: rootStore?.author,
},
});
const handleSubmit = () => {
form.handleSubmit(async (values: FormValues) => {
try {
rootStore.setName(values.name);
if (values.author) {
rootStore.setAuthor(values.author);
}
nextStep();
} catch (error) {
console.error("Error:", error);
}
})();
};
return (
<section className={"mt-4 px-4 pb-8"}>
<BackButton onClick={prevStep} />
<div className={"mb-[30px] flex flex-col text-sm"}>
<span className={"ml-4"}>/Заполните информацию о контенте</span>
<div>
1/<span className={"text-[#7B7B7B]"}>5</span>
</div>
</div>
<div className={"flex flex-col gap-[20px]"}>
<FormLabel label={"Название"}>
<Input
placeholder={"[ Введите название ]"}
{...form.register("name")}
/>
</FormLabel>
<FormLabel label={"Имя автора/исполнителя"}>
<Input
placeholder={"[ введите имя автора/исполнителя ]"}
{...form.register("author")}
/>
</FormLabel>
<FormLabel label={"Файл"}>
<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.fileSrc && <FileButton htmlFor={"file"} />}
{rootStore.fileSrc && (
<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>
)}
</FormLabel>
<div className={"flex flex-col gap-2"}>
<FormLabel
label={"Разрешить обложку"}
labelClassName={"flex"}
formLabelAddon={
<Checkbox
onClick={() => rootStore.setAllowCover(!rootStore.allowCover)}
checked={rootStore.allowCover}
/>
}
/>
{rootStore.allowCover && (
<FormLabel label={"Обложка"}>
<HiddenFileInput
id={"cover"}
accept={"image/*"}
onChange={(cover) => {
rootStore.setCover(cover);
}}
/>
{rootStore.cover ? (
<CoverButton
src={URL.createObjectURL(rootStore.cover)}
onClick={() => {
rootStore.setCover(null);
}}
/>
) : (
<FileButton htmlFor={"cover"} />
)}
</FormLabel>
)}
</div>
</div>
<Button
className={"mt-[30px]"}
onClick={handleSubmit}
includeArrows={true}
label={"Готово"}
disabled={
!form.formState.isValid ||
!rootStore.file ||
(rootStore.allowCover && !rootStore.cover)
}
/>
</section>
);
};

View File

@ -0,0 +1,302 @@
import {
useHapticFeedback,
useWebApp,
} from "@vkruglikov/react-telegram-web-app";
import { useState } from "react";
import ReactPlayer from "react-player/lazy";
import { Button } from "~/shared/ui/button";
import { useRootStore } from "~/shared/stores/root";
import { FormLabel } from "~/shared/ui/form-label";
import { useUploadFile } from "~/shared/services/file";
import { Progress } from "~/shared/ui/progress";
import { useCreateNewContent } from "~/shared/services/content";
import { BackButton } from "~/shared/ui/back-button";
type PresubmitStepProps = {
prevStep(): void;
};
export const PresubmitStep = ({ prevStep }: PresubmitStepProps) => {
const WebApp = useWebApp();
const rootStore = useRootStore();
const [impactOccurred] = useHapticFeedback();
const [isCoverExpanded, setCoverExpanded] = useState(false);
const uploadCover = useUploadFile();
const uploadFile = useUploadFile();
const createContent = useCreateNewContent();
const handleSubmit = async () => {
try {
let coverUploadResult = { content_id_v1: "" };
const fileUploadResult = await uploadFile.mutateAsync(
rootStore.file as File,
);
if (rootStore.allowCover && rootStore.cover) {
coverUploadResult = await uploadCover.mutateAsync(
rootStore.cover as File,
);
}
await createContent.mutateAsync({
title: rootStore.name,
// Это для одного автора
...(rootStore.author
? { authors: [rootStore.author] }
: { authors: [] }),
// Откомментировать при условии того что вы принимаете много авторов
// следует отметить что вы должны еще откомментровать AuthorsStep в RootPage
// authors: rootStore.authors,
content: fileUploadResult.content_id_v1,
image: coverUploadResult.content_id_v1,
price: String(rootStore.price * 10 ** 9),
royaltyParams: rootStore.royalty.map((member) => ({
...member,
value: member.value * 100,
})),
description: "",
// Если откомментировать поле resaleLicensePrice в price-step то
// это отработает как надо
...(rootStore.allowResale
? {
allowResale: true,
resaleLicensePrice: String(
rootStore.licenseResalePrice * 10 ** 9,
),
}
: { allowResale: false, resaleLicensePrice: "0" }),
});
WebApp.close();
} catch (error) {
console.error("An error occurred during the submission process:", error);
alert(`Возникла ошибка, ${JSON.stringify(error)}`);
}
};
return (
<section className={"mt-4 px-4 pb-8"}>
<BackButton onClick={prevStep} />
<div className={"mb-[30px] flex flex-col text-sm"}>
<span className={"ml-4"}>/Подтвердите правильность данных</span>
<div>
5/<span className={"text-[#7B7B7B]"}>5</span>
</div>
</div>
<section className={"flex flex-col gap-2"}>
<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
className={
"w-full border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm font-bold"
}
>
{rootStore.author}
</div>
</FormLabel>
)}
<FormLabel label={"Цена"}>
<div
className={
"w-full border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm font-bold"
}
>
{rootStore.price} TON
</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 && (
<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
style={{
height: isCoverExpanded ? "261px" : "68px",
}}
className={"bg-bg w-full"}
>
{rootStore.cover && !uploadCover.isUploading && (
<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>
<button
onClick={() => {
impactOccurred("light");
setCoverExpanded((p) => !p);
}}
style={{
height: isCoverExpanded ? "261px" : "68px",
}}
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>
</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>
);
};

View File

@ -0,0 +1,187 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useEffect, useMemo } from "react";
import { FormLabel } from "~/shared/ui/form-label";
import { Input } from "~/shared/ui/input";
import { Button } from "~/shared/ui/button";
import { useRootStore } from "~/shared/stores/root";
import { BackButton } from "~/shared/ui/back-button";
const MIN_PRICE = 0.07;
const MIN_RESALE_PRICE = 0.07;
const RECOMMENDED_PRICE = 0.15;
// const RECOMMENDED_RESALE_PRICE = 0.15;
type PriceStepProps = {
prevStep(): void;
nextStep(): void;
};
export const PriceStep = ({ nextStep, prevStep }: PriceStepProps) => {
const rootStore = useRootStore();
const formSchema = useMemo(() => {
if (rootStore.allowResale) {
return z.object({
price: z.preprocess(
(value) => {
const parsed = parseFloat(value as string);
return isNaN(parsed) ? undefined : parsed;
},
z
.number()
.min(MIN_PRICE, `Цена должна быть минимум ${MIN_PRICE} TON.`),
),
resaleLicensePrice: z
.preprocess(
(value) => {
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({
price: z.preprocess(
(value) => {
const parsed = parseFloat(value as string);
return isNaN(parsed) ? undefined : parsed;
},
z.number().min(MIN_PRICE, `Цена должна быть минимум ${MIN_PRICE} TON.`),
),
});
}, [rootStore.allowResale]);
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
price: rootStore.price,
//@ts-expect-error Fix typings
resaleLicensePrice: rootStore?.licenseResalePrice,
},
});
useEffect(() => {
void form.trigger();
}, [rootStore.allowResale, form]);
const handleSubmit = () => {
form.handleSubmit(async (values: FormValues) => {
try {
rootStore.setPrice(values.price);
//@ts-expect-error Fix typings
if (values?.resaleLicensePrice) {
//@ts-expect-error Fix typings
rootStore.setLicenseResalePrice(values?.resaleLicensePrice);
}
nextStep();
} catch (error) {
console.error("Error: ", error);
}
})();
};
return (
<section className={"mt-4 px-4 pb-8"}>
<BackButton onClick={prevStep} />
<div className={"mb-[30px] flex flex-col text-sm"}>
<span className={"ml-4"}>/Укажите цену</span>
<div>
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>
<Input
error={form.formState.errors?.price}
placeholder={"[ Введите цену ]"}
{...form.register("price")}
/>
</FormLabel>
{/*<div className={"flex flex-col gap-2"}>*/}
{/* <FormLabel*/}
{/* labelClassName={"flex"}*/}
{/* label={"Разрешить копии"}*/}
{/* formLabelAddon={*/}
{/* <Checkbox*/}
{/* checked={rootStore.allowResale}*/}
{/* onClick={() => {*/}
{/* rootStore.setAllowResale(!rootStore.allowResale);*/}
{/* }}*/}
{/* />*/}
{/* }*/}
{/* />*/}
{/* {rootStore.allowResale && (*/}
{/* <FormLabel label={"Цена копии TON"}>*/}
{/* <div className={"my-2 flex flex-col gap-1.5"}>*/}
{/* <p className={"text-xs"}>*/}
{/* Это цена, по которой пользователи будут покупать и*/}
{/* перепродавать ваш контент.*/}
{/* </p>*/}
{/* <p className={"text-xs"}>*/}
{/* Минимальная стоимость {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>
);
};

View File

@ -0,0 +1,98 @@
import { useHapticFeedback } from "@vkruglikov/react-telegram-web-app";
type ConfirmModalProps = {
text: string;
confirmLabel: string;
onConfirm(): void;
onCancel(): void;
};
export const ConfirmModal = ({
text,
confirmLabel,
onConfirm,
onCancel,
}: ConfirmModalProps) => {
const [impactOccurred] = useHapticFeedback();
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 gap-[30px]"}>
<div
className={
"border border-white bg-[#1D1D1B] px-[10px] py-[16px] text-center"
}
>
{text}
</div>
<div className={"flex w-full gap-2"}>
<button
className={
"flex flex-1 items-center justify-center gap-3 border border-white bg-[#1D1D1B] py-[16px]"
}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<line
x1="1.44674"
y1="11.1464"
x2="11.3462"
y2="1.24695"
stroke="#E30613"
/>
<line
x1="1.15385"
y1="1.14645"
x2="11.0533"
y2="11.0459"
stroke="#E30613"
/>
</svg>
<span className={"underline"} onClick={() => handleClick(onCancel)}>
Отменить
</span>
</button>
<button
className={
"flex flex-1 items-center justify-center gap-3 border border-white bg-[#1D1D1B] py-[16px]"
}
>
<svg
width="17"
height="12"
viewBox="0 0 17 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M1.25 5.22222L6.43182 10.5L16.25 0.5" stroke="#00FFFF" />
</svg>
<span
className={"underline"}
onClick={() => handleClick(onConfirm)}
>
{confirmLabel}
</span>
</button>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,23 @@
import { Button } from "~/shared/ui/button";
export const PercentHint = ({ close }: { close(): void }) => {
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 gap-3"}>
<div
className={
"border-primary border bg-[#1D1D1B] px-[10px] py-[8px] text-sm"
}
>
Сумма роялти должна составлять 100%
</div>
<Button onClick={close} label={"Понятно"} />
</div>
</div>
);
};

View File

@ -0,0 +1,206 @@
import { useMemo, useState } from "react";
import { useHapticFeedback } from "@vkruglikov/react-telegram-web-app";
import { Input } from "~/shared/ui/input";
import { FormLabel } from "~/shared/ui/form-label";
import { XMark } from "~/shared/ui/icons/x-mark.tsx";
import { Button } from "~/shared/ui/button";
import { PercentHint } from "~/pages/root/steps/royalty-step/components/percent-hint";
import { cn } from "~/shared/utils";
import { Spread } from "~/shared/ui/icons/spread.tsx";
import { ConfirmModal } from "~/pages/root/steps/royalty-step/components/confirm-modal";
import { useRootStore } from "~/shared/stores/root";
import { BackButton } from "~/shared/ui/back-button";
type RoyaltyStepProps = {
prevStep(): void;
nextStep(): void;
};
export const RoyaltyStep = ({ nextStep, prevStep }: RoyaltyStepProps) => {
const [impactOccurred] = useHapticFeedback();
const [isDeleteAllOpen, setDeleteAllOpen] = useState(false);
const [isSpreadOpen, setSpreadOpen] = useState(false);
const { royalty, setRoyalty, isPercentHintOpen, setPercentHintOpen } =
useRootStore();
const handleAdd = () => {
impactOccurred("light");
setRoyalty([...royalty, { address: "", value: 0 }]);
};
const handleRemove = (index: number) => {
if (royalty.length === 1) return;
impactOccurred("light");
const newRoyalty = royalty.filter((_, i) => i !== index);
setRoyalty(newRoyalty);
};
const handleWalletChange = (index: number, address: string) => {
const newRoyalty = royalty.map((member, i) =>
i === index ? { ...member, address } : member,
);
setRoyalty(newRoyalty);
};
const handlePercentChange = (index: number, value: string) => {
const percentNumber = parseInt(value, 10) || 0;
const newRoyalty = royalty.map((royalty, i) =>
i === index ? { ...royalty, value: percentNumber } : royalty,
);
setRoyalty(newRoyalty);
};
const spreadPercentageEqually = () => {
const equalPercent = 100 / royalty.length;
const updatedRoyalty = royalty.map((member) => ({
...member,
value: equalPercent,
}));
setRoyalty(updatedRoyalty);
};
const isValid = useMemo(() => {
return (
royalty.every((member) => member.address && member.value >= 0) &&
royalty.reduce((acc, curr) => acc + curr.value, 0) === 100
);
}, [royalty]);
return (
<section className={"mt-4 px-4 pb-8"}>
{isPercentHintOpen && (
<PercentHint close={() => setPercentHintOpen(false)} />
)}
{isDeleteAllOpen && (
<ConfirmModal
text={"Удалить всю информацию об авторах?"}
confirmLabel={"Удалить"}
onConfirm={() => {
setRoyalty([{ address: "", value: 100 }]);
setDeleteAllOpen(false);
}}
onCancel={() => {
setDeleteAllOpen(false);
}}
/>
)}
{isSpreadOpen && (
<ConfirmModal
text={"Распределить роялти на всех авторов равномерно?"}
confirmLabel={"Распределить"}
onConfirm={() => {
spreadPercentageEqually();
setSpreadOpen(false);
}}
onCancel={() => {
setSpreadOpen(false);
}}
/>
)}
<BackButton onClick={prevStep} />
<div className={"mb-[30px] flex flex-col text-sm"}>
<span className={"ml-4"}>/Заполните информацию об роялти</span>
<div>
3/<span className={"text-[#7B7B7B]"}>5</span>
</div>
</div>
<section className={"flex flex-col gap-1.5"}>
{royalty.map((member, 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"}
formLabelAddon={
index > 0 && (
<button onClick={() => handleRemove(index)}>
<XMark />
</button>
)
}
label={`Роялти_${index + 1}`}
>
<Input
value={member.address}
onChange={(e) => handleWalletChange(index, e.target.value)}
placeholder={"[ Введите адрес криптокошелька TON ]"}
/>
</FormLabel>
</div>
<div className={"w-[18%]"}>
<FormLabel labelClassName={"text-center"} label={"%"}>
<Input
value={member.value.toString()}
onChange={(e) => handlePercentChange(index, e.target.value)}
placeholder={"[ % ]"}
className={cn({
"pointer-events-none z-50 border-primary":
isPercentHintOpen,
})}
/>
</FormLabel>
</div>
</div>
</div>
))}
</section>
<button
onClick={handleAdd}
className={
"mt-[30px] flex w-full items-center justify-center border border-white py-[8px] text-sm"
}
>
Добавить_роялти
</button>
{royalty.length > 1 && (
<div className={"mt-[30px] flex items-center gap-2"}>
<button
onClick={() => {
impactOccurred("light");
setDeleteAllOpen(true);
}}
className={
"flex w-full items-center justify-center gap-3 border border-white py-[8px] text-sm"
}
>
Удалить все
<XMark />
</button>
<button
onClick={() => {
impactOccurred("light");
setSpreadOpen(true);
}}
className={
"flex w-full items-center justify-center gap-3 border border-white py-[8px] text-sm"
}
>
Распределить <Spread />
</button>
</div>
)}
<Button
label={"Готово"}
className={"mt-[30px]"}
includeArrows={true}
onClick={nextStep}
disabled={!isValid}
/>
</section>
);
};

View File

@ -0,0 +1,45 @@
import { Button } from "~/shared/ui/button";
import { useAuth } from "~/shared/services/auth";
type WelcomeStepProps = {
nextStep(): void;
};
export const WelcomeStep = ({ nextStep }: WelcomeStepProps) => {
const auth = useAuth();
const handleNextClick = async () => {
const res = await auth.mutateAsync();
sessionStorage.setItem("auth_v1_token", res.data.auth_v1_token);
nextStep();
};
return (
<section className={"mt-4 px-4"}>
<div className={"flex gap-2 text-sm"}>
<span>/ Добро пожаловать в MY</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 className={"mt-2"}>
<p className={"text-sm"}>
децентрализованную систему монетизации контента. для продолжения
необходимо подключить криптокошелек TON
</p>
</div>
<Button
label={"Подключить криптокошелёк TON"}
className={"mt-[30px]"}
includeArrows={true}
isLoading={auth.isLoading}
onClick={handleNextClick}
/>
</section>
);
};

View File

@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

@ -0,0 +1,31 @@
import { ReactNode, useMemo, useState } from "react";
export const useSteps = (
sections: ({
nextStep,
prevStep,
}: {
nextStep(): void;
prevStep(): void;
}) => ReactNode[],
) => {
const [step, setStep] = useState(0);
const nextStep = () => {
return setStep((s) => s + 1);
};
const prevStep = () => {
return setStep((s) => s - 1);
};
const ActiveSection = useMemo(() => {
return sections({ nextStep, prevStep })[step];
}, [step, sections]);
return {
ActiveSection,
setStep,
step,
};
};

2
src/shared/libs/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from "./request";
export * from "./query-client";

View File

@ -0,0 +1,9 @@
import { QueryClient } from "react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});

View File

@ -0,0 +1,17 @@
import axios from "axios";
export const APP_API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string;
export const request = axios.create({
baseURL: APP_API_BASE_URL,
});
request.interceptors.request.use((config) => {
const auth_v1_token = sessionStorage.getItem("auth_v1_token");
if (auth_v1_token) {
config.headers.Authorization = auth_v1_token;
}
return config;
});

View File

@ -0,0 +1,22 @@
import { useMutation } from "react-query";
import { useWebApp } from "@vkruglikov/react-telegram-web-app";
import { request } from "~/shared/libs";
export const useAuth = () => {
const WebApp = useWebApp();
return useMutation(["auth"], () => {
return request.post<{
connected_wallet: null | {
version: string;
address: string;
ton_balance: string;
};
auth_v1_token: string;
}>("/auth.twa", {
twa_data: WebApp.initData,
});
});
};

View File

@ -0,0 +1,38 @@
import { useMutation } from "react-query";
import { request } from "~/shared/libs";
import { Royalty } from "~/shared/stores/root";
type UseCreateNewContentPayload = {
title: string;
content: string;
image: string;
description: string;
price: string;
resaleLicensePrice: string; // nanoTON bignum (default = 0)
allowResale: boolean;
authors: string[];
royaltyParams: Royalty[];
};
export const useCreateNewContent = () => {
return useMutation(
["create-new-content"],
(payload: UseCreateNewContentPayload) => {
return request.post<{
message: string;
}>("/blockchain.sendNewContentMessage", payload);
},
);
};
export const usePurchaseContent = () => {
return useMutation(
["purchase-content"],
(payload: { content_address: string; price: string }) => {
return request.post<{
message: string;
}>("/blockchain.sendPurchaseContentMessage", payload);
},
);
};

View File

@ -0,0 +1,45 @@
import { useMutation } from "react-query";
import { useState } from "react";
import { request } from "~/shared/libs";
export const useUploadFile = () => {
const [uploadProgress, setUploadProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const mutation = useMutation(["upload-file"], (file: File) => {
setIsUploading(true);
const formData = new FormData();
formData.append("file", file);
return request
.post<{
content_sha256: string;
content_id_v1: string;
content_url: string;
}>("/storage", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / (progressEvent?.total as number) ??
0,
);
setUploadProgress(percentCompleted);
},
})
.then((response) => {
setIsUploading(false);
return response.data;
})
.catch((error) => {
setIsUploading(false);
throw error;
});
});
return { ...mutation, uploadProgress, isUploading };
};

View File

@ -0,0 +1,82 @@
import { create } from "zustand";
export type Royalty = {
address: string;
value: number;
};
type RootStore = {
name: string;
setName: (name: string) => void;
author: string;
setAuthor: (author: string) => void;
file: File | null;
setFile: (file: File) => void;
fileSrc: string;
setFileSrc: (fileSrc: string) => void;
allowCover: boolean;
setAllowCover: (allowCover: boolean) => void;
cover: File | null;
setCover: (cover: File | null) => void;
isPercentHintOpen: boolean;
setPercentHintOpen: (isPercentHintOpen: boolean) => void;
authors: string[];
setAuthors: (authors: string[]) => void;
royalty: Royalty[];
setRoyalty: (authors: Royalty[]) => void;
price: number;
setPrice: (price: number) => void;
allowResale: boolean;
setAllowResale: (allowResale: boolean) => void;
licenseResalePrice: number;
setLicenseResalePrice: (licenseResalePrice: number) => void;
};
export const useRootStore = create<RootStore>((set) => ({
name: "",
setName: (name) => set({ name }),
author: "",
setAuthor: (author) => set({ author }),
file: null,
setFile: (file) => set({ file }),
fileSrc: "",
setFileSrc: (fileSrc) => set({ fileSrc }),
allowCover: false,
setAllowCover: (allowCover) => set({ allowCover }),
cover: null,
setCover: (cover) => set({ cover }),
isPercentHintOpen: true,
setPercentHintOpen: (isPercentHintOpen) => set({ isPercentHintOpen }),
authors: [],
setAuthors: (authors) => set({ authors }),
royalty: [{ address: "", value: 100 }],
setRoyalty: (royalty) => set({ royalty }),
price: 0,
setPrice: (price: number) => set({ price }),
allowResale: false,
setAllowResale: (allowResale) => set({ allowResale }),
licenseResalePrice: 0,
setLicenseResalePrice: (licenseResalePrice) => set({ licenseResalePrice }),
}));

View File

@ -0,0 +1,22 @@
import {
BackButton as NativeBackButton,
useHapticFeedback,
} from "@vkruglikov/react-telegram-web-app";
type BackButtonProps = {
onClick?: () => void;
};
export const BackButton = ({ onClick }: BackButtonProps) => {
const [impactOccurred] = useHapticFeedback();
const handleClick = () => {
impactOccurred("light");
if (onClick) {
onClick();
}
};
return <NativeBackButton onClick={handleClick} />;
};

View File

@ -0,0 +1,47 @@
import { useHapticFeedback } from "@vkruglikov/react-telegram-web-app";
import { cn } from "~/shared/utils";
type ButtonProps = {
label: string;
onClick?(): void;
includeArrows?: boolean;
className?: string;
disabled?: boolean;
isLoading?: boolean;
};
export const Button = ({
includeArrows,
label,
className,
disabled,
onClick,
isLoading,
}: ButtonProps) => {
const [impactOccurred] = useHapticFeedback();
const handleClick = () => {
impactOccurred("light");
onClick?.();
};
return (
<button
onClick={handleClick}
disabled={disabled}
className={cn(
"flex w-full items-center justify-center border border-white bg-primary py-[8px] text-sm",
{
"pointer-events-none bg-[#7B7B7B80]": disabled,
"pointer-events-none animate-pulse": isLoading,
},
className,
)}
>
{includeArrows && <span className={"mr-3"}>{">>>"}</span>}
{label}
{includeArrows && <span className={"ml-3"}>{"<<<"}</span>}
</button>
);
};

View File

@ -0,0 +1,24 @@
import { useHapticFeedback } from "@vkruglikov/react-telegram-web-app";
type CheckboxProps = {
onClick(): void;
checked: boolean;
};
export const Checkbox = ({ onClick, checked }: CheckboxProps) => {
const [impactOccurred] = useHapticFeedback();
const handleClick = () => {
impactOccurred("light");
onClick();
};
return (
<div
onClick={handleClick}
className={"flex h-8 w-8 items-center justify-center bg-[#2B2B2B] p-2"}
>
{checked && <div className={"h-full w-full bg-primary"} />}
</div>
);
};

View File

@ -0,0 +1,16 @@
type FileButtonProps = {
htmlFor: string;
};
export const FileButton = ({ htmlFor }: FileButtonProps) => {
return (
<label
htmlFor={htmlFor}
className={
"flex w-full items-center justify-center border border-white py-[8px] text-sm"
}
>
<span className={"underline"}>Загрузитьайл</span>
</label>
);
};

View File

@ -0,0 +1,31 @@
import { ReactNode } from "react";
import { cn } from "~/shared/utils";
type FormLabelProps = {
label: string;
children?: ReactNode;
labelClassName?: string;
formLabelAddon?: ReactNode;
};
export const FormLabel = ({
label,
children,
labelClassName,
formLabelAddon,
}: FormLabelProps) => {
return (
<div className={"flex flex-col gap-1"}>
<div
className={cn(
"w-full items-center justify-between text-sm",
labelClassName,
)}
>
{label} {formLabelAddon}
</div>
{children}
</div>
);
};

View File

@ -0,0 +1,39 @@
import { ChangeEvent } from "react";
import { processFile } from "~/shared/utils";
type HiddenFileInputProps = {
id: string;
onChange(file: File): void;
accept?: string;
shouldProcess?: boolean;
};
export const HiddenFileInput = ({
id,
onChange,
accept,
shouldProcess = true,
}: HiddenFileInputProps) => {
const handleInputChange = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target?.files?.[0];
if (file) {
if (shouldProcess) {
onChange(await processFile(file));
} else {
onChange(file);
}
}
};
return (
<input
onChange={handleInputChange}
accept={accept}
type={"file"}
id={id}
hidden
/>
);
};

View File

@ -0,0 +1,16 @@
export const Spread = () => {
return (
<svg
width="20"
height="11"
viewBox="0 0 20 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.66328 0.251953H5.77656L1.17109 10.1133H0.0519533L4.66328 0.251953ZM11.5938 0.251953H12.707L8.10156 10.1133H6.98242L11.5938 0.251953ZM18.5242 0.251953H19.6375L15.032 10.1133H13.9129L18.5242 0.251953Z"
fill="white"
/>
</svg>
);
};

View File

@ -0,0 +1,26 @@
export const XMark = () => {
return (
<svg
width="11"
height="11"
viewBox="0 0 11 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<line
x1="0.747033"
y1="10.6464"
x2="10.6465"
y2="0.746952"
stroke="white"
/>
<line
x1="0.454139"
y1="0.646447"
x2="10.3536"
y2="10.5459"
stroke="white"
/>
</svg>
);
};

View File

@ -0,0 +1,27 @@
import { forwardRef, InputHTMLAttributes } from "react";
import { FieldError } from "react-hook-form";
import { cn } from "~/shared/utils";
type InputProps = InputHTMLAttributes<HTMLInputElement> & {
error?: FieldError;
};
export const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
return (
<div className={"flex w-full flex-col gap-1.5"}>
<input
ref={ref}
{...props}
className={cn(
"border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm outline-none",
props?.className,
)}
/>
{props.error && (
<span className={"text-xs text-red-500"}>{props.error.message}</span>
)}
</div>
);
});

View File

@ -0,0 +1,25 @@
import { cn, getIndexArray } from "~/shared/utils";
type ProgressProps = {
value: number; // from 0 to 100
};
export const Progress = ({ value }: ProgressProps) => {
const activeBars = Math.round((value / 100) * 25);
return (
<div className="flex items-center gap-[5px] border border-white px-[10px] py-[8px]">
{getIndexArray(25).map((idx) => (
<div
key={idx}
className={cn("h-[14px] w-full", {
"bg-primary": idx < activeBars,
"bg-[#2B2B2B]": idx >= activeBars,
})}
/>
))}
<div className="ml-2 text-sm">{value}%</div>
</div>
);
};

19
src/shared/utils/index.ts Normal file
View File

@ -0,0 +1,19 @@
import { twMerge } from "tailwind-merge";
import clsx, { ClassValue } from "clsx";
export const cn = (...inputs: ClassValue[]) => {
return twMerge(clsx(inputs));
};
export const bufferToFile = (buffer: ArrayBuffer, filename: string) => {
const blob = new Blob([buffer], { type: "image/*" });
return new File([blob], filename);
};
export const processFile = async (file: File) => {
const buffer = await file.arrayBuffer();
return bufferToFile(buffer, file.name);
};
export const getIndexArray = (len: number) =>
new Array(len).fill("").map((_, i) => i);

View File

@ -1,11 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/forms'),
],
}

13
tailwind.config.ts Normal file
View File

@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
gray: "#1d1d1b",
primary: "#e40615",
},
},
},
plugins: [],
};

View File

@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2022",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
@ -14,17 +14,16 @@
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"paths": {
"~/*": ["./src/*"]
}
},
"include": ["./src", "./tailwind.config.ts"],
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
tsconfig.node.json Normal file
View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import TSPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [react(), TSPaths()],
});

3030
yarn.lock Normal file

File diff suppressed because it is too large Load Diff