Саске Учиха 2 years ago
parent 4353ed3e4d
commit f6c27f0da4

2
.gitignore vendored

@ -0,0 +1,2 @@
dbconfig/
db/

@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

@ -0,0 +1,27 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
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-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
```
- 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

@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": false
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,59 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/inter": "^5.0.15",
"@hookform/resolvers": "^3.3.2",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.1.5",
"@react-icons/all-files": "https://github.com/react-icons/react-icons/releases/download/v4.7.1/react-icons-all-files-4.7.1.tgz",
"@tanstack/react-query": "^5.7.2",
"@tanstack/react-query-devtools": "^5.7.2",
"@tanstack/react-table": "^8.10.7",
"axios": "^1.6.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"date-fns": "^2.30.0",
"localforage": "^1.10.0",
"lodash.debounce": "^4.0.8",
"lucide-react": "^0.292.0",
"match-sorter": "^6.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.48.2",
"react-player": "^2.13.0",
"react-router-dom": "^6.18.0",
"sort-by": "^0.0.2",
"tailwind-merge": "^2.0.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/lodash.debounce": "^4.0.9",
"@types/node": "^20.8.10",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react": "^4.0.3",
"autoprefixer": "^10.4.16",
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"typescript": "^5.0.2",
"vite": "^4.4.5"
}
}

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

@ -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

@ -0,0 +1,80 @@
import { useRef, useState, useEffect } from 'react'
import ReactPlayer from 'react-player'
import { Disc3 } from 'lucide-react'
import {ImSoundcloud} from '@react-icons/all-files/im/ImSoundcloud'
import {BsSpotify} from '@react-icons/all-files/bs/BsSpotify'
import {BsFacebook} from '@react-icons/all-files/bs/BsFacebook'
import { MdHighQuality } from '@react-icons/all-files/md/MdHighQuality'
import { FaPlay } from '@react-icons/all-files/fa/FaPlay'
import { FaPause } from '@react-icons/all-files/fa/FaPause'
import {FaBackward} from '@react-icons/all-files/fa/FaBackward'
import {FaForward} from '@react-icons/all-files/fa/FaForward'
import {FaVolumeDown} from '@react-icons/all-files/fa/FaVolumeDown'
import {FaVolumeMute} from '@react-icons/all-files/fa/FaVolumeMute'
import { useQuery } from '@tanstack/react-query'
import VideoService from './services/VideoService'
function App() {
const ref = useRef<HTMLDivElement>(null);
const progressBar = useRef<HTMLDivElement>(null);
const player = useRef<ReactPlayer>(null);
const [progress, setProgress] = useState<number>(0);
const [progressSec, setProgressSec] = useState<number>(0);
const [volume, setVolume] = useState<boolean>(true);
const [playing, setPlaying] = useState<boolean>(false);
const { data, refetch } = useQuery({ queryKey: ['video '], queryFn: () => VideoService.GetOne()})
const [start, setStart] = useState<boolean>(true);
return (
<div className='bg-black flex justify-center h-screen shadow-inner'>
<div ref={ref} className="w-full flex flex-col shadow-inner">
<h1 className='absolute text-8xl top-4 left-4 drop-shadow-md font-bold text-white text-opacity-50'>808</h1>
<div className="w-full h-full absolute shadow-video"></div>
{start &&
<button onClick={()=> {setStart(false); setPlaying(true)}} className="absolute z-10 bg-yellow-500 shadow-xl p-4 px-8 text-3xl rounded-lg text-black font-bold left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">Начать</button>
}
<ReactPlayer onEnded={()=> {refetch(); player.current?.seekTo(0) }} volume={volume ? 1 : 0} playing={playing} ref={player} onProgress={(e) => {setProgress(e.played); setProgressSec(e.playedSeconds)}} className='h-screen w-full m-auto' width='auto' height='100%' url={data?.data.video.videoHQ}/>
<div className="absolute bottom-0 flex flex-col w-full overflow-hidden py-2">
<div ref={progressBar} onClick={(e)=> {player.current.seekTo(progressBar.current ? e.clientX / progressBar.current?.clientWidth : 0); setProgress(progressBar.current ? e.clientX / progressBar.current?.clientWidth : 0)}} className="bg-slate-500 h-2">
<div className="bg-yellow-500 h-2 relative" style={{width: progress*100 + '%'}}>
<div className="bg-white shadow-2xl h-4 w-4 rounded-full absolute -right-1 -top-1"></div>
</div>
</div>
<div className="py-3 px-10 flex justify-between">
<div className="flex items-center">
<div className="border-2 border-white p-2 rounded-full mr-5"><Disc3 strokeWidth={1.5} className='text-purple-500 w-8 h-auto'/></div>
<div className="flex flex-col mr-10">
<p className='text text-white'>Исполнитель</p>
<p className='text-lg font-medium text-white'>Название трека</p>
</div>
<div className="flex">
{data?.data.video.artist[0]?.soundcloud &&
<a className='border-2 border-slate-300 text-white hover:border-yellow-500 transition-all hover:text-yellow-500 mr-3 rounded-full text-2xl p-2' href={data?.data.video.artist[0]?.soundcloud} target='_blank'><ImSoundcloud /></a>
}
{data?.data.video.artist[0]?.spotify &&
<a className='border-2 border-slate-300 text-white hover:border-yellow-500 transition-all hover:text-yellow-500 mr-3 rounded-full text-2xl p-2' href={data?.data.video.artist[0]?.spotify} target='_blank'><BsSpotify /></a>
}
{data?.data.video.artist[0]?.facebook &&
<a className='border-2 border-slate-300 text-white hover:border-yellow-500 transition-all hover:text-yellow-500 rounded-full text-2xl p-2' href={data?.data.video.artist[0]?.facebook} target='_blank'><BsFacebook /></a>
}
</div>
</div>
<div className="flex items-center">
<button className="text-4xl mr-8 text-white"><MdHighQuality /></button>
<div className="flex items-center mr-8">
<button onClick={()=> player.current?.seekTo(progressSec - 10, 'seconds')} className='text-white text-2xl'><FaBackward className='fill-white'/></button>
<button onClick={()=> setPlaying(playing ? false : true)} className='text-white text-2xl mx-8'>{playing ? <FaPause className='fill-white'/> : <FaPlay className='fill-white'/>}</button>
<button onClick={()=> player.current?.seekTo(progressSec + 10, 'seconds')} className='text-white text-2xl'><FaForward className='fill-white'/></button>
</div>
<button onClick={()=> setVolume(volume ? false : true)} className='text-3xl text-white'>{volume ? <FaVolumeDown/> : <FaVolumeMute/>}</button>
</div>
</div>
</div>
</div>
</div>
)
}
export default App

@ -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="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@ -0,0 +1,76 @@
import { FC, useContext } from "react";
import { useForm, SubmitHandler } from "react-hook-form";
import { Input } from '@/components/ui/Input';
import { Button } from "@/components/ui/Button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/Form"
import AuthService from "@/services/AuthService";
import { useNavigate } from "react-router-dom";
import { AuthContext } from "@/components/Auth/AuthProvider";
type Form = {
login: string;
password: string
}
const LoginForm: FC = () => {
const LoginForm = useForm<Form>({
defaultValues: {
login: '',
password: ''
}
});
const navigate = useNavigate();
const {setIsAuth} = useContext(AuthContext)
const onSubmit: SubmitHandler<Form> = async(data) => {
await AuthService.login(data.login, data.password).then(
(res) => {
localStorage.setItem('token', res.data.accessToken);
localStorage.setItem('user', JSON.stringify(res.data.user));
setIsAuth(true);
navigate('/admin/artists')
}
)
}
return (
<div className="max-w-xl w-full m-0 p-10 shadow-md bg-white rounded-lg">
<h1 className="mb-5 text-5xl text-center font-extrabold text-slate-800">808</h1>
<Form {...LoginForm}>
<form onSubmit={LoginForm.handleSubmit(onSubmit)}>
<FormField
control={LoginForm.control}
name="login"
rules={{required: 'Поле обязательно к заполнению'}}
render={({ field }) => (
<FormItem className="mb-5">
<FormLabel>Логин:</FormLabel>
<FormControl>
<Input type="text" placeholder="Логин..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={LoginForm.control}
name="password"
rules={{required: 'Поле обязательно к заполнению'}}
render={({ field }) => (
<FormItem className="mb-8">
<FormLabel>Пароль:</FormLabel>
<FormControl>
<Input type="password" placeholder="Пароль..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">Войти</Button>
</form>
</Form>
</div>
)
}
export default LoginForm;

@ -0,0 +1,24 @@
import { FC } from 'react'
import MenuItem from './MenuItem';
import { MenuItemProps } from './MenuItem';
import { Users, Video, ListMusic, GaugeCircle, LockKeyhole } from 'lucide-react'
const Menu: FC = () => {
const menuItems = [
{id: 0, name: 'Общий дашбоард', path: '/admin', Ico: GaugeCircle},
{id: 1, name: 'Исполнители', path: '/admin/artists', Ico: Users},
{id: 2, name: 'Видео', path: '/admin/videos', Ico: Video},
{id: 3, name: 'Плейлисты', path: '/admin/playlists', Ico: ListMusic},
{id: 4, name: 'Пользователи', path: '/admin/users', Ico: LockKeyhole},
// {id: 5, name: 'Настройки', path: '/admin/settings', Ico: Settings}
] as ({id: number} & MenuItemProps)[];
return (
<nav className='flex-grow'>
{menuItems.map(item=>
<MenuItem key={item.id} className='mb-7' name={item.name} path={item.path} Ico={item.Ico}/>
)}
</nav>
)
}
export default Menu;

@ -0,0 +1,22 @@
import { FC } from 'react'
import { Link } from 'react-router-dom';
import { LucideIcon } from 'lucide-react';
import { useLocation } from 'react-router-dom';
export interface MenuItemProps {
name: string;
path: string;
Ico?: LucideIcon;
className?: string;
}
const MenuItem:FC<MenuItemProps> = ({name, path, Ico, className}) => {
const { pathname } = useLocation();
return (
<div className={['hover:bg-slate-200 rounded-md', pathname === path && 'bg-slate-200', className].join(' ')}>
<Link className='flex items-center py-3 px-5 ' to={path}>{Ico && <Ico className='mr-5'/>} {name}</Link>
</div>
)
}
export default MenuItem;

@ -0,0 +1,17 @@
import { FC } from 'react'
import { LogOut } from 'lucide-react'
import { useLogout } from '@/hooks/useLogout';
const Profile:FC = () => {
const login = JSON.parse(localStorage.getItem("user") || "").login;
const logout = useLogout();
return (
<div className='flex items-center px-3'>
<p>Добро пожаловать: <b>{login}</b></p>
<button><LogOut onClick={()=> logout()} className='ml-3' size={18}/></button>
</div>
)
}
export default Profile;

@ -0,0 +1,15 @@
import { FC } from 'react'
import Menu from './Menu';
import Profile from './Profile';
const Sidebar: FC = () => {
return (
<aside className='flex flex-col bg-slate-100 p-5 py-10 h-screen basis-[330px]'>
<h1 className='text-slate-900 font-extrabold text-5xl mb-14 px-5'>808</h1>
<Menu/>
<Profile/>
</aside>
)
}
export default Sidebar;

@ -0,0 +1,56 @@
import { ColumnDef } from "@tanstack/react-table"
import { IArtist } from "@/models/IArtist"
import AddModal from "./Modals/AddModal"
import RemoveModal from "./Modals/RemoveModal"
import { format } from 'date-fns'
export const ArtistColumns: ColumnDef<IArtist>[] = [
{
accessorKey: "name",
header: "Имя",
cell: ({ row }) => (
<div className="capitalize">{row.getValue("name")}</div>
),
},
{
accessorKey: "soundcloud",
header: "Soundcloud",
cell: ({ row }) => (
<div className="lowercase">{row.getValue("soundcloud")}</div>
),
},
{
accessorKey: "facebook",
header: "Facebook",
cell: ({ row }) => (
<div className="lowercase">{row.getValue("facebook")}</div>
),
},
{
accessorKey: "spotify",
header: "Spotify",
cell: ({ row }) => (
<div className="lowercase">{row.getValue("spotify")}</div>
),
},
{
accessorKey: "date",
header: "Date",
cell: ({ row }) => (
<div className="lowercase">{format(new Date(row.getValue("date")), 'dd.mm.yyyy')}</div>
),
},
{
id: "actions",
enableHiding: false,
maxSize: 0,
cell: ({row}) => {
return (
<div className="flex items-center justify-end">
<AddModal edit artist={row.original}/>
<RemoveModal artist_id={row.original._id}/>
</div>
)
},
},
]

@ -0,0 +1,52 @@
import { useState, useMemo } from "react";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import {PaginationState, ColumnFiltersState} from "@tanstack/react-table"
import ArtistService from "@/services/ArtistService";
import { useTable } from "@/hooks/useTable";
import DefaultTable from "../DefaultTable";
import TablePagination from "../TablePagination";
import TableSearch from "../TableSearch";
import { ArtistColumns } from "./ArtistColumns";
import { IArtist } from "@/models/IArtist";
import AddModal from "./Modals/AddModal";
const ArtistTable = () => {
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
)
const filtering = useMemo(
() => (columnFilters),
[columnFilters]
)
const fetchDataOptions = {
pageIndex,
pageSize,
columnFilters
}
const { data } = useQuery({ queryKey: ['artists', fetchDataOptions], queryFn: () => ArtistService.GetAll({page: pageIndex, search: columnFilters[0]?.value as string}), placeholderData: keepPreviousData})
const table = useTable<IArtist>({data: data?.data.artists, pageCount: data?.headers["x-total-count"], columns: ArtistColumns, pagination, setPagination, filtering, setColumnFilters});
return (
<div>
<div className="flex items-center py-4 justify-between">
<TableSearch table={table}/>
<AddModal />
</div>
<DefaultTable table={table} columns={ArtistColumns}/>
<TablePagination table={table}/>
</div>
)
}
export default ArtistTable;

@ -0,0 +1,114 @@
import { FC, useEffect, useState } from 'react'
import { useForm, SubmitHandler } from "react-hook-form";
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, DialogClose} from "@/components/ui/Dialog"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/Form"
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { IField } from '@/models/IField';
import { useArtist } from '@/hooks/useArtist';
import { useToast } from '@/components/ui/use-toast';
import { Pencil } from "lucide-react"
import { IArtist } from '@/models/IArtist';
interface AddModalProps {
edit?: boolean;
artist?: IArtist;
}
type AddForm = {
name: string;
soundcloud: string;
facebook: string;
spotify: string;
}
const fields: IField<AddForm>[] = [
{id: 0, label: 'Имя:', placeholder: 'Имя...', name: 'name', required: true},
{id: 1, label: 'Soundcloud:', placeholder: 'Soundcloud...', name: 'soundcloud'},
{id: 2, label: 'Facebook:', placeholder: 'Facebook...', name: 'facebook'},
{id: 3, label: 'Spotify:', placeholder: 'Spotify...', name: 'spotify'}
]
const AddModal: FC<AddModalProps> = ({edit, artist}) => {
const CreateForm = useForm<AddForm>({
defaultValues: {
name: '',
soundcloud: '',
facebook: '',
spotify: '',
},
});
useEffect(() => {
if(artist) {
CreateForm.setValue('name', artist?.name);
CreateForm.setValue('soundcloud', artist?.soundcloud ?? '');
CreateForm.setValue('facebook', artist?.facebook ?? '');
CreateForm.setValue('spotify', artist?.spotify ?? '');
}
}, [CreateForm, artist])
const [open, setOpen] = useState(false);
const { toast } = useToast()
const {createMutation, editMutation} = useArtist(() => {
setOpen(false);
toast({
title: 'Успешно!',
description: edit ? 'Артист успешно отредактирован.' : 'Новый артист успешно добавлен.'
})
}, artist?._id ?? '' );
const onSubmit: SubmitHandler<AddForm> = async(data) => {
edit ? editMutation.mutate(data) : createMutation.mutate(data);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{edit
?
<Button className="mr-2" variant="outline" size="icon" title="Редактировать">
<Pencil className="h-4 w-4" />
</Button>
:
<Button>Добавить исполнителя</Button>
}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать' : 'Добавить'} исполнителя</DialogTitle>
</DialogHeader>
<Form {...CreateForm}>
<form onSubmit={CreateForm.handleSubmit(onSubmit)}>
{fields.map(item =>
<FormField
key={item.id}
control={CreateForm.control}
name={item.name}
rules={item.required ? {required: 'Поле обязательно к заполнению'} : {}}
render={({ field }) => (
<FormItem className="mb-5">
<FormLabel>{item.label}</FormLabel>
<FormControl>
<Input placeholder={item.placeholder} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<DialogFooter>
<Button type='submit' className='mr-2' variant='default'>{edit ? 'Редактировать' : 'Добавить'}</Button>
<DialogClose asChild>
<Button type='button' variant='secondary'>Отмена</Button>
</DialogClose>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
export default AddModal;

@ -0,0 +1,48 @@
import { FC, useState } from 'react'
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogDescription, DialogTrigger, DialogClose} from "@/components/ui/Dialog"
import { Button } from '@/components/ui/Button'
import { useArtist } from '@/hooks/useArtist';
import { useToast } from '@/components/ui/use-toast';
import { Trash } from "lucide-react"
interface RemoveModalProps {
artist_id: string;
}
const RemoveModal: FC<RemoveModalProps> = ({artist_id}) => {
const [open, setOpen] = useState(false);
const { toast } = useToast()
const {deleteMutation} = useArtist(() => {
setOpen(false);
toast({
title: 'Успешно!',
description: 'Артист успешно удален.'
})
}, artist_id ?? '' );
const removeHandler = async () => {
deleteMutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="icon" title="Удалить">
<Trash className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Удалить исполнителя</DialogTitle>
<DialogDescription>Вы уверены что хотите удалить исполнителя ?</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={()=> removeHandler()} type='submit' className='mr-2' variant='destructive'>Удалить</Button>
<DialogClose asChild>
<Button type='button' variant='secondary'>Отмена</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default RemoveModal;

@ -0,0 +1,63 @@
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/Table"
import {flexRender} from "@tanstack/react-table"
import { Table as TableT } from '@tanstack/react-table'
import { ColumnDef } from "@tanstack/react-table"
interface DefaultTableProps<T> {
table: TableT<T>
columns: ColumnDef<T>[]
}
const DefaultTable = <T,>({table, columns}: DefaultTableProps<T>) => {
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} style={{width: header.getSize()}}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length
?
(table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell className="px-4 py-3" key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
)))
:
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
Нет результатов.
</TableCell>
</TableRow>
}
</TableBody>
</Table>
</div>
)
}
export default DefaultTable;

@ -0,0 +1,102 @@
import { FC, useEffect, useState } from 'react'
import { useForm, SubmitHandler } from "react-hook-form";
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, DialogClose} from "@/components/ui/Dialog"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/Form"
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { IField } from '@/models/IField';
import { usePlaylist } from '@/hooks/usePlaylist';
import { useToast } from '@/components/ui/use-toast';
import { Pencil } from "lucide-react"
import { IPlaylist } from '@/models/IPlaylist';
interface AddModalProps {
edit?: boolean;
playlist?: IPlaylist;
}
type AddForm = {
name: string;
}
const fields: IField<AddForm>[] = [
{id: 0, label: 'Имя:', placeholder: 'Имя...', name: 'name', required: true},
]
const AddModal: FC<AddModalProps> = ({edit, playlist}) => {
const CreateForm = useForm<AddForm>({
defaultValues: {
name: '',
},
});
useEffect(() => {
if(playlist) {
CreateForm.setValue('name', playlist?.name);
}
}, [CreateForm, playlist])
const [open, setOpen] = useState(false);
const { toast } = useToast()
const {createMutation, editMutation} = usePlaylist(() => {
setOpen(false);
toast({
title: 'Успешно!',
description: edit ? 'Плейлист успешно отредактирован.' : 'Новый плейлист успешно добавлен.'
})
}, playlist?._id ?? '' );
const onSubmit: SubmitHandler<AddForm> = async(data) => {
edit ? editMutation.mutate(data) : createMutation.mutate(data);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{edit
?
<Button className="mr-2" variant="outline" size="icon" title="Редактировать">
<Pencil className="h-4 w-4" />
</Button>
:
<Button>Добавить плейлист</Button>
}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать' : 'Добавить'} плейлист</DialogTitle>
</DialogHeader>
<Form {...CreateForm}>
<form onSubmit={CreateForm.handleSubmit(onSubmit)}>
{fields.map(item =>
<FormField
key={item.id}
control={CreateForm.control}
name={item.name}
rules={item.required ? {required: 'Поле обязательно к заполнению'} : {}}
render={({ field }) => (
<FormItem className="mb-5">
<FormLabel>{item.label}</FormLabel>
<FormControl>
<Input placeholder={item.placeholder} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<DialogFooter>
<Button type='submit' className='mr-2' variant='default'>{edit ? 'Редактировать' : 'Добавить'}</Button>
<DialogClose asChild>
<Button type='button' variant='secondary'>Отмена</Button>
</DialogClose>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
export default AddModal;

@ -0,0 +1,48 @@
import { FC, useState } from 'react'
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogDescription, DialogTrigger, DialogClose} from "@/components/ui/Dialog"
import { Button } from '@/components/ui/Button'
import { usePlaylist } from '@/hooks/usePlaylist';
import { useToast } from '@/components/ui/use-toast';
import { Trash } from "lucide-react"
interface RemoveModalProps {
playlist_id: string;
}
const RemoveModal: FC<RemoveModalProps> = ({playlist_id}) => {
const [open, setOpen] = useState(false);
const { toast } = useToast()
const {deleteMutation} = usePlaylist(() => {
setOpen(false);
toast({
title: 'Успешно!',
description: 'Плейлист успешно удален.'
})
}, playlist_id ?? '' );
const removeHandler = async () => {
deleteMutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="icon" title="Удалить">
<Trash className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Удалить плейлист</DialogTitle>
<DialogDescription>Вы уверены что хотите удалить плейлист ?</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={()=> removeHandler()} type='submit' className='mr-2' variant='destructive'>Удалить</Button>
<DialogClose asChild>
<Button type='button' variant='secondary'>Отмена</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default RemoveModal;

@ -0,0 +1,34 @@
import { ColumnDef } from "@tanstack/react-table"
import { IPlaylist } from "@/models/IPlaylist"
import AddModal from "./Modals/AddModal"
import RemoveModal from "./Modals/RemoveModal"
import { format } from 'date-fns'
export const PlaylistColumns: ColumnDef<IPlaylist>[] = [
{
accessorKey: "name",
header: "Имя",
cell: ({ row }) => (
<div className="capitalize">{row.getValue("name")}</div>
),
},
{
accessorKey: "date",
header: "Date",
cell: ({ row }) => (
<div className="lowercase">{format(new Date(row.getValue("date")), 'dd.mm.yyyy')}</div>
),
},
{
id: "actions",
enableHiding: false,
cell: ({row}) => {
return (
<div className="flex items-center justify-end">
<AddModal edit playlist={row.original}/>
<RemoveModal playlist_id={row.original._id}/>
</div>
)
},
},
]

@ -0,0 +1,52 @@
import { useState, useMemo } from "react";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import {PaginationState, ColumnFiltersState} from "@tanstack/react-table"
import PlaylistService from "@/services/PlaylistService";
import { useTable } from "@/hooks/useTable";
import DefaultTable from "../DefaultTable";
import TablePagination from "../TablePagination";
import TableSearch from "../TableSearch";
import { PlaylistColumns } from "./PlaylistColumns";
import { IPlaylist } from "@/models/IPlaylist";
import AddModal from "./Modals/AddModal";
const PlaylistTable = () => {
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
)
const filtering = useMemo(
() => (columnFilters),
[columnFilters]
)
const fetchDataOptions = {
pageIndex,
pageSize,
columnFilters
}
const { data } = useQuery({ queryKey: ['playlists', fetchDataOptions], queryFn: () => PlaylistService.GetAll({page: pageIndex, search: columnFilters[0]?.value as string}), placeholderData: keepPreviousData})
const table = useTable<IPlaylist>({data: data?.data.playlists, pageCount: data?.headers["x-total-count"], columns: PlaylistColumns, pagination, setPagination, filtering, setColumnFilters});
return (
<div>
<div className="flex items-center py-4 justify-between">
<TableSearch table={table}/>
<AddModal />
</div>
<DefaultTable table={table} columns={PlaylistColumns}/>
<TablePagination table={table}/>
</div>
)
}
export default PlaylistTable;

@ -0,0 +1,33 @@
import { Button } from "@/components/ui/Button";
import { Table } from "@tanstack/react-table";
import { ChevronLeft, ChevronsLeft, ChevronRight, ChevronsRight } from "lucide-react"
const TablePagination = <T,>({table}: {table: Table<T>}) => {
return (
<div className="flex items-center justify-end space-x-2 py-4">
<div className="space-x-2 flex items-center">
<p className="mr-5">Страница:
<span className="font-semibold">
{' '}
{table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount()}
</span>
</p>
<Button variant="outline" size="sm" onClick={() => table?.setPageIndex(0)} disabled={!table.getCanPreviousPage()}>
<ChevronsLeft />
</Button>
<Button variant="outline" size="sm" onClick={() => table?.previousPage()} disabled={!table.getCanPreviousPage()}>
<ChevronLeft />
</Button>
<Button variant="outline" size="sm" onClick={() => table?.nextPage()} disabled={!table.getCanNextPage()}>
<ChevronRight />
</Button>
<Button variant="outline" size="sm" onClick={() => table?.setPageIndex(table.getPageCount() - 1)} disabled={!table.getCanNextPage()}>
<ChevronsRight />
</Button>
</div>
</div>
)
}
export default TablePagination;

@ -0,0 +1,23 @@
import { useState, useMemo } from "react";
import debounce from "lodash.debounce";
import { Table } from "@tanstack/react-table";
import { Input } from "@/components/ui/Input";
const TableSearch = <T,>({table}: {table: Table<T>}) => {
const [filterInput, setFilterInput] = useState<string>('')
const filterHandler = useMemo(() => {
return debounce((e: string)=> {
table?.getColumn("name")?.setFilterValue(e);
table?.setPageIndex(0);
}, 1000);
}, [table]);
return (
<Input className="max-w-sm" placeholder="Поиск..." value={filterInput}
onChange={(e) => {setFilterInput(e.target.value); filterHandler(e.target.value)}}
/>
)
}
export default TableSearch;

@ -0,0 +1,105 @@
import { FC, useEffect, useState } from 'react'
import { useForm, SubmitHandler } from "react-hook-form";
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, DialogClose} from "@/components/ui/Dialog"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/Form"
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { IField } from '@/models/IField';
import { useUser } from '@/hooks/useUser';
import { useToast } from '@/components/ui/use-toast';
import { Pencil } from "lucide-react"
import { IUser } from '@/models/IUser';
interface AddModalProps {
edit?: boolean;
user?: IUser;
}
type AddForm = {
login: string;
password: string;
}
const AddModal: FC<AddModalProps> = ({edit, user}) => {
const fields: IField<AddForm>[] = [
{id: 0, label: 'Логин:', placeholder: 'Логин...', name: 'login', required: true},
{id: 1, label: 'Пароль:', placeholder: 'Пароль...', name: 'password', required: edit ? false : true},
]
const CreateForm = useForm<AddForm>({
defaultValues: {
login: '',
password: '',
},
});
useEffect(() => {
if(user) {
CreateForm.setValue('login', user?.login);
}
}, [CreateForm, user])
const [open, setOpen] = useState(false);
const { toast } = useToast()
const {createMutation, editMutation} = useUser(() => {
setOpen(false);
toast({
title: 'Успешно!',
description: edit ? 'Пользователь успешно отредактирован.' : 'Новый пользователь успешно добавлен.'
})
}, user?._id ?? '' );
const onSubmit: SubmitHandler<AddForm> = async(data) => {
edit ? editMutation.mutate(data) : createMutation.mutate(data);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{edit
?
<Button className="mr-2" variant="outline" size="icon" title="Редактировать">
<Pencil className="h-4 w-4" />
</Button>
:
<Button>Добавить пользователя</Button>
}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать' : 'Добавить'} пользователя</DialogTitle>
</DialogHeader>
<Form {...CreateForm}>
<form onSubmit={CreateForm.handleSubmit(onSubmit)}>
{fields.map(item =>
<FormField
key={item.id}
control={CreateForm.control}
name={item.name}
rules={item.required ? {required: 'Поле обязательно к заполнению'} : {}}
render={({ field }) => (
<FormItem className="mb-5">
<FormLabel>{item.label}</FormLabel>
<FormControl>
<Input placeholder={item.placeholder} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<DialogFooter>
<Button type='submit' className='mr-2' variant='default'>{edit ? 'Редактировать' : 'Добавить'}</Button>
<DialogClose asChild>
<Button type='button' variant='secondary'>Отмена</Button>
</DialogClose>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
export default AddModal;

@ -0,0 +1,48 @@
import { FC, useState } from 'react'
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogDescription, DialogTrigger, DialogClose} from "@/components/ui/Dialog"
import { Button } from '@/components/ui/Button'
import { useUser } from '@/hooks/useUser'
import { useToast } from '@/components/ui/use-toast';
import { Trash } from "lucide-react"
interface RemoveModalProps {
user_id: string;
}
const RemoveModal: FC<RemoveModalProps> = ({user_id}) => {
const [open, setOpen] = useState(false);
const { toast } = useToast()
const {deleteMutation} = useUser(() => {
setOpen(false);
toast({
title: 'Успешно!',
description: 'Пользователь успешно удален.'
})
}, user_id ?? '' );
const removeHandler = async () => {
deleteMutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="icon" title="Удалить">
<Trash className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Удалить пользователя</DialogTitle>
<DialogDescription>Вы уверены что хотите удалить пользователя ?</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={()=> removeHandler()} type='submit' className='mr-2' variant='destructive'>Удалить</Button>
<DialogClose asChild>
<Button type='button' variant='secondary'>Отмена</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default RemoveModal;

@ -0,0 +1,27 @@
import { ColumnDef } from "@tanstack/react-table"
import { IUser } from "@/models/IUser"
import AddModal from "./Modals/AddModal"
import RemoveModal from "./Modals/RemoveModal"
export const UserColumns: ColumnDef<IUser>[] = [
{
accessorKey: "name",
header: "Логин",
cell: ({ row }) => (
<div className="capitalize">{row.original.login}</div>
),
},
{
id: "actions",
enableHiding: false,
maxSize: 0,
cell: ({row}) => {
return (
<div className="flex items-center justify-end">
<AddModal edit user={row.original}/>
<RemoveModal user_id={row.original._id}/>
</div>
)
},
},
]

@ -0,0 +1,52 @@
import { useState, useMemo } from "react";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import {PaginationState, ColumnFiltersState} from "@tanstack/react-table"
import UserService from "@/services/UserService";
import { useTable } from "@/hooks/useTable";
import DefaultTable from "../DefaultTable";
import TablePagination from "../TablePagination";
import TableSearch from "../TableSearch";
import { UserColumns } from "./UserColumns";
import { IUser } from "@/models/IUser";
import AddModal from "./Modals/AddModal";
const UsersTable = () => {
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
)
const filtering = useMemo(
() => (columnFilters),
[columnFilters]
)
const fetchDataOptions = {
pageIndex,
pageSize,
columnFilters
}
const { data } = useQuery({ queryKey: ['users', fetchDataOptions], queryFn: () => UserService.GetAll({page: pageIndex, search: columnFilters[0]?.value as string}), placeholderData: keepPreviousData})
const table = useTable<IUser>({data: data?.data.users, pageCount: data?.headers["x-total-count"], columns: UserColumns, pagination, setPagination, filtering, setColumnFilters});
return (
<div>
<div className="flex items-center py-4 justify-between">
<TableSearch table={table}/>
<AddModal />
</div>
<DefaultTable table={table} columns={UserColumns}/>
<TablePagination table={table}/>
</div>
)
}
export default UsersTable;

@ -0,0 +1,165 @@
import { FC, useEffect, useState } from 'react'
import { useForm, SubmitHandler } from "react-hook-form";
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, DialogClose} from "@/components/ui/Dialog"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/Form"
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { IField } from '@/models/IField';
import { useVideo } from '@/hooks/useVideo';
import { useToast } from '@/components/ui/use-toast';
import { Pencil } from "lucide-react"
import { IVideo } from '@/models/IVideo';
import PlaylistService from '@/services/PlaylistService';
import ArtistService from '@/services/ArtistService';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import ComboBox from '@/components/ui/ComboBox';
interface AddModalProps {
edit?: boolean;
video?: IVideo;
}
export type AddForm = {
name: string;
videoHQ: string;
videoLQ: string;
playlist: string;
artist: string;
}
type IFieldExtended<T> = IField<T> & {type: string}
const fields: IFieldExtended<AddForm>[] = [
{id: 0, label: 'Название видео:', placeholder: 'Название...', name: 'name', required: true, type: 'text'},
// {id: 1, label: 'Исполнитель:', placeholder: 'Исполнитель...', name: 'artist', required: true, type: 'text'},
// {id: 2, label: 'Плейлист:', placeholder: 'Плейлист...', name: 'playlist', required: true, type: 'text'},
{id: 3, label: 'Видео HQ:', placeholder: 'Видео HQ...', name: 'videoHQ', required: true, type: 'file'},
{id: 4, label: 'Видео LQ:', placeholder: 'Видео LQ...', name: 'videoLQ', required: true, type: 'file'},
]
const AddModal: FC<AddModalProps> = ({edit, video}) => {
const [finds, setFinds] = useState({
find1: '',
find2: ''
})
const { data: playlists } = useQuery({ queryKey: ['playlists', {finds}], queryFn: () => PlaylistService.GetAll({search: finds.find2}), enabled: finds.find2.length >= 3, placeholderData: keepPreviousData});
const { data: artists } = useQuery({ queryKey: ['artists', {finds}], queryFn: () => ArtistService.GetAll({search: finds.find1}), enabled: finds.find1.length >= 3, placeholderData: keepPreviousData})
const CreateForm = useForm<AddForm>({
defaultValues: {
name: '',
videoHQ: '',
playlist: '',
artist: ''
},
});
const [open, setOpen] = useState(false)
useEffect(() => {
if(video) {
CreateForm.setValue('name', video?.name);
setFinds({find2: video.playlist?.name ?? '', find1: video?.artist?.name ?? ''})
CreateForm.setValue('artist', video?.artist?._id ?? '');
CreateForm.setValue('playlist', video?.playlist?._id ?? '');
}
}, [CreateForm, video])
const { toast } = useToast()
const {createMutation, editMutation} = useVideo(() => {
setOpen(false);
toast({
title: 'Успешно!',
description: edit ? 'Видео успешно отредактировано.' : 'Новое видео успешно добавлено.'
})
}, video?._id ?? '' );
const onSubmit: SubmitHandler<AddForm> = async(data) => {
edit ? editMutation.mutate(data) : createMutation.mutate(data);
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>, name: keyof AddForm) => {
CreateForm.setValue(name, e.target.files as unknown as string);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{edit
?
<Button className="mr-2" variant="outline" size="icon" title="Редактировать">
<Pencil className="h-4 w-4" />
</Button>
:
<Button>Добавить видео</Button>
}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать' : 'Добавить'} видео</DialogTitle>
</DialogHeader>
<Form {...CreateForm}>
<form onSubmit={CreateForm.handleSubmit(onSubmit)}>
{fields.map(item =>
<FormField
key={item.id}
control={CreateForm.control}
name={item.name}
rules={item.required && !edit ? {required: 'Поле обязательно к заполнению'} : {}}
render={({ field }) => (
<FormItem className="mb-5">
<FormLabel>{item.label}</FormLabel>
<FormControl>
<Input type={item.type} placeholder={item.placeholder}
{...(item.type !== 'file' && { ...field})}
{...(item.type === 'file' && { onChange: (e) => handleChange(e, item.name)})} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={CreateForm.control}
name='artist'
rules={{required: 'Поле обязательно к заполнению'}}
render={({ field }) => (
<FormItem className="mb-5">
<FormLabel>Артист:</FormLabel>
<ComboBox field={field.value} onSelect={(e: string)=> CreateForm.setValue("artist", e)} onValueChange={(e: string)=> setFinds({...finds, find1: e})} data={artists?.data.artists}/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={CreateForm.control}
name='playlist'
rules={{required: 'Поле обязательно к заполнению'}}
render={({ field }) => (
<FormItem className="mb-5 w-full">
<FormLabel>Плейлист:</FormLabel>
<ComboBox field={field.value} onSelect={(e: string)=> CreateForm.setValue("playlist", e)} onValueChange={(e: string)=> setFinds({...finds, find2: e})} data={playlists?.data.playlists}/>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type='submit' className='mr-2' variant='default'>{edit ? 'Редактировать' : 'Добавить'}</Button>
<DialogClose asChild>
<Button type='button' variant='secondary'>Отмена</Button>
</DialogClose>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
export default AddModal;

@ -0,0 +1,48 @@
import { FC, useState } from 'react'
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogDescription, DialogTrigger, DialogClose} from "@/components/ui/Dialog"
import { Button } from '@/components/ui/Button'
import { useVideo } from '@/hooks/useVideo'
import { useToast } from '@/components/ui/use-toast';
import { Trash } from "lucide-react"
interface RemoveModalProps {
video_id: string;
}
const RemoveModal: FC<RemoveModalProps> = ({video_id}) => {
const [open, setOpen] = useState(false);
const { toast } = useToast()
const {deleteMutation} = useVideo(() => {
setOpen(false);
toast({
title: 'Успешно!',
description: 'Видео успешно удалено.'
})
}, video_id ?? '' );
const removeHandler = async () => {
deleteMutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="icon" title="Удалить">
<Trash className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Удалить видео</DialogTitle>
<DialogDescription>Вы уверены что хотите удалить видео ?</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={()=> removeHandler()} type='submit' className='mr-2' variant='destructive'>Удалить</Button>
<DialogClose asChild>
<Button type='button' variant='secondary'>Отмена</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default RemoveModal;

@ -0,0 +1,49 @@
import { ColumnDef } from "@tanstack/react-table"
import { IVideo } from "@/models/IVideo"
import AddModal from "./Modals/AddModal"
import RemoveModal from "./Modals/RemoveModal"
import { format } from 'date-fns'
export const VideoColumns: ColumnDef<IVideo>[] = [
{
accessorKey: "name",
header: "Имя",
cell: ({ row }) => (
<div className="capitalize">{row.getValue("name")}</div>
),
},
{
accessorKey: "playlist",
header: "Плейлист",
cell: ({ row }) => (
<div className="lowercase">{row.original.playlist?.name}</div>
),
},
{
accessorKey: "artist",
header: "Исполнитель",
cell: ({ row }) => (
<div className="lowercase">{row.original.artist?.name}</div>
),
},
{
accessorKey: "date",
header: "Date",
cell: ({ row }) => (
<div className="lowercase">{format(new Date(row.getValue("date")), 'dd.mm.yyyy')}</div>
),
},
{
id: "actions",
enableHiding: false,
maxSize: 0,
cell: ({row}) => {
return (
<div className="flex items-center justify-end">
<AddModal edit video={row.original}/>
<RemoveModal video_id={row.original._id}/>
</div>
)
},
},
]

@ -0,0 +1,52 @@
import { useState, useMemo } from "react";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import {PaginationState, ColumnFiltersState} from "@tanstack/react-table"
import VideoService from "@/services/VideoService";
import { useTable } from "@/hooks/useTable";
import DefaultTable from "../DefaultTable";
import TablePagination from "../TablePagination";
import TableSearch from "../TableSearch";
import { VideoColumns } from "./VideoColumns";
import { IVideo } from "@/models/IVideo";
import AddModal from "./Modals/AddModal";
const VideoTable = () => {
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
)
const filtering = useMemo(
() => (columnFilters),
[columnFilters]
)
const fetchDataOptions = {
pageIndex,
pageSize,
columnFilters
}
const { data } = useQuery({ queryKey: ['videos', fetchDataOptions], queryFn: () => VideoService.GetAll({page: pageIndex, search: columnFilters[0]?.value as string}), placeholderData: keepPreviousData})
const table = useTable<IVideo>({data: data?.data.videos, pageCount: data?.headers["x-total-count"], columns: VideoColumns, pagination, setPagination, filtering, setColumnFilters});
return (
<div>
<div className="flex items-center py-4 justify-between">
<TableSearch table={table}/>
<AddModal />
</div>
<DefaultTable table={table} columns={VideoColumns}/>
<TablePagination table={table}/>
</div>
)
}
export default VideoTable;

@ -0,0 +1,49 @@
import {FC, PropsWithChildren, useEffect, useState, createContext} from 'react';
import axios from "axios";
import { AuthResponse } from '@/models/response/AuthResponse';
import { API_URL } from '@/http';
type AuthContextType = {
isAuth: boolean;
setIsAuth: (bool: boolean) => void;
}
export const AuthContext = createContext<AuthContextType>({} as AuthContextType);
const checkAuth = async (setIsLoading: (bool: boolean) => void, setIsAuth: (bool: boolean) => void) => {
if(localStorage.getItem('token')) {
try {
await axios.get<AuthResponse>(`${API_URL}/auth/refresh`, {withCredentials: true}).then(
(res)=> {
localStorage.setItem('token', res.data.accessToken);
localStorage.setItem('user', JSON.stringify(res.data.user));
setIsLoading(false);
setIsAuth(true);
}
);
} catch (error) {
setIsLoading(false);
console.log(error)
}
} else {
setIsLoading(false);
}
}
const AuthProvider: FC<PropsWithChildren> = ({children}) => {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isAuth, setIsAuth] = useState<boolean>(false);
useEffect(() => {
checkAuth(setIsLoading, setIsAuth);
}, [])
return (
<AuthContext.Provider value={{isAuth, setIsAuth}}>
{!isLoading &&
children
}
</AuthContext.Provider>
)
}
export default AuthProvider;

@ -0,0 +1,17 @@
import { FC, PropsWithChildren, useContext } from 'react'
import { useLocation, Navigate } from 'react-router-dom';
import { AuthContext } from "@/components/Auth/AuthProvider";
const AuthRequired: FC<PropsWithChildren> = ({children}) => {
const {isAuth} = useContext(AuthContext)
const location = useLocation();
if (!isAuth) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
export default AuthRequired;

@ -0,0 +1,17 @@
import { FC, PropsWithChildren, useContext } from 'react'
import { useLocation, Navigate } from 'react-router-dom';
import { AuthContext } from "@/components/Auth/AuthProvider";
const UnauthorizeRequired: FC<PropsWithChildren> = ({children}) => {
const {isAuth} = useContext(AuthContext)
const location = useLocation();
if (isAuth) {
return <Navigate to="/" state={{ from: location }} replace />;
}
return children;
}
export default UnauthorizeRequired;

@ -0,0 +1,17 @@
import { FC } from 'react'
import Sidebar from '../Admin/Sidebar/Sidebar';
import { Toaster } from '../ui/Toaster';
import { Outlet } from 'react-router-dom';
const AdminLayout: FC = () => {
return (
<div className='flex flex-grow'>
<Sidebar/>
<Outlet />
<Toaster />
</div>
)
}
export default AdminLayout;

@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-slate-950 dark:focus-visible:ring-slate-300",
{
variants: {
variant: {
default: "bg-slate-900 text-slate-50 hover:bg-slate-900/90 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/90",
destructive:
"bg-red-500 text-slate-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-slate-50 dark:hover:bg-red-900/90",
outline:
"border border-slate-200 bg-white hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50",
secondary:
"bg-slate-100 text-slate-900 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80",
ghost: "hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50",
link: "text-slate-900 underline-offset-4 hover:underline dark:text-slate-50",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

@ -0,0 +1,72 @@
import {Command, CommandEmpty, CommandGroup, CommandInput, CommandItem} from "@/components/ui/Command"
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/Popover"
import { FormControl } from "./Form";
import { Button } from "./Button";
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
interface ComboBox {
field: string,
onSelect: (e: string) => void,
onValueChange: (e: string) => void,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any
}
const ComboBox = ({field, data, onValueChange, onSelect}: ComboBox) => {
return (
<div className="">
<Popover>
<PopoverTrigger className='' asChild>
<FormControl className=''>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field && "text-muted-foreground"
)}
>
{field
? data?.find(
(item: { _id: string; }) => item._id === field
)?.name
: "Выбрать"}
{/* <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> */}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[462px] p-0">
<Command className='w-full'>
<CommandInput className='w-full' onValueChange={onValueChange} placeholder="Введите 3 символа..." />
<CommandEmpty>Ничего не найдено.</CommandEmpty>
<CommandGroup >
{data?.map((item: {name: string, _id: string}) => (
<CommandItem
className="w-full"
value={item.name}
key={item._id}
onSelect={() => {
onSelect(item._id)
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
item._id === field
? "opacity-100"
: "opacity-0"
)}
/>
{item.name}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
)
}
export default ComboBox;

@ -0,0 +1,153 @@
import * as React from "react"
import { DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/Dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-slate-500 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5 dark:[&_[cmdk-group-heading]]:text-slate-400">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 dark:placeholder:text-slate-400",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-slate-950 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-slate-500 dark:text-slate-50 dark:[&_[cmdk-group-heading]]:text-slate-400",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-slate-200 dark:bg-slate-800", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-slate-100 aria-selected:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:aria-selected:bg-slate-800 dark:aria-selected:text-slate-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-slate-500 dark:text-slate-400",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-white/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 dark:bg-slate-950/80",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-slate-200 bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full dark:border-slate-800 dark:bg-slate-950",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 data-[state=open]:text-slate-500 dark:ring-offset-slate-950 dark:focus:ring-slate-300 dark:data-[state=open]:bg-slate-800 dark:data-[state=open]:text-slate-400">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-slate-500 dark:text-slate-400", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

@ -0,0 +1,176 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/Label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-red-500 dark:text-red-900", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-slate-500 dark:text-slate-400", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-red-500 dark:text-red-900", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:bg-slate-950 dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus-visible:ring-slate-300",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

@ -0,0 +1,29 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border border-slate-200 bg-white p-4 text-slate-950 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("bg-slate-900 font-medium text-slate-50 dark:bg-slate-50 dark:text-slate-900", className)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-slate-100/50 data-[state=selected]:bg-slate-100 dark:hover:bg-slate-800/50 dark:data-[state=selected]:bg-slate-800",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-slate-500 [&:has([role=checkbox])]:pr-0 dark:text-slate-400",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-slate-500 dark:text-slate-400", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

@ -0,0 +1,127 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border border-slate-200 p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full dark:border-slate-800",
{
variants: {
variant: {
default: "border bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50",
destructive:
"destructive group border-red-500 bg-red-500 text-slate-50 dark:border-red-900 dark:bg-red-900 dark:text-slate-50",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border border-slate-200 bg-transparent px-3 text-sm font-medium ring-offset-white transition-colors hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-slate-100/40 group-[.destructive]:hover:border-red-500/30 group-[.destructive]:hover:bg-red-500 group-[.destructive]:hover:text-slate-50 group-[.destructive]:focus:ring-red-500 dark:border-slate-800 dark:ring-offset-slate-950 dark:hover:bg-slate-800 dark:focus:ring-slate-300 dark:group-[.destructive]:border-slate-800/40 dark:group-[.destructive]:hover:border-red-900/30 dark:group-[.destructive]:hover:bg-red-900 dark:group-[.destructive]:hover:text-slate-50 dark:group-[.destructive]:focus:ring-red-900",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-slate-950/50 opacity-0 transition-opacity hover:text-slate-950 focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600 dark:text-slate-50/50 dark:hover:text-slate-50",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

@ -0,0 +1,33 @@
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/Toast"
import { useToast } from "@/components/ui/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

@ -0,0 +1,192 @@
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/Toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

@ -0,0 +1,40 @@
import ArtistService from '@/services/ArtistService';
import { IArtistReq } from '@/models/IArtist';
import { useMutation, useQueryClient } from '@tanstack/react-query';
export const useArtist = (callback: () => void, _id: string) => {
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: (newArtist: IArtistReq) => {
return ArtistService.Create(newArtist);
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['artists']});
callback();
}
})
const editMutation = useMutation({
mutationFn: (editedArtist: IArtistReq) => {
return ArtistService.Edit(_id, editedArtist);
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['artists']});
callback();
}
})
const deleteMutation = useMutation({
mutationFn: () => {
return ArtistService.Delete(_id);
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['artists']});
callback();
}
})
return {createMutation, editMutation, deleteMutation};
}

@ -0,0 +1,25 @@
import { useContext } from 'react'
import AuthService from '@/services/AuthService'
import { AuthContext } from "@/components/Auth/AuthProvider";
export const useLogout = () => {
const { setIsAuth } = useContext(AuthContext);
const Logout = async () => {
try {
await AuthService.logout().then(
() => {
localStorage.removeItem('token');
localStorage.removeItem('user');
setIsAuth(false);
}
);
} catch (e) {
setIsAuth(false);
console.log(e);
}
}
return Logout;
}

@ -0,0 +1,40 @@
import PlaylistService from '@/services/PlaylistService';
import { IPlaylistReq } from '@/models/IPlaylist';
import { useMutation, useQueryClient } from '@tanstack/react-query';
export const usePlaylist = (callback: () => void, _id: string) => {
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: (newPlaylist: IPlaylistReq) => {
return PlaylistService.Create(newPlaylist);
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['playlists']});
callback();
}
})
const editMutation = useMutation({
mutationFn: (editedPlaylist: IPlaylistReq) => {
return PlaylistService.Edit(_id, editedPlaylist);
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['playlists']});
callback();
}
})
const deleteMutation = useMutation({
mutationFn: () => {
return PlaylistService.Delete(_id);
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['playlists']});
callback();
}
})
return {createMutation, editMutation, deleteMutation};
}

@ -0,0 +1,32 @@
import { getCoreRowModel, useReactTable, PaginationState, ColumnFiltersState} from "@tanstack/react-table"
import { ColumnDef } from '@tanstack/react-table';
interface useTableProps<T> {
data?: T[];
pageCount?: number;
columns: ColumnDef<T>[];
pagination: PaginationState;
setPagination: React.Dispatch<React.SetStateAction<PaginationState>>;
filtering: ColumnFiltersState;
setColumnFilters: React.Dispatch<React.SetStateAction<ColumnFiltersState>>
}
export const useTable = <T>({data, pageCount, columns, pagination, setPagination, filtering, setColumnFilters}: useTableProps<T>) => {
const table = useReactTable<T>({
data: data ?? [],
columns: columns,
pageCount: pageCount,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualFiltering: true,
state: {
pagination,
columnFilters: filtering,
},
onPaginationChange: setPagination,
onColumnFiltersChange: setColumnFilters
})
return table;
}

@ -0,0 +1,41 @@
import AuthService from '@/services/AuthService';
import UserService from '@/services/UserService';
import { IUserReq } from '@/models/IUser';
import { useMutation, useQueryClient } from '@tanstack/react-query';
export const useUser = (callback: () => void, _id: string) => {
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: (newUser: IUserReq & {password: string}) => {
return AuthService.registration(newUser);
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['users']});
callback();
}
})
const editMutation = useMutation({
mutationFn: (editedUser: IUserReq) => {
return UserService.Edit(_id, editedUser);
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['users']});
callback();
}
})
const deleteMutation = useMutation({
mutationFn: () => {
return UserService.Delete(_id);
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['users']});
callback();
}
})
return {createMutation, editMutation, deleteMutation};
}

@ -0,0 +1,40 @@
import VideoService from '@/services/VideoService';
import { IVideoReq } from '@/models/IVideo';
import { useMutation, useQueryClient } from '@tanstack/react-query';
export const useVideo = (callback: () => void, _id: string) => {
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: (newVideo: IVideoReq) => {
return VideoService.Create(newVideo);
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['videos']});
callback();
}
})
const editMutation = useMutation({
mutationFn: (editedVideo: IVideoReq) => {
return VideoService.Edit(_id, editedVideo);
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['videos']});
callback();
}
})
const deleteMutation = useMutation({
mutationFn: () => {
return VideoService.Delete(_id);
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['videos']});
callback();
}
})
return {createMutation, editMutation, deleteMutation};
}

@ -0,0 +1,33 @@
import axios from "axios";
import { AuthResponse } from "@/models/response/AuthResponse";
export const API_URL = `http://localhost:8080/api`
const $api = axios.create({
withCredentials: true,
baseURL: API_URL
})
$api.interceptors.request.use(config => {
config.headers.Authorization = `Bearer ${localStorage.getItem('token')}`;
return config;
})
$api.interceptors.response.use((config) => {
return config;
}, async (error) => {
const originalRequest = error.config
if(error.response.status === 401) {
try {
const response = await axios.get<AuthResponse>(`${API_URL}/auth/refresh`, {withCredentials: true});
localStorage.setItem('token', response.data.accessToken);
return $api.request(originalRequest)
} catch (e) {
console.log('НЕ АВТОРИЗОВАН !')
}
}
});
export default $api;

