Spaces:
Running
Создай полноценный frontend для проекта **LobsterTube** — полной копии YouTube, с современным дизайном, максимально приближённым к оригиналу YouTube (2025 года).
Browse files**Требования к стеку:**
* React + TypeScript
* Tailwind CSS
* React Router DOM для
маршрутизации
* Redux Toolkit или Zustand для управления состоянием
* Axios для запросов к backend API
* Redux Toolkit или Zustand для управления состоянием
* Axios для запросов к backend API
* Поддержка тёмной и светлой темы
* UI-библиотека: shadcn/ui или
Material UI
**Главные страницы:**
1. / - главная страница с рекомендованными видео (сеткой превью, как на YouTube)
2. `/watch/:id — просмотр видео (видео-плеер, описание, комментарии, справа) рекомендации
3. '/channel/:id` - страница канала (баннер, аватар, видео, вкладки "Видео", "Плейлисты", "О канале")
4. /search` - страница поиска видео и каналов
5. `/upload` - страница загрузки видео (форма с названием, описанием, тегами, превью)
6. `/login` и `/register` - формы
авторизации и регистрации пользователей
**Компоненты:**
* Header (поиск, логотип VibeTube, иконки уведомлений и профиля)
* Sidebar (главная, подписки, тренды, история и т.д.)
* VideoCard (превью видео)
* Player (встроенный видеоплеер с контролами)
* ChannelCard, PlaylistCard
* Адаптивный дизайн для desktop, tablet, mobile
* Скелетоны для загрузки (skeleton UI)
* Drag-and-drop при загрузке видео
* Infinite scroll (ленивая подгрузка видео)
* Поддержка JWT (через localStorage)
* АРІ-запросы к backend по REST или GraphQL
**Цель:** создать максимально реалистичный frontend аналог YouTube с собственным брендингом
**LobsterTube**
- README.md +8 -5
- index.html +14 -18
- package.json +37 -0
- postcss.config.js +6 -0
- public/favicon.svg +9 -0
- src/App.tsx +37 -0
- src/components/Header.tsx +106 -0
- src/components/Layout.tsx +25 -0
- src/components/Sidebar.tsx +62 -0
- src/components/VideoCard.tsx +34 -0
- src/components/VideoGrid.tsx +34 -0
- src/components/VideoPlayer.tsx +95 -0
- src/components/ui/button.tsx +43 -0
- src/components/ui/card.tsx +17 -0
- src/components/ui/dropdown-menu.tsx +41 -0
- src/components/ui/input.tsx +21 -0
- src/components/ui/skeleton.tsx +8 -0
- src/components/ui/textarea.tsx +21 -0
- src/hooks/useInfiniteScroll.ts +27 -0
- src/index.css +20 -0
- src/lib/api.ts +17 -0
- src/lib/types.ts +41 -0
- src/lib/utils.ts +25 -0
- src/main.tsx +22 -0
- src/pages/Home.tsx +40 -0
- src/pages/Watch.tsx +35 -0
- src/providers/AuthProvider.tsx +19 -0
- src/providers/ThemeProvider.tsx +48 -0
- src/store/auth.ts +33 -0
- src/store/ui.ts +16 -0
- tailwind.config.js +24 -0
- tsconfig.app.json +7 -0
- tsconfig.json +23 -0
- vite.config.ts +15 -0
|
@@ -1,10 +1,13 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: LobsterTube
|
| 3 |
+
colorFrom: red
|
| 4 |
+
colorTo: red
|
| 5 |
+
emoji: 🐳
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
+
tags:
|
| 9 |
+
- deepsite-v3
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Welcome to your new DeepSite project!
|
| 13 |
+
This project was created with [DeepSite](https://huggingface.co/deepsite).
|
|
@@ -1,19 +1,15 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
</p>
|
| 17 |
-
</div>
|
| 18 |
-
</body>
|
| 19 |
-
</html>
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="ru" class="dark">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>LobsterTube</title>
|
| 7 |
+
<meta name="theme-color" content="#0f0f0f" />
|
| 8 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 9 |
+
</head>
|
| 10 |
+
<body class="bg-white text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 antialiased">
|
| 11 |
+
<div id="root"></div>
|
| 12 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 13 |
+
<script src="https://huggingface.co/deepsite/deepsite-badge.js"></script>
|
| 14 |
+
</body>
|
| 15 |
+
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
json
|
| 2 |
+
{
|
| 3 |
+
"name": "lobstertube",
|
| 4 |
+
"private": true,
|
| 5 |
+
"version": "0.1.0",
|
| 6 |
+
"type": "module",
|
| 7 |
+
"scripts": {
|
| 8 |
+
"dev": "vite",
|
| 9 |
+
"build": "tsc -b && vite build",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@radix-ui/react-dialog": "^1.0.5",
|
| 14 |
+
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
| 15 |
+
"@radix-ui/react-switch": "^1.0.3",
|
| 16 |
+
"axios": "^1.7.2",
|
| 17 |
+
"clsx": "^2.1.1",
|
| 18 |
+
"lucide-react": "^0.446.0",
|
| 19 |
+
"react": "^18.3.1",
|
| 20 |
+
"react-dom": "^18.3.1",
|
| 21 |
+
"react-router-dom": "^6.26.1",
|
| 22 |
+
"zustand": "^4.5.2"
|
| 23 |
+
},
|
| 24 |
+
"devDependencies": {
|
| 25 |
+
"@types/node": "^20.14.9",
|
| 26 |
+
"@types/react": "^18.3.3",
|
| 27 |
+
"@types/react-dom": "^18.3.0",
|
| 28 |
+
"@vitejs/plugin-react": "^4.3.1",
|
| 29 |
+
"autoprefixer": "^10.4.19",
|
| 30 |
+
"postcss": "^8.4.39",
|
| 31 |
+
"tailwindcss": "^3.4.10",
|
| 32 |
+
"typescript": "^5.5.4",
|
| 33 |
+
"vite": "^5.4.1"
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
</html>
|
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {}
|
| 5 |
+
}
|
| 6 |
+
}
|
|
|
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tsx
|
| 2 |
+
import { Route, Routes, Navigate, useLocation } from 'react-router-dom'
|
| 3 |
+
import Layout from './components/Layout'
|
| 4 |
+
import Home from './pages/Home'
|
| 5 |
+
import Watch from './pages/Watch'
|
| 6 |
+
import Channel from './pages/Channel'
|
| 7 |
+
import Search from './pages/Search'
|
| 8 |
+
import Upload from './pages/Upload'
|
| 9 |
+
import Login from './pages/Login'
|
| 10 |
+
import Register from './pages/Register'
|
| 11 |
+
import { useAuthStore } from './store/auth'
|
| 12 |
+
|
| 13 |
+
function Protected({ children }: { children: JSX.Element }) {
|
| 14 |
+
const token = useAuthStore(s => s.token)
|
| 15 |
+
const loc = useLocation()
|
| 16 |
+
if (!token) return <Navigate to="/login" state={{ from: loc }} replace />
|
| 17 |
+
return children
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export default function App() {
|
| 21 |
+
return (
|
| 22 |
+
<Routes>
|
| 23 |
+
<Route element={<Layout />}>
|
| 24 |
+
<Route index element={<Home />} />
|
| 25 |
+
<Route path="/watch/:id" element={<Watch />} />
|
| 26 |
+
<Route path="/channel/:id" element={<Channel />} />
|
| 27 |
+
<Route path="/search" element={<Search />} />
|
| 28 |
+
<Route path="/upload" element={<Protected><Upload /></Protected>} />
|
| 29 |
+
<Route path="*" element={<Navigate to="/" replace />} />
|
| 30 |
+
</Route>
|
| 31 |
+
<Route path="/login" element={<Login />} />
|
| 32 |
+
<Route path="/register" element={<Register />} />
|
| 33 |
+
</Routes>
|
| 34 |
+
)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
</html>
|
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tsx
|
| 2 |
+
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
| 3 |
+
import { Menu, Search, Video, Bell, User, LogOut, Settings, Moon, Sun, Monitor } from 'lucide-react'
|
| 4 |
+
import { Button } from './ui/button'
|
| 5 |
+
import { Input } from './ui/input'
|
| 6 |
+
import { useUIStore } from '@/store/ui'
|
| 7 |
+
import { useAuthStore } from '@/store/auth'
|
| 8 |
+
import { useTheme } from '@/providers/ThemeProvider'
|
| 9 |
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu'
|
| 10 |
+
import { useMemo } from 'react'
|
| 11 |
+
|
| 12 |
+
export default function Header() {
|
| 13 |
+
const navigate = useNavigate()
|
| 14 |
+
const [params, setParams] = useSearchParams()
|
| 15 |
+
const q = useMemo(() => params.get('q') || '', [params])
|
| 16 |
+
const { toggleSidebar } = useUIStore()
|
| 17 |
+
const { user, logout } = useAuthStore()
|
| 18 |
+
const { theme, setTheme } = useTheme()
|
| 19 |
+
|
| 20 |
+
function onSearch(e: React.FormEvent<HTMLFormElement>) {
|
| 21 |
+
e.preventDefault()
|
| 22 |
+
const form = e.currentTarget
|
| 23 |
+
const data = new FormData(form)
|
| 24 |
+
const query = data.get('q') as string
|
| 25 |
+
navigate(`/search?q=${encodeURIComponent(query)}`)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
return (
|
| 29 |
+
<header className="sticky top-0 z-50 border-b border-zinc-200/80 bg-white/80 backdrop-blur dark:border-zinc-800/80 dark:bg-zinc-950/80">
|
| 30 |
+
<div className="mx-auto flex h-14 max-w-[1920px] items-center gap-2 px-3 sm:px-5">
|
| 31 |
+
<div className="flex items-center gap-2">
|
| 32 |
+
<Button variant="ghost" size="icon" onClick={toggleSidebar} aria-label="Toggle menu">
|
| 33 |
+
<Menu className="h-5 w-5" />
|
| 34 |
+
</Button>
|
| 35 |
+
<Link to="/" className="flex items-center gap-2">
|
| 36 |
+
<div className="h-7 w-7 rounded-md bg-red-600 text-white flex items-center justify-center font-black">LT</div>
|
| 37 |
+
<span className="text-lg font-semibold tracking-tight">LobsterTube</span>
|
| 38 |
+
</Link>
|
| 39 |
+
</div>
|
| 40 |
+
<form onSubmit={onSearch} className="flex-1 flex justify-center px-2">
|
| 41 |
+
<div className="flex w-full max-w-2xl items-stretch">
|
| 42 |
+
<Input name="q" defaultValue={q} placeholder="Поиск" className="rounded-r-none border-r-0" />
|
| 43 |
+
<Button type="submit" className="rounded-l-none border border-l-0 border-zinc-200 dark:border-zinc-800">
|
| 44 |
+
<Search className="h-5 w-5" />
|
| 45 |
+
</Button>
|
| 46 |
+
</div>
|
| 47 |
+
</form>
|
| 48 |
+
<div className="flex items-center gap-1">
|
| 49 |
+
<Button variant="ghost" size="icon" onClick={() => navigate('/upload')} title="Создать">
|
| 50 |
+
<Video className="h-5 w-5" />
|
| 51 |
+
</Button>
|
| 52 |
+
<Button variant="ghost" size="icon">
|
| 53 |
+
<Bell className="h-5 w-5" />
|
| 54 |
+
</Button>
|
| 55 |
+
<DropdownMenu>
|
| 56 |
+
<DropdownMenuTrigger asChild>
|
| 57 |
+
<Button variant="ghost" size="icon" className="overflow-hidden">
|
| 58 |
+
{user ? (
|
| 59 |
+
<img src={user.avatarUrl || `https://api.dicebear.com/9.x/identicon/svg?seed=${user.id}`} className="h-7 w-7 rounded-full" alt="avatar" />
|
| 60 |
+
) : (
|
| 61 |
+
<User className="h-5 w-5" />
|
| 62 |
+
)}
|
| 63 |
+
</Button>
|
| 64 |
+
</DropdownMenuTrigger>
|
| 65 |
+
<DropdownMenuContent align="end" className="w-56">
|
| 66 |
+
{user ? (
|
| 67 |
+
<>
|
| 68 |
+
<div className="px-2 py-2 text-sm text-zinc-500 dark:text-zinc-400">Вошли как {user.name}</div>
|
| 69 |
+
<DropdownMenuItem onClick={() => navigate(`/channel/${user.id}`)}>
|
| 70 |
+
<User className="mr-2 h-4 w-4" /> Мой канал
|
| 71 |
+
</DropdownMenuItem>
|
| 72 |
+
<DropdownMenuItem onClick={() => setTheme('system')}>
|
| 73 |
+
<Monitor className="mr-2 h-4 w-4" /> Системная
|
| 74 |
+
</DropdownMenuItem>
|
| 75 |
+
<DropdownMenuItem onClick={() => setTheme('light')}>
|
| 76 |
+
<Sun className="mr-2 h-4 w-4" /> Светлая
|
| 77 |
+
</DropdownMenuItem>
|
| 78 |
+
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
| 79 |
+
<Moon className="mr-2 h-4 w-4" /> Тёмная
|
| 80 |
+
</DropdownMenuItem>
|
| 81 |
+
<DropdownMenuItem>
|
| 82 |
+
<Settings className="mr-2 h-4 w-4" /> Настройки
|
| 83 |
+
</DropdownMenuItem>
|
| 84 |
+
<DropdownMenuItem onClick={() => logout()}>
|
| 85 |
+
<LogOut className="mr-2 h-4 w-4" /> Выйти
|
| 86 |
+
</DropdownMenuItem>
|
| 87 |
+
</>
|
| 88 |
+
) : (
|
| 89 |
+
<>
|
| 90 |
+
<DropdownMenuItem onClick={() => navigate('/login')}>
|
| 91 |
+
<User className="mr-2 h-4 w-4" /> Войти
|
| 92 |
+
</DropdownMenuItem>
|
| 93 |
+
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
| 94 |
+
<Moon className="mr-2 h-4 w-4" /> Тёмная
|
| 95 |
+
</DropdownMenuItem>
|
| 96 |
+
</>
|
| 97 |
+
)}
|
| 98 |
+
</DropdownMenuContent>
|
| 99 |
+
</DropdownMenu>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
</header>
|
| 103 |
+
)
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
</html>
|
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tsx
|
| 2 |
+
import { Outlet } from 'react-router-dom'
|
| 3 |
+
import Header from './Header'
|
| 4 |
+
import Sidebar from './Sidebar'
|
| 5 |
+
import { useUIStore } from '@/store/ui'
|
| 6 |
+
import { cn } from '@/lib/utils'
|
| 7 |
+
|
| 8 |
+
export default function Layout() {
|
| 9 |
+
const { sidebarOpen } = useUIStore()
|
| 10 |
+
return (
|
| 11 |
+
<div className="flex h-screen w-full flex-col">
|
| 12 |
+
<Header />
|
| 13 |
+
<div className="flex min-h-0 flex-1">
|
| 14 |
+
<Sidebar />
|
| 15 |
+
<main className={cn('flex-1 overflow-auto p-4 sm:p-6', sidebarOpen ? 'md:ml-64' : 'md:ml-20')}>
|
| 16 |
+
<div className="mx-auto max-w-[1600px]">
|
| 17 |
+
<Outlet />
|
| 18 |
+
</div>
|
| 19 |
+
</main>
|
| 20 |
+
</div>
|
| 21 |
+
</div>
|
| 22 |
+
)
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
</html>
|
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tsx
|
| 2 |
+
import { NavLink, useNavigate } from 'react-router-dom'
|
| 3 |
+
import { Home, Compass, History, PlaySquare, Clock, ThumbsUp, PlusCircle, Settings, HelpCircle } from 'lucide-react'
|
| 4 |
+
import { cn } from '@/lib/utils'
|
| 5 |
+
import { useUIStore } from '@/store/ui'
|
| 6 |
+
import { useAuthStore } from '@/store/auth'
|
| 7 |
+
|
| 8 |
+
const item = 'flex items-center gap-6 rounded-md px-3 py-2 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800'
|
| 9 |
+
const icon = 'h-5 w-5'
|
| 10 |
+
|
| 11 |
+
export default function Sidebar() {
|
| 12 |
+
const { sidebarOpen } = useUIStore()
|
| 13 |
+
const { user } = useAuthStore()
|
| 14 |
+
const navigate = useNavigate()
|
| 15 |
+
return (
|
| 16 |
+
<aside className={cn('hidden border-r border-zinc-200 dark:border-zinc-800 md:block', sidebarOpen ? 'w-64' : 'w-20')}>
|
| 17 |
+
<div className="flex h-[calc(100vh-56px)] flex-col gap-2 overflow-y-auto p-2 scrollbar-thin">
|
| 18 |
+
<NavLink to="/" className={({ isActive }) => cn(item, isActive && 'bg-zinc-100 dark:bg-zinc-800')}>
|
| 19 |
+
<Home className={icon} /> <span>Главная</span>
|
| 20 |
+
</NavLink>
|
| 21 |
+
<NavLink to="/trending" className={({ isActive }) => cn(item, isActive && 'bg-zinc-100 dark:bg-zinc-800')}>
|
| 22 |
+
<Compass className={icon} /> <span>Тренды</span>
|
| 23 |
+
</NavLink>
|
| 24 |
+
{user ? (
|
| 25 |
+
<>
|
| 26 |
+
<NavLink to="/history" className={({ isActive }) => cn(item, isActive && 'bg-zinc-100 dark:bg-zinc-800')}>
|
| 27 |
+
<History className={icon} /> <span>История</span>
|
| 28 |
+
</NavLink>
|
| 29 |
+
<NavLink to="/playlists" className={({ isActive }) => cn(item, isActive && 'bg-zinc-100 dark:bg-zinc-800')}>
|
| 30 |
+
<PlaySquare className={icon} /> <span>Плейлисты</span>
|
| 31 |
+
</NavLink>
|
| 32 |
+
<NavLink to="/liked" className={({ isActive }) => cn(item, isActive && 'bg-zinc-100 dark:bg-zinc-800')}>
|
| 33 |
+
<ThumbsUp className={icon} /> <span>Понравившиеся</span>
|
| 34 |
+
</NavLink>
|
| 35 |
+
<NavLink to="/watch-later" className={({ isActive }) => cn(item, isActive && 'bg-zinc-100 dark:bg-zinc-800')}>
|
| 36 |
+
<Clock className={icon} /> <span>Смотреть позже</span>
|
| 37 |
+
</NavLink>
|
| 38 |
+
</>
|
| 39 |
+
) : null}
|
| 40 |
+
<div className="my-2 border-t border-zinc-200 dark:border-zinc-800" />
|
| 41 |
+
{user ? (
|
| 42 |
+
<button onClick={() => navigate('/upload')} className={cn(item, 'text-left')}>
|
| 43 |
+
<PlusCircle className={icon} /> <span>Создать</span>
|
| 44 |
+
</button>
|
| 45 |
+
) : (
|
| 46 |
+
<button onClick={() => navigate('/login')} className={cn(item, 'text-left')}>
|
| 47 |
+
<PlusCircle className={icon} /> <span>Войти</span>
|
| 48 |
+
</button>
|
| 49 |
+
)}
|
| 50 |
+
<div className="mt-auto" />
|
| 51 |
+
<NavLink to="/settings" className={({ isActive }) => cn(item, isActive && 'bg-zinc-100 dark:bg-zinc-800')}>
|
| 52 |
+
<Settings className={icon} /> <span>Настройки</span>
|
| 53 |
+
</NavLink>
|
| 54 |
+
<NavLink to="/help" className={({ isActive }) => cn(item, isActive && 'bg-zinc-100 dark:bg-zinc-800')}>
|
| 55 |
+
<HelpCircle className={icon} /> <span>Справка</span>
|
| 56 |
+
</NavLink>
|
| 57 |
+
</div>
|
| 58 |
+
</aside>
|
| 59 |
+
)
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
</html>
|
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tsx
|
| 2 |
+
import { Link } from 'react-router-dom'
|
| 3 |
+
import { Video } from '@/lib/types'
|
| 4 |
+
import { formatViews, timeAgo } from '@/lib/utils'
|
| 5 |
+
import { Verified } from 'lucide-react'
|
| 6 |
+
import { cn } from '@/lib/utils'
|
| 7 |
+
|
| 8 |
+
export default function VideoCard({ video, className }: { video: Video; className?: string }) {
|
| 9 |
+
return (
|
| 10 |
+
<div className={cn('group flex w-full flex-col gap-2', className)}>
|
| 11 |
+
<Link to={`/watch/${video.id}`} className="relative aspect-video overflow-hidden rounded-xl">
|
| 12 |
+
<img src={video.thumbnailUrl} alt={video.title} className="h-full w-full object-cover transition-transform group-hover:scale-[1.02]" />
|
| 13 |
+
<span className="absolute bottom-1 right-1 rounded bg-black/80 px-1 py-0.5 text-xs text-white">{video.duration}</span>
|
| 14 |
+
</Link>
|
| 15 |
+
<div className="flex gap-3">
|
| 16 |
+
<Link to={`/channel/${video.channel.id}`} className="shrink-0">
|
| 17 |
+
<img src={video.channel.avatarUrl || `https://api.dicebear.com/9.x/identicon/svg?seed=${video.channel.id}`} className="h-9 w-9 rounded-full" alt="avatar" />
|
| 18 |
+
</Link>
|
| 19 |
+
<div className="min-w-0">
|
| 20 |
+
<Link to={`/watch/${video.id}`} className="line-clamp-2 font-medium group-hover:underline">{video.title}</Link>
|
| 21 |
+
<div className="mt-1 flex items-center gap-1 text-sm text-zinc-500">
|
| 22 |
+
<Link to={`/channel/${video.channel.id}`} className="hover:underline">{video.channel.name}</Link>
|
| 23 |
+
{video.channel.verified ? <Verified className="h-4 w-4 text-zinc-500" /> : null}
|
| 24 |
+
</div>
|
| 25 |
+
<div className="text-sm text-zinc-500">
|
| 26 |
+
{formatViews(video.views)} просмотров • {timeAgo(video.publishedAt)}
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
)
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
</html>
|
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tsx
|
| 2 |
+
import { Video } from '@/lib/types'
|
| 3 |
+
import VideoCard from './VideoCard'
|
| 4 |
+
import { Skeleton } from './ui/skeleton'
|
| 5 |
+
import { cn } from '@/lib/utils'
|
| 6 |
+
|
| 7 |
+
export default function VideoGrid({ videos, className }: { videos: Video[]; className?: string }) {
|
| 8 |
+
return (
|
| 9 |
+
<div className={cn('grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5', className)}>
|
| 10 |
+
{videos.map(v => <VideoCard key={v.id} video={v} />)}
|
| 11 |
+
</div>
|
| 12 |
+
)
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function VideoGridSkeleton() {
|
| 16 |
+
return (
|
| 17 |
+
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
| 18 |
+
{Array.from({ length: 15 }).map((_, i) => (
|
| 19 |
+
<div key={i} className="flex flex-col gap-2">
|
| 20 |
+
<Skeleton className="aspect-video w-full rounded-xl" />
|
| 21 |
+
<div className="flex gap-3">
|
| 22 |
+
<Skeleton className="h-9 w-9 rounded-full" />
|
| 23 |
+
<div className="flex-1 space-y-2">
|
| 24 |
+
<Skeleton className="h-4 w-full" />
|
| 25 |
+
<Skeleton className="h-3 w-2/3" />
|
| 26 |
+
</div>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
))}
|
| 30 |
+
</div>
|
| 31 |
+
)
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
</html>
|
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tsx
|
| 2 |
+
import { useEffect, useRef, useState } from 'react'
|
| 3 |
+
import { Play, Pause, Volume2, VolumeX, Maximize, Settings } from 'lucide-react'
|
| 4 |
+
import { Button } from './ui/button'
|
| 5 |
+
|
| 6 |
+
type Props = {
|
| 7 |
+
src: string
|
| 8 |
+
poster?: string
|
| 9 |
+
title?: string
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export default function VideoPlayer({ src, poster, title }: Props) {
|
| 13 |
+
const videoRef = useRef<HTMLVideoElement>(null)
|
| 14 |
+
const [playing, setPlaying] = useState(false)
|
| 15 |
+
const [muted, setMuted] = useState(false)
|
| 16 |
+
const [progress, setProgress] = useState(0)
|
| 17 |
+
|
| 18 |
+
useEffect(() => {
|
| 19 |
+
const v = videoRef.current
|
| 20 |
+
if (!v) return
|
| 21 |
+
const onTime = () => setProgress((v.currentTime / v.duration) * 100 || 0)
|
| 22 |
+
v.addEventListener('timeupdate', onTime)
|
| 23 |
+
return () => v.removeEventListener('timeupdate', onTime)
|
| 24 |
+
}, [])
|
| 25 |
+
|
| 26 |
+
function togglePlay() {
|
| 27 |
+
const v = videoRef.current
|
| 28 |
+
if (!v) return
|
| 29 |
+
if (v.paused) {
|
| 30 |
+
v.play()
|
| 31 |
+
setPlaying(true)
|
| 32 |
+
} else {
|
| 33 |
+
v.pause()
|
| 34 |
+
setPlaying(false)
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function toggleMute() {
|
| 39 |
+
const v = videoRef.current
|
| 40 |
+
if (!v) return
|
| 41 |
+
v.muted = !v.muted
|
| 42 |
+
setMuted(v.muted)
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function seek(e: React.ChangeEvent<HTMLInputElement>) {
|
| 46 |
+
const v = videoRef.current
|
| 47 |
+
if (!v) return
|
| 48 |
+
const pct = Number(e.target.value)
|
| 49 |
+
v.currentTime = (pct / 100) * v.duration
|
| 50 |
+
setProgress(pct)
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function fullscreen() {
|
| 54 |
+
const v = videoRef.current
|
| 55 |
+
if (!v) return
|
| 56 |
+
if (document.fullscreenElement) document.exitFullscreen()
|
| 57 |
+
else v.requestFullscreen()
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
return (
|
| 61 |
+
<div className="relative w-full overflow-hidden rounded-xl bg-black">
|
| 62 |
+
<video ref={videoRef} className="h-auto w-full" src={src} poster={poster} controls={false} playsInline onClick={togglePlay} />
|
| 63 |
+
<div className="absolute inset-0 flex flex-col justify-end">
|
| 64 |
+
<div className="bg-gradient-to-t from-black/60 to-transparent p-3">
|
| 65 |
+
<input
|
| 66 |
+
type="range"
|
| 67 |
+
min={0}
|
| 68 |
+
max={100}
|
| 69 |
+
value={progress}
|
| 70 |
+
onChange={seek}
|
| 71 |
+
className="mb-2 h-1 w-full appearance-none rounded-full bg-white/40 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
|
| 72 |
+
/>
|
| 73 |
+
<div className="flex items-center gap-2">
|
| 74 |
+
<Button size="icon" variant="ghost" onClick={togglePlay} className="text-white">
|
| 75 |
+
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
| 76 |
+
</Button>
|
| 77 |
+
<Button size="icon" variant="ghost" onClick={toggleMute} className="text-white">
|
| 78 |
+
{muted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
| 79 |
+
</Button>
|
| 80 |
+
<div className="ml-auto flex items-center gap-1">
|
| 81 |
+
<Button size="icon" variant="ghost" className="text-white">
|
| 82 |
+
<Settings className="h-5 w-5" />
|
| 83 |
+
</Button>
|
| 84 |
+
<Button size="icon" variant="ghost" onClick={fullscreen} className="text-white">
|
| 85 |
+
<Maximize className="h-5 w-5" />
|
| 86 |
+
</Button>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
)
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
</html>
|
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tsx
|
| 2 |
+
import React from 'react'
|
| 3 |
+
import { cn } from '@/lib/utils'
|
| 4 |
+
|
| 5 |
+
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
| 6 |
+
variant?: 'default' | 'ghost' | 'outline' | 'link' | 'destructive'
|
| 7 |
+
size?: 'sm' | 'md' | 'lg' | 'icon'
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
const variants = {
|
| 11 |
+
default: 'bg-brand-600 hover:bg-brand-500 text-white',
|
| 12 |
+
ghost: 'bg-transparent hover:bg-zinc-100/50 dark:hover:bg-zinc-800',
|
| 13 |
+
outline: 'border border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100/50 dark:hover:bg-zinc-800',
|
| 14 |
+
link: 'text-brand-500 underline-offset-4 hover:underline',
|
| 15 |
+
destructive: 'bg-red-600 hover:bg-red-500 text-white'
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const sizes = {
|
| 19 |
+
sm: 'h-8 px-3 text-sm',
|
| 20 |
+
md: 'h-10 px-4',
|
| 21 |
+
lg: 'h-12 px-6 text-lg',
|
| 22 |
+
icon: 'h-10 w-10'
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
| 26 |
+
({ className, variant = 'default', size = 'md', ...props }, ref) => {
|
| 27 |
+
return (
|
| 28 |
+
<button
|
| 29 |
+
ref={ref}
|
| 30 |
+
className={cn(
|
| 31 |
+
'inline-flex items-center justify-center rounded-md transition-colors disabled:opacity-50 disabled:pointer-events-none',
|
| 32 |
+
variants[variant],
|
| 33 |
+
sizes[size],
|
| 34 |
+
className
|
| 35 |
+
)}
|
| 36 |
+
{...props}
|
| 37 |
+
/>
|
| 38 |
+
)
|
| 39 |
+
}
|
| 40 |
+
)
|
| 41 |
+
Button.displayName = 'Button'
|
| 42 |
+
|
| 43 |
+
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tsx
|
| 2 |
+
import React from 'react'
|
| 3 |
+
import { cn } from '@/lib/utils'
|
| 4 |
+
|
| 5 |
+
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
| 6 |
+
return <div className={cn('rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900', className)} {...props} />
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
| 10 |
+
return <div className={cn('p-4', className)} {...props} />
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
| 14 |
+
return <div className={cn('p-4 pt-0', className)} {...props} />
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
</html>
|
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tsx
|
| 2 |
+
import * as React from 'react'
|
| 3 |
+
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
| 4 |
+
import { cn } from '@/lib/utils'
|
| 5 |
+
|
| 6 |
+
export const DropdownMenu = DropdownMenuPrimitive.Root
|
| 7 |
+
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
| 8 |
+
export const DropdownMenuContent = React.forwardRef<
|
| 9 |
+
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
| 10 |
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
| 11 |
+
>(({ className, sideOffset = 8, ...props }, ref) => (
|
| 12 |
+
<DropdownMenuPrimitive.Portal>
|
| 13 |
+
<DropdownMenuPrimitive.Content
|
| 14 |
+
ref={ref}
|
| 15 |
+
sideOffset={sideOffset}
|
| 16 |
+
className={cn(
|
| 17 |
+
'z-50 min-w-[12rem] overflow-hidden rounded-md border border-zinc-200 bg-white p-1 text-zinc-950 shadow-md dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-50',
|
| 18 |
+
className
|
| 19 |
+
)}
|
| 20 |
+
{...props}
|
| 21 |
+
/>
|
| 22 |
+
</DropdownMenuPrimitive.Portal>
|
| 23 |
+
))
|
| 24 |
+
DropdownMenuContent.displayName = 'DropdownMenuContent'
|
| 25 |
+
|
| 26 |
+
export const DropdownMenuItem = React.forwardRef<
|
| 27 |
+
HTMLDivElement,
|
| 28 |
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
|
| 29 |
+
>(({ className, ...props }, ref) => (
|
| 30 |
+
<DropdownMenuPrimitive.Item
|
| 31 |
+
ref={ref}
|
| 32 |
+
className={cn(
|
| 33 |
+
'relative flex cursor-default select-none items-center rounded-sm px-2 py-2 text-sm outline-none hover:bg-zinc-100 dark:hover:bg-zinc-800',
|
| 34 |
+
className
|
| 35 |
+
)}
|
| 36 |
+
{...props}
|
| 37 |
+
/>
|
| 38 |
+
))
|
| 39 |
+
DropdownMenuItem.displayName = 'DropdownMenuItem'
|
| 40 |
+
|
| 41 |
+
</html>
|
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tsx
|
| 2 |
+
import React from 'react'
|
| 3 |
+
import { cn } from '@/lib/utils'
|
| 4 |
+
|
| 5 |
+
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
| 6 |
+
|
| 7 |
+
export const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => {
|
| 8 |
+
return (
|
| 9 |
+
<input
|
| 10 |
+
ref={ref}
|
| 11 |
+
className={cn(
|
| 12 |
+
'flex h-10 w-full rounded-md border border-zinc-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-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:bg-zinc-900 dark:ring-offset-zinc-950 dark:placeholder:text-zinc-600',
|
| 13 |
+
className
|
| 14 |
+
)}
|
| 15 |
+
{...props}
|
| 16 |
+
/>
|
| 17 |
+
)
|
| 18 |
+
})
|
| 19 |
+
Input.displayName = 'Input'
|
| 20 |
+
|
| 21 |
+
</html>
|
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tsx
|
| 2 |
+
import { cn } from '@/lib/utils'
|
| 3 |
+
|
| 4 |
+
export function Skeleton({ className }: { className?: string }) {
|
| 5 |
+
return <div className={cn('animate-pulse rounded-md bg-zinc-200 dark:bg-zinc-800', className)} />
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
</html>
|
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tsx
|
| 2 |
+
import React from 'react'
|
| 3 |
+
import { cn } from '@/lib/utils'
|
| 4 |
+
|
| 5 |
+
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
| 6 |
+
|
| 7 |
+
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
|
| 8 |
+
return (
|
| 9 |
+
<textarea
|
| 10 |
+
ref={ref}
|
| 11 |
+
className={cn(
|
| 12 |
+
'flex w-full rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:bg-zinc-900 dark:ring-offset-zinc-950 dark:placeholder:text-zinc-600',
|
| 13 |
+
className
|
| 14 |
+
)}
|
| 15 |
+
{...props}
|
| 16 |
+
/>
|
| 17 |
+
)
|
| 18 |
+
})
|
| 19 |
+
Textarea.displayName = 'Textarea'
|
| 20 |
+
|
| 21 |
+
</html>
|
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ts
|
| 2 |
+
import { useEffect, useRef, useState } from 'react'
|
| 3 |
+
|
| 4 |
+
export function useInfiniteScroll(callback: () => void, options?: IntersectionObserverInit) {
|
| 5 |
+
const [isFetching, setIsFetching] = useState(false)
|
| 6 |
+
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
| 7 |
+
|
| 8 |
+
useEffect(() => {
|
| 9 |
+
if (!sentinelRef.current) return
|
| 10 |
+
const observer = new IntersectionObserver(
|
| 11 |
+
entries => {
|
| 12 |
+
if (entries[0].isIntersecting) setIsFetching(true)
|
| 13 |
+
},
|
| 14 |
+
options
|
| 15 |
+
)
|
| 16 |
+
observer.observe(sentinelRef.current)
|
| 17 |
+
return () => observer.disconnect()
|
| 18 |
+
}, [options])
|
| 19 |
+
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
if (!isFetching) return
|
| 22 |
+
callback()
|
| 23 |
+
}, [isFetching, callback])
|
| 24 |
+
|
| 25 |
+
return { sentinelRef, isFetching, setIsFetching }
|
| 26 |
+
}
|
| 27 |
+
</html>
|
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
color-scheme: light dark;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
* {
|
| 10 |
+
-webkit-tap-highlight-color: transparent;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
.scrollbar-thin::-webkit-scrollbar {
|
| 14 |
+
height: 8px;
|
| 15 |
+
width: 8px;
|
| 16 |
+
}
|
| 17 |
+
.scrollbar-thin::-webkit-scrollbar-thumb {
|
| 18 |
+
background: rgba(100,100,100,0.4);
|
| 19 |
+
border-radius: 8px;
|
| 20 |
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ts
|
| 2 |
+
import axios from 'axios'
|
| 3 |
+
|
| 4 |
+
const api = axios.create({
|
| 5 |
+
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:4000',
|
| 6 |
+
withCredentials: false
|
| 7 |
+
})
|
| 8 |
+
|
| 9 |
+
api.interceptors.request.use(config => {
|
| 10 |
+
const token = localStorage.getItem('lt-token')
|
| 11 |
+
if (token) config.headers.Authorization = `Bearer ${token}`
|
| 12 |
+
return config
|
| 13 |
+
})
|
| 14 |
+
|
| 15 |
+
export default api
|
| 16 |
+
|
| 17 |
+
</html>
|
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ts
|
| 2 |
+
export type Video = {
|
| 3 |
+
id: string
|
| 4 |
+
title: string
|
| 5 |
+
thumbnailUrl: string
|
| 6 |
+
channel: {
|
| 7 |
+
id: string
|
| 8 |
+
name: string
|
| 9 |
+
avatarUrl?: string
|
| 10 |
+
verified?: boolean
|
| 11 |
+
}
|
| 12 |
+
views: number
|
| 13 |
+
publishedAt: string
|
| 14 |
+
duration: string
|
| 15 |
+
description?: string
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export type Channel = {
|
| 19 |
+
id: string
|
| 20 |
+
name: string
|
| 21 |
+
avatarUrl?: string
|
| 22 |
+
bannerUrl?: string
|
| 23 |
+
verified?: boolean
|
| 24 |
+
subscribers: number
|
| 25 |
+
description?: string
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export type Comment = {
|
| 29 |
+
id: string
|
| 30 |
+
author: {
|
| 31 |
+
id: string
|
| 32 |
+
name: string
|
| 33 |
+
avatarUrl?: string
|
| 34 |
+
}
|
| 35 |
+
text: string
|
| 36 |
+
likes: number
|
| 37 |
+
publishedAt: string
|
| 38 |
+
replies?: Comment[]
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
</html>
|
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ts
|
| 2 |
+
import { type ClassValue, clsx } from 'clsx'
|
| 3 |
+
import { twMerge } from 'tailwind-merge'
|
| 4 |
+
|
| 5 |
+
export function cn(...inputs: ClassValue[]) {
|
| 6 |
+
return twMerge(clsx(inputs))
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export function formatViews(views: number) {
|
| 10 |
+
if (views >= 1_000_000) return `${(views / 1_000_000).toFixed(1)}M`
|
| 11 |
+
if (views >= 1_000) return `${(views / 1_000).toFixed(1)}K`
|
| 12 |
+
return `${views}`
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function timeAgo(date: string) {
|
| 16 |
+
const d = new Date(date)
|
| 17 |
+
const diff = (Date.now() - d.getTime()) / 1000
|
| 18 |
+
if (diff < 60) return 'только что'
|
| 19 |
+
if (diff < 3600) return `${Math.floor(diff / 60)} мин. назад`
|
| 20 |
+
if (diff < 86400) return `${Math.floor(diff / 3600)} ч. назад`
|
| 21 |
+
if (diff < 2629800) return `${Math.floor(diff / 86400)} дн. назад`
|
| 22 |
+
return d.toLocaleDateString()
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
</html>
|
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tsx
|
| 2 |
+
import React from 'react'
|
| 3 |
+
import ReactDOM from 'react-dom/client'
|
| 4 |
+
import { BrowserRouter } from 'react-router-dom'
|
| 5 |
+
import App from './App'
|
| 6 |
+
import './index.css'
|
| 7 |
+
import { ThemeProvider } from './providers/ThemeProvider'
|
| 8 |
+
import { AuthProvider } from './providers/AuthProvider'
|
| 9 |
+
|
| 10 |
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
| 11 |
+
<React.StrictMode>
|
| 12 |
+
<BrowserRouter>
|
| 13 |
+
<ThemeProvider defaultTheme="dark" storageKey="lobstertube-theme">
|
| 14 |
+
<AuthProvider>
|
| 15 |
+
<App />
|
| 16 |
+
</AuthProvider>
|
| 17 |
+
</ThemeProvider>
|
| 18 |
+
</BrowserRouter>
|
| 19 |
+
</React.StrictMode>
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
</html>
|
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tsx
|
| 2 |
+
import { useEffect, useState } from 'react'
|
| 3 |
+
import api from '@/lib/api'
|
| 4 |
+
import { Video } from '@/lib/types'
|
| 5 |
+
import VideoGrid, { VideoGridSkeleton } from '@/components/VideoGrid'
|
| 6 |
+
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
|
| 7 |
+
|
| 8 |
+
export default function Home() {
|
| 9 |
+
const [videos, setVideos] = useState<Video[]>([])
|
| 10 |
+
const [page, setPage] = useState(1)
|
| 11 |
+
const [loading, setLoading] = useState(false)
|
| 12 |
+
const [hasMore, setHasMore] = useState(true)
|
| 13 |
+
|
| 14 |
+
async function load() {
|
| 15 |
+
if (loading || !hasMore) return
|
| 16 |
+
setLoading(true)
|
| 17 |
+
const res = await api.get('/videos', { params: { page, perPage: 24 } })
|
| 18 |
+
const data = res.data as Video[]
|
| 19 |
+
setVideos(prev => [...prev, ...data])
|
| 20 |
+
setPage(p => p + 1)
|
| 21 |
+
setHasMore(data.length > 0)
|
| 22 |
+
setLoading(false)
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
useEffect(() => {
|
| 26 |
+
load()
|
| 27 |
+
}, [])
|
| 28 |
+
|
| 29 |
+
const { sentinelRef } = useInfiniteScroll(() => load())
|
| 30 |
+
|
| 31 |
+
return (
|
| 32 |
+
<div className="space-y-6">
|
| 33 |
+
<VideoGrid videos={videos} />
|
| 34 |
+
{loading && <VideoGridSkeleton />}
|
| 35 |
+
{hasMore && <div ref={sentinelRef} className="h-10 w-full" />}
|
| 36 |
+
</div>
|
| 37 |
+
)
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
</html>
|
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tsx
|
| 2 |
+
import { useEffect, useState } from 'react'
|
| 3 |
+
import { useParams } from 'react-router-dom'
|
| 4 |
+
import api from '@/lib/api'
|
| 5 |
+
import { Comment, Video } from '@/lib/types'
|
| 6 |
+
import VideoPlayer from '@/components/VideoPlayer'
|
| 7 |
+
import { formatViews, timeAgo } from '@/lib/utils'
|
| 8 |
+
import { Button } from '@/components/ui/button'
|
| 9 |
+
import { ThumbsUp, ThumbsDown, Share, Download, Flag, MoreHorizontal } from 'lucide-react'
|
| 10 |
+
import VideoGrid from '@/components/VideoGrid'
|
| 11 |
+
import { Skeleton } from '@/components/ui/skeleton'
|
| 12 |
+
|
| 13 |
+
export default function Watch() {
|
| 14 |
+
const { id } = useParams()
|
| 15 |
+
const [video, setVideo] = useState<Video | null>(null)
|
| 16 |
+
const [comments, setComments] = useState<Comment[]>([])
|
| 17 |
+
const [recs, setRecs] = useState<Video[]>([])
|
| 18 |
+
const [loading, setLoading] = useState(true)
|
| 19 |
+
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
async function load() {
|
| 22 |
+
setLoading(true)
|
| 23 |
+
const [v, c, r] = await Promise.all([
|
| 24 |
+
api.get(`/videos/${id}`),
|
| 25 |
+
api.get(`/videos/${id}/comments`),
|
| 26 |
+
api.get('/videos', { params: { relatedTo: id, perPage: 20 } })
|
| 27 |
+
])
|
| 28 |
+
setVideo(v.data)
|
| 29 |
+
setComments(c.data)
|
| 30 |
+
setRecs(r.data)
|
| 31 |
+
setLoading(false)
|
| 32 |
+
}
|
| 33 |
+
if (id) load()
|
| 34 |
+
}, [
|
| 35 |
+
</html>
|
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tsx
|
| 2 |
+
import React from 'react'
|
| 3 |
+
import { useAuthStore } from '@/store/auth'
|
| 4 |
+
|
| 5 |
+
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
| 6 |
+
// Initialize from localStorage
|
| 7 |
+
const token = useAuthStore(s => s.token)
|
| 8 |
+
const setAuth = useAuthStore(s => s.setAuth)
|
| 9 |
+
React.useEffect(() => {
|
| 10 |
+
const t = localStorage.getItem('lt-token')
|
| 11 |
+
const u = localStorage.getItem('lt-user')
|
| 12 |
+
if (t && u) {
|
| 13 |
+
setAuth({ token: t, user: JSON.parse(u) })
|
| 14 |
+
}
|
| 15 |
+
}, [setAuth])
|
| 16 |
+
return <>{children}</>
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
</html>
|
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tsx
|
| 2 |
+
import React, { createContext, useContext, useEffect, useState } from 'react'
|
| 3 |
+
|
| 4 |
+
type Theme = 'dark' | 'light' | 'system'
|
| 5 |
+
type ThemeProviderProps = {
|
| 6 |
+
children: React.ReactNode
|
| 7 |
+
defaultTheme?: Theme
|
| 8 |
+
storageKey?: string
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
type ThemeProviderState = {
|
| 12 |
+
theme: Theme
|
| 13 |
+
setTheme: (theme: Theme) => void
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const initialState: ThemeProviderState = {
|
| 17 |
+
theme: 'system',
|
| 18 |
+
setTheme: () => null
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
| 22 |
+
|
| 23 |
+
export function ThemeProvider({ children, defaultTheme = 'system', storageKey = 'vite-ui-theme' }: ThemeProviderProps) {
|
| 24 |
+
const [theme, setTheme] = useState<Theme>(defaultTheme)
|
| 25 |
+
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
const root = window.document.documentElement
|
| 28 |
+
root.classList.remove('light', 'dark')
|
| 29 |
+
if (theme === 'system') {
|
| 30 |
+
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
| 31 |
+
root.classList.add(systemTheme)
|
| 32 |
+
return
|
| 33 |
+
}
|
| 34 |
+
root.classList.add(theme)
|
| 35 |
+
}, [theme])
|
| 36 |
+
|
| 37 |
+
const value = { theme, setTheme: (t: Theme) => localStorage.setItem(storageKey, t) || setTheme(t) }
|
| 38 |
+
|
| 39 |
+
return <ThemeProviderContext.Provider value={value}>{children}</ThemeProviderContext.Provider>
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export const useTheme = () => {
|
| 43 |
+
const context = useContext(ThemeProviderContext)
|
| 44 |
+
if (!context) throw new Error('useTheme must be used within ThemeProvider')
|
| 45 |
+
return context
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
</html>
|
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ts
|
| 2 |
+
import { create } from 'zustand'
|
| 3 |
+
|
| 4 |
+
type User = {
|
| 5 |
+
id: string
|
| 6 |
+
name: string
|
| 7 |
+
avatarUrl?: string
|
| 8 |
+
subscribers: number
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
type AuthState = {
|
| 12 |
+
token: string | null
|
| 13 |
+
user: User | null
|
| 14 |
+
setAuth: (data: { token: string; user: User }) => void
|
| 15 |
+
logout: () => void
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export const useAuthStore = create<AuthState>(set => ({
|
| 19 |
+
token: null,
|
| 20 |
+
user: null,
|
| 21 |
+
setAuth: ({ token, user }) => {
|
| 22 |
+
localStorage.setItem('lt-token', token)
|
| 23 |
+
localStorage.setItem('lt-user', JSON.stringify(user))
|
| 24 |
+
set({ token, user })
|
| 25 |
+
},
|
| 26 |
+
logout: () => {
|
| 27 |
+
localStorage.removeItem('lt-token')
|
| 28 |
+
localStorage.removeItem('lt-user')
|
| 29 |
+
set({ token: null, user: null })
|
| 30 |
+
}
|
| 31 |
+
}))
|
| 32 |
+
|
| 33 |
+
</html>
|
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ts
|
| 2 |
+
import { create } from 'zustand'
|
| 3 |
+
|
| 4 |
+
type UIState = {
|
| 5 |
+
sidebarOpen: boolean
|
| 6 |
+
toggleSidebar: () => void
|
| 7 |
+
setSidebarOpen: (open: boolean) => void
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export const useUIStore = create<UIState>(set => ({
|
| 11 |
+
sidebarOpen: true,
|
| 12 |
+
toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })),
|
| 13 |
+
setSidebarOpen: (open) => set({ sidebarOpen: open })
|
| 14 |
+
}))
|
| 15 |
+
|
| 16 |
+
</html>
|
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('tailwindcss').Config} */
|
| 2 |
+
export default {
|
| 3 |
+
darkMode: 'class',
|
| 4 |
+
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
| 5 |
+
theme: {
|
| 6 |
+
extend: {
|
| 7 |
+
colors: {
|
| 8 |
+
brand: {
|
| 9 |
+
50: '#f4f7ff',
|
| 10 |
+
100: '#e6edff',
|
| 11 |
+
200: '#c8d7ff',
|
| 12 |
+
300: '#a5bbff',
|
| 13 |
+
400: '#7f9cff',
|
| 14 |
+
500: '#627cff',
|
| 15 |
+
600: '#465fde',
|
| 16 |
+
700: '#3348a8',
|
| 17 |
+
800: '#273f8a',
|
| 18 |
+
900: '#1f346f'
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
},
|
| 23 |
+
plugins: []
|
| 24 |
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
json
|
| 2 |
+
{
|
| 3 |
+
"extends": "./tsconfig.json",
|
| 4 |
+
"include": ["src"]
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
</html>
|
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
json
|
| 2 |
+
{
|
| 3 |
+
"compilerOptions": {
|
| 4 |
+
"target": "ES2020",
|
| 5 |
+
"useDefineForClassFields": true,
|
| 6 |
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
| 7 |
+
"module": "ESNext",
|
| 8 |
+
"skipLibCheck": true,
|
| 9 |
+
"moduleResolution": "Bundler",
|
| 10 |
+
"resolveJsonModule": true,
|
| 11 |
+
"isolatedModules": true,
|
| 12 |
+
"noEmit": true,
|
| 13 |
+
"jsx": "react-jsx",
|
| 14 |
+
"strict": true,
|
| 15 |
+
"baseUrl": ".",
|
| 16 |
+
"paths": {
|
| 17 |
+
"@/*": ["./src/*"]
|
| 18 |
+
}
|
| 19 |
+
},
|
| 20 |
+
"include": ["src", "vite.config.ts"]
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
</html>
|
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ts
|
| 2 |
+
import { defineConfig } from 'vite'
|
| 3 |
+
import react from '@vitejs/plugin-react'
|
| 4 |
+
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
plugins: [react()],
|
| 7 |
+
server: { port: 5173 },
|
| 8 |
+
resolve: {
|
| 9 |
+
alias: {
|
| 10 |
+
'@': '/src'
|
| 11 |
+
}
|
| 12 |
+
}
|
| 13 |
+
})
|
| 14 |
+
|
| 15 |
+
</html>
|