lolakd commited on
Commit
a7aae55
·
verified ·
1 Parent(s): 3a48846

Создай полноценный 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 CHANGED
@@ -1,10 +1,13 @@
1
  ---
2
- title: Lobstertube
3
- emoji:
4
- colorFrom: green
5
- colorTo: green
6
  sdk: static
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
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).
index.html CHANGED
@@ -1,19 +1,15 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
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>
 
 
 
 
package.json ADDED
@@ -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>
postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {}
5
+ }
6
+ }
public/favicon.svg ADDED
src/App.tsx ADDED
@@ -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>
src/components/Header.tsx ADDED
@@ -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>
src/components/Layout.tsx ADDED
@@ -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>
src/components/Sidebar.tsx ADDED
@@ -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>
src/components/VideoCard.tsx ADDED
@@ -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>
src/components/VideoGrid.tsx ADDED
@@ -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>
src/components/VideoPlayer.tsx ADDED
@@ -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>
src/components/ui/button.tsx ADDED
@@ -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>
src/components/ui/card.tsx ADDED
@@ -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>
src/components/ui/dropdown-menu.tsx ADDED
@@ -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>
src/components/ui/input.tsx ADDED
@@ -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>
src/components/ui/skeleton.tsx ADDED
@@ -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>
src/components/ui/textarea.tsx ADDED
@@ -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>
src/hooks/useInfiniteScroll.ts ADDED
@@ -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>
src/index.css ADDED
@@ -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
+ }
src/lib/api.ts ADDED
@@ -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>
src/lib/types.ts ADDED
@@ -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>
src/lib/utils.ts ADDED
@@ -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>
src/main.tsx ADDED
@@ -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>
src/pages/Home.tsx ADDED
@@ -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>
src/pages/Watch.tsx ADDED
@@ -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>
src/providers/AuthProvider.tsx ADDED
@@ -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>
src/providers/ThemeProvider.tsx ADDED
@@ -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>
src/store/auth.ts ADDED
@@ -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>
src/store/ui.ts ADDED
@@ -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>
tailwind.config.js ADDED
@@ -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
+ }
tsconfig.app.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ json
2
+ {
3
+ "extends": "./tsconfig.json",
4
+ "include": ["src"]
5
+ }
6
+
7
+ </html>
tsconfig.json ADDED
@@ -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>
vite.config.ts ADDED
@@ -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>