@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: 'Inter', sans-serif;
}

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

@ -0,0 +1,28 @@
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { RouterProvider } from "react-router-dom";
import AuthProvider from './components/Auth/AuthProvider.tsx';
import adminRoutes from './routers/admin.routes.tsx'
import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css';
import '@fontsource/inter/700.css';
import '@fontsource/inter/800.css';
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false
}
}
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<AuthProvider>
<RouterProvider router={adminRoutes} />
</AuthProvider>
</QueryClientProvider>
)

@ -0,0 +1,15 @@
export interface IArtist {
_id: string;
name: string;
soundcloud?: string;
facebook?: string;
spotify?: string;
date: Date
}
export interface IArtistReq {
name: string;
soundcloud?: string;
facebook?: string;
spotify?: string;
}

@ -0,0 +1,7 @@
export interface IField<T> {
id: number;
label: string;
placeholder: string;
name: keyof T;
required?: boolean;
}

@ -0,0 +1,9 @@
export interface IPlaylist {
_id: string;
name: string;
date: Date
}
export interface IPlaylistReq {
name?: string;
}

@ -0,0 +1,8 @@
export interface IUser {
_id: string;
login: string;
}
export interface IUserReq {
login: string;
}

@ -0,0 +1,21 @@
import { IArtist } from "./IArtist";
export interface IVideo {
_id: string;
name: string;
videoHQ?: string;
videoLQ?: string;
playlist?: {
_id: string,
name: string
};
artist?: IArtist,
date: Date
}
export interface IVideoReq {
name: string;
videoHQ?: string;
videoLQ?: string;
playlist?: string;
artist: string;
}

@ -0,0 +1,9 @@
import { IArtist } from "../IArtist";
export interface ArtistResponse {
artist: IArtist;
}
export interface ArtistsResponse {
artists: IArtist[];
}

@ -0,0 +1,7 @@
import { IUser } from "../IUser";
export interface AuthResponse {
accessToken: string;
refreshToken: string;
user: IUser;
}

@ -0,0 +1,9 @@
import { IPlaylist } from "../IPlaylist";
export interface PlaylistResponse {
playlist: IPlaylist;
}
export interface PlaylistsResponse {
playlists: IPlaylist[];
}

@ -0,0 +1,9 @@
import { IUser } from "../IUser";
export interface UserResponse {
user: IUser;
}
export interface UsersResponse {
users: IUser[];
}

@ -0,0 +1,9 @@
import { IVideo } from "../IVideo";
export interface VideoResponse {
video: IVideo;
}
export interface VideosResponse {
videos: IVideo[];
}

@ -0,0 +1,11 @@
import ArtistTable from "@/components/Admin/Tables/ArtistTable/ArtistTable";
const ArtistsPage = () => {
return (
<div className="w-full p-10">
<ArtistTable/>
</div>
)
}
export default ArtistsPage;

@ -0,0 +1,11 @@
import LoginForm from '@/components/Admin/LoginForm/LoginForm';
const LoginPage = () => {
return (
<div className="flex w-full h-screen bg-slate-100 justify-center items-center">
<LoginForm/>
</div>
)
}
export default LoginPage;

@ -0,0 +1,11 @@
import PlaylistTable from "@/components/Admin/Tables/PlaylistTable/PlaylistTable";
const PlatlistsPage = () => {
return (
<div className="w-full p-10">
<PlaylistTable/>
</div>
)
}
export default PlatlistsPage;

@ -0,0 +1,11 @@
import UsersTable from "@/components/Admin/Tables/UsersTable/UserTable";
const UsersPage = () => {
return (
<div className="w-full p-10">
<UsersTable/>
</div>
)
}
export default UsersPage;

@ -0,0 +1,11 @@
import VideoTable from "@/components/Admin/Tables/VideosTable/VideoTable";
const VideosPage = () => {
return (
<div className="w-full p-10">
<VideoTable/>
</div>
)
}
export default VideosPage;

@ -0,0 +1,47 @@
import { createBrowserRouter } from "react-router-dom";
import AuthRequired from "@/components/Auth/AuthRequired";
import UnauthorizeRequired from "@/components/Auth/UnauthorizeRequired";
import AdminLayout from "@/components/Layouts/AdminLayout";
import LoginPage from "@/pages/Admin/LoginPage";
import ArtistsPage from "@/pages/Admin/ArtistsPage";
import UsersPage from "@/pages/Admin/UsersPage";
import VideosPage from "@/pages/Admin/VideosPage";
import PlatlistsPage from "@/pages/Admin/PlatlistsPage";
import App from "@/App";
const router = createBrowserRouter([
{
element: <AuthRequired><AdminLayout/></AuthRequired>,
children: [
{
path: "/admin/artists",
element: <ArtistsPage/>
},
{
path: "/admin/playlists",
element: <PlatlistsPage/>
},
{
path: "/admin/videos",
element: <VideosPage/>
},
{
path: "/admin/users",
element: <UsersPage/>
},
]
},
{
path: "/login",
element: <UnauthorizeRequired><LoginPage /></UnauthorizeRequired>,
},
{
path: "/",
element: <App/>,
},
]);
export default router;

@ -0,0 +1,23 @@
import $api from '@/http';
import { AxiosResponse } from 'axios';
import { ArtistsResponse, ArtistResponse } from "@/models/response/ArtistResponse";
import { IArtistReq } from '@/models/IArtist';
export default class ArtistService {
static async Create({ name, soundcloud, facebook, spotify }: IArtistReq): Promise<AxiosResponse<ArtistResponse>> {
return $api.post<ArtistResponse>('/artist', {name, soundcloud, facebook, spotify}, {withCredentials: true})
}
static async GetAll({ page, search }: {page?: number, search?: string}): Promise<AxiosResponse<ArtistsResponse>> {
return $api.get<ArtistsResponse>('/artist', {withCredentials: true, params: {page, search}})
}
static async Edit(_id: string, { name, soundcloud, facebook, spotify }: IArtistReq): Promise<AxiosResponse<ArtistResponse>> {
return $api.put<ArtistResponse>('/artist/'+_id, {name, soundcloud, facebook, spotify}, {withCredentials: true})
}
static async Delete(_id: string): Promise<AxiosResponse<ArtistResponse>> {
return $api.delete<ArtistResponse>('/artist/'+_id, {withCredentials: true})
}
}

@ -0,0 +1,22 @@
import $api from '@/http';
import { AxiosResponse } from 'axios';
import { IUserReq } from '@/models/IUser';
import { AuthResponse } from "@/models/response/AuthResponse";
export default class AuthService {
static async registration({login, password}: IUserReq & {password: string}): Promise<AxiosResponse<AuthResponse>> {
return $api.post<AuthResponse>('/auth/registration', {login, password})
}
static async login(login: string, password: string): Promise<AxiosResponse<AuthResponse>> {
return $api.post<AuthResponse>('/auth/login', {login, password})
}
static async logout(): Promise<void> {
return $api.post('/auth/logout')
}
}

@ -0,0 +1,23 @@
import $api from '@/http';
import { AxiosResponse } from 'axios';
import { PlaylistResponse, PlaylistsResponse } from '@/models/response/PlaylistResponse';
import { IPlaylistReq } from '@/models/IPlaylist';
export default class PlaylistService {
static async Create({ name }: IPlaylistReq): Promise<AxiosResponse<PlaylistResponse>> {
return $api.post<PlaylistResponse>('/playlist', {name}, {withCredentials: true})
}
static async GetAll({ page, search }: {page?: number, search?: string}): Promise<AxiosResponse<PlaylistsResponse>> {
return $api.get<PlaylistsResponse>('/playlist', {withCredentials: true, params: {page, search}})
}
static async Edit(_id: string, { name }: IPlaylistReq): Promise<AxiosResponse<PlaylistResponse>> {
return $api.put<PlaylistResponse>('/playlist/'+_id, {name}, {withCredentials: true})
}
static async Delete(_id: string): Promise<AxiosResponse<PlaylistResponse>> {
return $api.delete<PlaylistResponse>('/playlist/'+_id, {withCredentials: true})
}
}

@ -0,0 +1,23 @@
import $api from '@/http';
import { AxiosResponse } from 'axios';
import { UserResponse, UsersResponse } from '@/models/response/UserResponse';
import { IUserReq } from '@/models/IUser';
export default class UserService {
static async Create({ login }: IUserReq): Promise<AxiosResponse<UserResponse>> {
return $api.post<UserResponse>('/auth/registration', {login}, {withCredentials: true})
}
static async GetAll({ page, search }: {page?: number, search?: string}): Promise<AxiosResponse<UsersResponse>> {
return $api.get<UsersResponse>('/user', {withCredentials: true, params: {page, search}})
}
static async Edit(_id: string, { login }: IUserReq): Promise<AxiosResponse<UserResponse>> {
return $api.put<UserResponse>('/user/'+_id, {login}, {withCredentials: true})
}
static async Delete(_id: string): Promise<AxiosResponse<UserResponse>> {
return $api.delete<UserResponse>('/user/'+_id, {withCredentials: true})
}
}

@ -0,0 +1,33 @@
import $api from '@/http';
import { AxiosResponse } from 'axios';
import { VideoResponse, VideosResponse } from '@/models/response/VideoResponse';
import { IVideoReq } from '@/models/IVideo';
export default class VideoService {
static async Create({ name, videoHQ, videoLQ, playlist, artist }: IVideoReq): Promise<AxiosResponse<VideoResponse>> {
const formData = new FormData();
formData.append("name", name);
videoHQ && formData.append("videoHQ", videoHQ[0]);
videoLQ && formData.append("videoLQ", videoLQ[0]);
formData.append("playlist", playlist ?? '');
formData.append("artist", artist);
return $api.post<VideoResponse>('/video', formData, {withCredentials: true})
}
static async GetAll({ page, search }: {page?: number, search?: string}): Promise<AxiosResponse<VideosResponse>> {
return $api.get<VideosResponse>('/video', {withCredentials: true, params: {page, search}})
}
static async GetOne(): Promise<AxiosResponse<VideoResponse>> {
return $api.get<VideoResponse>('/video/random', {withCredentials: true});
}
static async Edit(_id: string, { name, videoHQ, videoLQ, playlist, artist }: IVideoReq): Promise<AxiosResponse<VideoResponse>> {
return $api.put<VideoResponse>('/video/'+_id, {name, videoHQ, videoLQ, playlist, artist}, {withCredentials: true})
}
static async Delete(_id: string): Promise<AxiosResponse<VideoResponse>> {
return $api.delete<VideoResponse>('/video/'+_id, {withCredentials: true})
}
}

@ -0,0 +1 @@
/// <reference types="vite/client" />

@ -0,0 +1,41 @@
/** @type {import('tailwindcss').Config} */
// eslint-disable-next-line no-undef
module.exports = {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
boxShadow: {
'video': 'inset 0 -39px 68px rgba(0,0,0,0.8)',
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
// eslint-disable-next-line no-undef
plugins: [require("tailwindcss-animate")],
}

@ -0,0 +1,32 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
},
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

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

@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from "path"
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
},
preview: {
port: 3000,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})

@ -0,0 +1 @@
node_modules/

@ -0,0 +1,8 @@
{
"serverPort": 8080,
"siteBackUrl": "http://localhost:8080",
"dbUrl": "mongodb://root:123456@localhost:27017/808?authMechanism=DEFAULT&authSource=admin",
"JWTAccessSecret": "jwt-808-access-sercet-asduhvhjkdcsajhkdasjhkads",
"JWTRefreshSecret": "jwt-808-refresh-sercet-qwy7tetyuqwftugydiasguid"
}

@ -0,0 +1,65 @@
import ArtistService from '../services/ArtistService.js';
import {validationResult} from 'express-validator';
import ApiError from './ErrorController.js';
class ArtistController {
async create(req, res, next) {
try {
const errors = validationResult(req);
if(!errors.isEmpty()) {
return next(ApiError.BadRequest('Ошибка при валидации', errors.array()));
}
const { name, soundcloud, facebook, spotify } = req.body;
const artist = await ArtistService.create({name, soundcloud, facebook, spotify});
return res.json(artist);
} catch (e) {
next(e);
}
}
async getAll(req, res, next) {
try {
const {page, search} = req.query;
const artists = await ArtistService.getAll({page, search});
res.set('Access-Control-Expose-Headers', 'X-total-count');
res.set('X-total-count', artists.count);
return res.json({artists: artists.artists});
} catch (e) {
next(e);
}
}
async getOne(req, res, next) {
try {
const { id } = req.params;
const artist = await ArtistService.getOne({id});
return res.json(artist);
} catch (e) {
next(e);
}
}
async edit(req, res, next) {
try {
const { id } = req.params;
const { name, soundcloud, facebook, spotify } = req.body;
const artist = await ArtistService.edit({id, name, soundcloud, facebook, spotify});
return res.json(artist);
} catch (e) {
next(e);
}
}
async delete(req, res, next) {
try {
const { id } = req.params;
const artist = await ArtistService.delete({id});
return res.json(artist);
} catch (e) {
next(e);
}
}
}
export default new ArtistController();

@ -0,0 +1,54 @@
import AuthService from '../services/AuthService.js';
import {validationResult} from 'express-validator';
import ApiError from './ErrorController.js';
class AuthController {
async registration(req, res, next) {
try {
const errors = validationResult(req);
if(!errors.isEmpty()) {
return next(ApiError.BadRequest('Ошибка при валидации', errors.array()));
}
const {login, password} = req.body;
const user = await AuthService.registration({login, password});
return res.json(user);
} catch (e) {
next(e);
}
}
async login(req, res, next) {
try {
const {login, password} = req.body;
const user = await AuthService.login({login, password});
res.cookie('refreshToken', user.refreshToken, {maxAge: 30 * 24 * 60 * 60 * 1000, httpOnly: true });
return res.json(user);
} catch (e) {
next(e);
}
}
async logout(req, res, next) {
try {
const { refreshToken } = req.cookies;
const token = await AuthService.logout(refreshToken);
res.clearCookie('refreshToken');
return res.json(token);
} catch (e) {
next(e);
}
}
async refresh(req, res, next) {
try {
let { refreshToken } = req.cookies;
const user = await AuthService.refresh(refreshToken);
res.cookie('refreshToken', user.refreshToken, {maxAge: 30 * 24 * 60 * 60 * 1000, httpOnly: true });
return res.json(user);
} catch (e) {
next(e);
}
}
}
export default new AuthController();

@ -0,0 +1,22 @@
export default class ApiError extends Error {
status;
errors;
constructor(status, message, errors = []) {
super(message);
this.status = status;
this.errors = errors;
}
static UnauthorizedError() {
return new ApiError(401, 'Пользователь не авторизован')
}
static ForbiddenError() {
return new ApiError(403, 'Нет доступа')
}
static BadRequest(message, errors = []) {
return new ApiError(400, message, errors)
}
}

@ -0,0 +1,65 @@
import PlaylistService from '../services/PlaylistService.js';
import {validationResult} from 'express-validator';
import ApiError from './ErrorController.js';
class PlaylistController {
async create(req, res, next) {
try {
const errors = validationResult(req);
if(!errors.isEmpty()) {
return next(ApiError.BadRequest('Ошибка при валидации', errors.array()));
}
const { name } = req.body;
const playlist = await PlaylistService.create({name});
return res.json(playlist);
} catch (e) {
next(e);
}
}
async getAll(req, res, next) {
try {
const {page, search} = req.query;
const playlists = await PlaylistService.getAll({page, search});
res.set('Access-Control-Expose-Headers', 'X-total-count');
res.set('X-total-count', playlists.count);
return res.json({playlists: playlists.playlists});
} catch (e) {
next(e);
}
}
async getOne(req, res, next) {
try {
const { id } = req.params;
const playlist = await PlaylistService.getOne({id});
return res.json(playlist);
} catch (e) {
next(e);
}
}
async edit(req, res, next) {
try {
const { id } = req.params;
const { name } = req.body;
const playlist = await PlaylistService.edit({id, name});
return res.json(playlist);
} catch (e) {
next(e);
}
}
async delete(req, res, next) {
try {
const { id } = req.params;
const playlist = await PlaylistService.delete({id});
return res.json(playlist);
} catch (e) {
next(e);
}
}
}
export default new PlaylistController();

@ -0,0 +1,48 @@
import UserService from '../services/UserService.js';
class UserController {
async getAll(req, res, next) {
try {
const {page, search} = req.query;
const users = await UserService.getAll({page, search});
res.set('Access-Control-Expose-Headers', 'X-total-count');
res.set('X-total-count', users.count);
return res.json(users);
} catch (e) {
next(e);
}
}
async getOne(req, res, next) {
try {
const { id } = req.params;
const user = await UserService.getOne({id});
return res.json(user);
} catch (e) {
next(e);
}
}
async edit(req, res, next) {
try {
const { id } = req.params;
const {login, password} = req.body;
const user = await UserService.edit({id, login, password});
return res.json(user);
} catch (e) {
next(e);
}
}
async delete(req, res, next) {
try {
const { id } = req.params;
const user = await UserService.delete({id});
return res.json(user);
} catch (e) {
next(e);
}
}
}
export default new UserController();

@ -0,0 +1,66 @@
import VideoService from '../services/VideoService.js';
import {validationResult} from 'express-validator';
import ApiError from './ErrorController.js';
class VideoController {
async create(req, res, next) {
try {
const errors = validationResult(req);
if(!errors.isEmpty()) {
return next(ApiError.BadRequest('Ошибка при валидации', errors.array()));
}
console.log(req.files);
const { name, videoHQ, videoLQ, playlist, artist } = req.body;
const video = await VideoService.create({name, videoHQ, videoLQ, playlist, artist});
return res.json(video);
} catch (e) {
next(e);
}
}
async getAll(req, res, next) {
try {
const {page, search} = req.query;
const videos = await VideoService.getAll({page, search});
res.set('Access-Control-Expose-Headers', 'X-total-count');
res.set('X-total-count', videos.count);
return res.json({videos: videos.videos});
} catch (e) {
next(e);
}
}
async getOne(req, res, next) {
try {
const video = await VideoService.getOne();
return res.json(video);
} catch (e) {
next(e);
}
}
async edit(req, res, next) {
try {
const { id } = req.params;
const { name, videoHQ, videoLQ, playlist, artist } = req.body;
const video = await VideoService.edit({id, name, videoHQ, videoLQ, playlist, artist});
return res.json(video);
} catch (e) {
next(e);
}
}
async delete(req, res, next) {
try {
const { id } = req.params;
const video = await VideoService.delete({id});
return res.json(video);
} catch (e) {
next(e);
}
}
}
export default new VideoController();

@ -0,0 +1,9 @@
export default class UserDto {
_id;
login;
constructor(model) {
this._id = model._id;
this.login = model.login;
}
}

@ -0,0 +1,49 @@
import express from "express";
import mongoose from "mongoose";
import config from "config";
import cors from "cors";
import errorMiddleware from "./middlewares/errorMiddleware.js";
import fileUpload from "express-fileupload";
import cookieParser from "cookie-parser";
import authRouter from "./routers/auth.router.js"
import userRouter from "./routers/user.router.js"
import artistRouter from "./routers/artist.router.js"
import playlistRouter from "./routers/playlist.router.js"
import videoRouter from "./routers/video.router.js"
const PORT = config.get('serverPort');
const app = express();
app.use(cors({origin:['http://www.localhost:3000', 'http://localhost:3000'], credentials: true}));
app.use(express.static('public'));
app.use(express.json());
app.use(fileUpload({}));
app.use(cookieParser())
app.use('/api/auth', authRouter);
app.use('/api/user', userRouter);
app.use('/api/artist', artistRouter);
app.use('/api/playlist', playlistRouter);
app.use('/api/video', videoRouter);
app.use(errorMiddleware);
const start = async() => {
try {
mongoose.set('strictQuery', true);
await mongoose.connect(config.get('dbUrl', {
useNewUrlParser: true,
useUnifieldTopology: true
}));
app.listen(PORT, ()=> {
console.log(`Сервер успешно запущен на порту: ${PORT}`)
})
} catch (e) {
console.log(e)
}
}
start();

@ -0,0 +1,29 @@
import ApiError from '../controllers/ErrorController.js';
import TokenService from '../services/TokenService.js';
export default function (req, res, next) {
if(req.method === 'OPTIONS') {
next();
}
try {
const authHeader = req.headers.authorization;
if(!authHeader) {
return next(ApiError.UnauthorizedError());
}
const accessToken = authHeader.split(' ')[1];
if(!accessToken) {
return next(ApiError.UnauthorizedError());
}
const decodedData = TokenService.validateAccessToken(accessToken);
if(!decodedData) {
return next(ApiError.UnauthorizedError());
}
req.user = decodedData;
next();
} catch (e) {
return next(ApiError.UnauthorizedError());
}
}

@ -0,0 +1,9 @@
import ApiError from "../controllers/ErrorController.js";
export default function(err, req, res, next) {
if(err instanceof ApiError) {
return res.status(err.status).json({error: err.message, errors: err.errors});
}
console.log(err);
return res.status(500).json({error: 'Произошла неизвестная ошибка, попробуйте позже'});
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save