first commit
This commit is contained in:
commit
bb3aeea99b
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# 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?
|
||||
.env
|
||||
21
eslint.config.js
Normal file
21
eslint.config.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
},
|
||||
])
|
||||
13
index.html
Normal file
13
index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3506
package-lock.json
generated
Normal file
3506
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.105.1",
|
||||
"axios": "^1.21.0",
|
||||
"meilisearch": "^0.62.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-router-dom": "^7.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.13.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"vite": "^8.3.5"
|
||||
}
|
||||
}
|
||||
37
src/api/audio.js
Normal file
37
src/api/audio.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "./client";
|
||||
|
||||
export function useAudio({id, page, perPage}) {
|
||||
return useQuery({
|
||||
queryKey: ['audio', id, page, perPage],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/audio',
|
||||
{params: {id, page, per_page: perPage}}
|
||||
)
|
||||
return data
|
||||
},
|
||||
enabled: !!id
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlaylists({}) {
|
||||
return useQuery ({
|
||||
queryKey: ['playlists'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/audioplaylists')
|
||||
return data
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useAudioFile({id}) {
|
||||
return useQuery ({
|
||||
queryKey: ['audio', id],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("audio_file", { params: { id } })
|
||||
return data
|
||||
},
|
||||
enabled: !!id
|
||||
})
|
||||
|
||||
}
|
||||
26
src/api/channels.js
Normal file
26
src/api/channels.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from './client.js'
|
||||
|
||||
|
||||
export function useChannels() {
|
||||
return useQuery({
|
||||
queryKey: ['channels'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/channels')
|
||||
return data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useChannel({id, page = 1, perPage = 24}) {
|
||||
return useQuery({
|
||||
queryKey: ['channels', id, page, perPage],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/channel", {
|
||||
params: {id, page, per_page: perPage }
|
||||
})
|
||||
return data
|
||||
},
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
65
src/api/client.js
Normal file
65
src/api/client.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import axios from 'axios'
|
||||
|
||||
export const BASE = import.meta.env.VITE_API_URL ?? 'http://127.0.0.1:8000'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: BASE,
|
||||
withCredentials: true, // 👈 refresh-кука должна лететь
|
||||
})
|
||||
|
||||
let isRefreshing = false
|
||||
let queue = [] // запросы, ждущие окончания refresh
|
||||
|
||||
const processQueue = (error) => {
|
||||
queue.forEach(({ resolve, reject }) => (error ? reject(error) : resolve()))
|
||||
queue = []
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const original = error.config
|
||||
const status = error.response?.status
|
||||
|
||||
// не 401, или это сам /refresh, или уже повторяли — отдаём ошибку
|
||||
if (
|
||||
status !== 422 ||
|
||||
original._retry ||
|
||||
original.url?.includes("/auth/refresh")
|
||||
) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// если refresh уже идёт — ставим запрос в очередь
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
queue.push({ resolve, reject })
|
||||
}).then(() => api(original))
|
||||
}
|
||||
|
||||
original._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
await api.post("/auth/refresh") // 👈 бэк ставит новую access-куку
|
||||
processQueue(null)
|
||||
return api(original) // 👈 повторяем исходный запрос
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError)
|
||||
// refresh не удался — на логин
|
||||
window.dispatchEvent(new Event("auth:unauthorized"))
|
||||
return Promise.reject(refreshError)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
export const assetUrl = (path) => {
|
||||
if (!path) return '';
|
||||
if (path.startsWith('http')) return path;
|
||||
// ВАЖНО: Никаких decodeURIComponent здесь быть не должно!
|
||||
// Бэкенд уже отдал энкоженный путь.
|
||||
return `${BASE}${path}`;
|
||||
}
|
||||
42
src/api/photo.js
Normal file
42
src/api/photo.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
import { api } from './client.js'
|
||||
|
||||
|
||||
export function usePhotos({page, perPage=24, category}) {
|
||||
return (
|
||||
useQuery({
|
||||
queryKey: ['channels', page, perPage, category],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/photos", {
|
||||
params: {page, per_page: perPage, category}
|
||||
})
|
||||
return data
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function useCategories({}) {
|
||||
return (
|
||||
useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/categories")
|
||||
return data
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function useCategory({id, page, perPage}) {
|
||||
return (
|
||||
useQuery({
|
||||
queryKey: ['photo', id, page, perPage = 24],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("category", {params: {id, page, per_page: perPage}})
|
||||
return data
|
||||
},
|
||||
enabled: !!id,
|
||||
})
|
||||
)
|
||||
}
|
||||
0
src/api/playlist.js
Normal file
0
src/api/playlist.js
Normal file
12
src/api/search.js
Normal file
12
src/api/search.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { api } from "./client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
|
||||
export function useSearch({q, limit, offset}) {
|
||||
return useQuery({
|
||||
queryKey: ['search', q, limit, offset],
|
||||
queryFn: async () => {
|
||||
api.get('/search', {params: {q, limit, offset}})
|
||||
},
|
||||
})
|
||||
}
|
||||
28
src/api/videos.js
Normal file
28
src/api/videos.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from './client.js'
|
||||
|
||||
|
||||
export function useVideos({page = 1, perPage = 24 }) {
|
||||
return useQuery({
|
||||
queryKey: ['videos', page, perPage],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/videos', {
|
||||
params: {page, per_page: perPage },
|
||||
})
|
||||
return data
|
||||
},
|
||||
staleTime: 60000,
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
}
|
||||
|
||||
export function useVideo(id) {
|
||||
return useQuery({
|
||||
queryKey: ['video', id],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/video', { params: { id } })
|
||||
return data
|
||||
},
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
121
src/components/AudioPlayer.jsx
Normal file
121
src/components/AudioPlayer.jsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import { assetUrl } from '../api/client'
|
||||
|
||||
export default function AudioPlayer({ src, title, cover }) {
|
||||
const audioRef = useRef(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [volume, setVolume] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
|
||||
const updateTime = () => setCurrentTime(audio.currentTime)
|
||||
const updateDuration = () => setDuration(audio.duration)
|
||||
const onEnded = () => setIsPlaying(false)
|
||||
|
||||
audio.addEventListener('timeupdate', updateTime)
|
||||
audio.addEventListener('loadedmetadata', updateDuration)
|
||||
audio.addEventListener('ended', onEnded)
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', updateTime)
|
||||
audio.removeEventListener('loadedmetadata', updateDuration)
|
||||
audio.removeEventListener('ended', onEnded)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const togglePlay = () => {
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause()
|
||||
} else {
|
||||
audioRef.current.play()
|
||||
}
|
||||
setIsPlaying(!isPlaying)
|
||||
}
|
||||
|
||||
const handleSeek = (e) => {
|
||||
const time = Number(e.target.value)
|
||||
audioRef.current.currentTime = time
|
||||
setCurrentTime(time)
|
||||
}
|
||||
|
||||
const handleVolume = (e) => {
|
||||
const vol = Number(e.target.value)
|
||||
audioRef.current.volume = vol
|
||||
setVolume(vol)
|
||||
}
|
||||
|
||||
const formatTime = (time) => {
|
||||
if (isNaN(time)) return '0:00'
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = Math.floor(time % 60)
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4 shadow-lg flex flex-col gap-4">
|
||||
{/* Скрытый нативный аудио-тег */}
|
||||
<audio ref={audioRef} src={assetUrl(src)} />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Обложка (если есть) */}
|
||||
{cover && (
|
||||
<img
|
||||
src={assetUrl(cover)}
|
||||
alt={title}
|
||||
className="w-16 h-16 rounded-md object-cover shadow-md"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-white font-semibold line-clamp-1">{title}</h3>
|
||||
<p className="text-sm text-zinc-400">Аудиофайл</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Прогресс-бар */}
|
||||
<div className="flex items-center gap-3 text-xs text-zinc-400 font-mono">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 0}
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
className="w-full h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-rose-500"
|
||||
/>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* Кнопки управления */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="w-12 h-12 flex items-center justify-center bg-rose-500 hover:bg-rose-400 text-white rounded-full transition shadow-md"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6 ml-1" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Громкость */}
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-zinc-400" fill="currentColor" viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={handleVolume}
|
||||
className="w-24 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-rose-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
83
src/components/Layout.jsx
Normal file
83
src/components/Layout.jsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { Outlet, NavLink } from 'react-router-dom'
|
||||
import SearchBox from './SearchBox'
|
||||
|
||||
|
||||
const ICONS = {
|
||||
video: 'M8 5v14l11-7z',
|
||||
channels:
|
||||
'M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z',
|
||||
categories:
|
||||
'M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z',
|
||||
photos:
|
||||
'M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z',
|
||||
playlists:
|
||||
'M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z',
|
||||
}
|
||||
|
||||
const NAV = [
|
||||
{ to: '/', label: 'video', icon: ICONS.video, end: true },
|
||||
{ to: '/channels', label: 'channels', icon: ICONS.channels },
|
||||
{ to: '/categories', label: 'photo categories', icon: ICONS.categories },
|
||||
{ to: '/photos', label: 'all photos', icon: ICONS.photos },
|
||||
{ to: '/audioplaylists', label: 'playlists', icon: ICONS.playlists },
|
||||
]
|
||||
|
||||
const linkClass = ({ isActive }) =>
|
||||
`group relative flex shrink-0 items-center gap-2 rounded-full px-3.5 py-2 text-sm font-medium transition-colors
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400 ${
|
||||
isActive
|
||||
? 'bg-rose-500/15 text-rose-100 ring-1 ring-inset ring-rose-500/40'
|
||||
: 'text-zinc-400 hover:bg-white/5 hover:text-white'
|
||||
}`
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100 antialiased selection:bg-rose-500/40">
|
||||
{/* soft rose glow behind the header */}
|
||||
<div className="pointer-events-none fixed inset-x-0 top-0 -z-0 h-80 bg-[radial-gradient(60%_100%_at_50%_0%,rgba(244,63,94,0.16),transparent)]" />
|
||||
|
||||
<header className="sticky top-0 z-40 border-b border-white/10 bg-zinc-950/75 backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-4 px-4 py-3 sm:px-6">
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
aria-label="На главную"
|
||||
className="flex shrink-0 items-center gap-2.5 rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400"
|
||||
>
|
||||
<span className="hidden text-lg font-bold tracking-tight text-white sm:block">ndmg's media library</span>
|
||||
</NavLink>
|
||||
|
||||
<nav
|
||||
aria-label="Основная навигация"
|
||||
className="flex min-w-0 flex-1 gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{NAV.map(({ to, label, icon, end }) => (
|
||||
<NavLink key={to} to={to} end={end} className={linkClass}>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<svg
|
||||
className={`hidden h-[18px] w-[18px] md:block ${
|
||||
isActive ? 'text-rose-400' : 'text-zinc-500 group-hover:text-rose-400'
|
||||
}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={icon} />
|
||||
</svg>
|
||||
{label}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<SearchBox />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="relative mx-auto max-w-6xl px-4 py-8 sm:px-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
src/components/Paginator.jsx
Normal file
21
src/components/Paginator.jsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export default function Paginator({page, hasNext, isFetching, setPage}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 mt-4">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1 || isFetching}
|
||||
className="px-4 py-2 rounded-md bg-zinc-800 disabled:opacity-40"
|
||||
>
|
||||
← back
|
||||
</button>
|
||||
<span className="text-slate-400">стр. {page}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={!hasNext || isFetching}
|
||||
className="px-4 py-2 rounded-md bg-zinc-800 disabled:opacity-40"
|
||||
>
|
||||
next →
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
src/components/PhotoList.jsx
Normal file
27
src/components/PhotoList.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { assetUrl } from '../api/client'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function PhotoList({data}) {
|
||||
return (
|
||||
<div>
|
||||
{data.photos.length === 0 ? (
|
||||
<p className="text-white">Ничего не найдено.</p>
|
||||
)
|
||||
: (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{data.photos.map((p) => (
|
||||
<Link to={assetUrl(p.path)}>
|
||||
<img
|
||||
src={assetUrl(p.path)}
|
||||
alt={(p.filename)}
|
||||
className="w-full h-40 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
254
src/components/SearchBox.jsx
Normal file
254
src/components/SearchBox.jsx
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Meilisearch } from 'meilisearch'
|
||||
|
||||
const client = new Meilisearch({
|
||||
host: import.meta.env.VITE_MEILI_HOST,
|
||||
apiKey: import.meta.env.VITE_MEILI_SEARCH_KEY,
|
||||
})
|
||||
|
||||
const MEDIA = import.meta.env.VITE_MEDIA_URL
|
||||
|
||||
// Кодируем каждый сегмент пути отдельно (пробелы, кириллица, эмодзи, # и ?)
|
||||
const mediaUrl = (path) => {
|
||||
if (!path) return null
|
||||
if (!MEDIA) {
|
||||
console.warn('VITE_MEDIA_URL не задан')
|
||||
return null
|
||||
}
|
||||
return `${MEDIA}/${path.split('/').map(encodeURIComponent).join('/')}`
|
||||
}
|
||||
|
||||
// Оставьте только те индексы, которые реально существуют в Meilisearch,
|
||||
// иначе multiSearch вернёт ошибку целиком.
|
||||
const INDEXES = [
|
||||
{
|
||||
uid: 'videos',
|
||||
label: 'Видео',
|
||||
limit: 20,
|
||||
title: (d) => d.title,
|
||||
subtitle: (d) => [d.channel_name, d.playlist_name].filter(Boolean).join(' · '),
|
||||
image: (d) => mediaUrl(d.preview_path),
|
||||
to: (d) => `/video/${d.id}`,
|
||||
},
|
||||
// Раскомментируйте, когда создадите индексы:
|
||||
// { uid: 'channels', label: 'Каналы', limit: 5, title: (d) => d.name, to: (d) => `/channels/${d.id}` },
|
||||
// { uid: 'categories', label: 'Категории', limit: 5, title: (d) => d.name, to: (d) => `/categories/${d.id}` },
|
||||
// { uid: 'photos', label: 'Фото', limit: 5, title: (d) => d.title || d.name, to: (d) => `/photos/${d.id}` },
|
||||
// { uid: 'playlists', label: 'Плейлисты', limit: 5, title: (d) => d.title || d.name, to: (d) => `/audioplaylists/${d.id}` },
|
||||
]
|
||||
|
||||
export default function SearchBox() {
|
||||
const navigate = useNavigate()
|
||||
const [q, setQ] = useState('')
|
||||
const [groups, setGroups] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [active, setActive] = useState(-1)
|
||||
const inputRef = useRef(null)
|
||||
const rootRef = useRef(null)
|
||||
const listRef = useRef(null)
|
||||
const reqId = useRef(0)
|
||||
|
||||
// плоский список для навигации стрелками
|
||||
const flat = useMemo(() => groups.flatMap((g) => g.items), [groups])
|
||||
|
||||
// поиск с debounce
|
||||
useEffect(() => {
|
||||
const query = q.trim()
|
||||
if (!query) {
|
||||
setGroups([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
const id = ++reqId.current
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const { results } = await client.multiSearch({
|
||||
queries: INDEXES.map((i) => ({
|
||||
indexUid: i.uid,
|
||||
q: query,
|
||||
limit: i.limit ?? 4,
|
||||
})),
|
||||
})
|
||||
if (id !== reqId.current) return // устаревший ответ
|
||||
const next = results
|
||||
.map((r) => {
|
||||
const cfg = INDEXES.find((i) => i.uid === r.indexUid)
|
||||
return {
|
||||
label: cfg.label,
|
||||
total: r.estimatedTotalHits,
|
||||
items: r.hits.map((h) => ({
|
||||
key: `${cfg.uid}:${h.id}`,
|
||||
title: cfg.title(h) ?? '(без названия)',
|
||||
subtitle: cfg.subtitle?.(h),
|
||||
image: cfg.image?.(h),
|
||||
to: cfg.to(h),
|
||||
})),
|
||||
}
|
||||
})
|
||||
.filter((g) => g.items.length)
|
||||
setGroups(next)
|
||||
setActive(-1)
|
||||
} catch (e) {
|
||||
console.error('Meili search error', e)
|
||||
if (id === reqId.current) setGroups([])
|
||||
} finally {
|
||||
if (id === reqId.current) setLoading(false)
|
||||
}
|
||||
}, 200)
|
||||
return () => clearTimeout(t)
|
||||
}, [q])
|
||||
|
||||
// Ctrl/Cmd+K и клик вне компонента
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
inputRef.current?.focus()
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
const onClick = (e) => {
|
||||
if (!rootRef.current?.contains(e.target)) setOpen(false)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
document.addEventListener('mousedown', onClick)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
document.removeEventListener('mousedown', onClick)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// подкрутка списка к выбранному пункту при навигации стрелками
|
||||
useEffect(() => {
|
||||
if (active < 0) return
|
||||
listRef.current
|
||||
?.querySelector(`[data-idx="${active}"]`)
|
||||
?.scrollIntoView({ block: 'nearest' })
|
||||
}, [active])
|
||||
|
||||
const go = (item) => {
|
||||
if (!item) return
|
||||
navigate(item.to)
|
||||
setOpen(false)
|
||||
setQ('')
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
|
||||
const onKeyDown = (e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setActive((a) => (flat.length ? (a + 1) % flat.length : -1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActive((a) => (flat.length ? (a <= 0 ? flat.length - 1 : a - 1) : -1))
|
||||
} else if (e.key === 'Enter') {
|
||||
go(flat[active >= 0 ? active : 0])
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}
|
||||
|
||||
const showPanel = open && q.trim().length > 0
|
||||
let idx = -1
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative w-full sm:w-64 lg:w-80">
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.5-3.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value)
|
||||
setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Поиск…"
|
||||
aria-label="Поиск по медиатеке"
|
||||
className="w-full rounded-full border border-white/10 bg-white/5 py-2 pl-9 pr-12 text-sm text-zinc-100 placeholder:text-zinc-500 focus:border-rose-500/50 focus:outline-none focus:ring-2 focus:ring-rose-400/40 [&::-webkit-search-cancel-button]:hidden"
|
||||
/>
|
||||
<kbd className="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 rounded border border-white/10 px-1.5 text-[10px] text-zinc-500 sm:block">
|
||||
⌘K
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{showPanel && (
|
||||
// Внешняя оболочка: оформление и blur. Внутри отдельный блок, который скроллится.
|
||||
<div className="absolute left-0 right-0 top-full z-50 mt-2 overflow-hidden rounded-2xl border border-white/10 bg-zinc-900/95 shadow-2xl backdrop-blur-xl">
|
||||
<div
|
||||
ref={listRef}
|
||||
style={{ maxHeight: 'min(70vh, 480px)' }}
|
||||
className="overflow-y-auto p-2"
|
||||
>
|
||||
{loading && !groups.length && (
|
||||
<p className="px-3 py-2 text-sm text-zinc-500">Ищу…</p>
|
||||
)}
|
||||
{!loading && !groups.length && (
|
||||
<p className="px-3 py-2 text-sm text-zinc-500">Ничего не найдено</p>
|
||||
)}
|
||||
|
||||
{groups.map((g) => (
|
||||
<div key={g.label} className="mb-1 last:mb-0">
|
||||
<p className="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
{g.label}
|
||||
{g.total > g.items.length && ` · ${g.total}`}
|
||||
</p>
|
||||
{g.items.map((item) => {
|
||||
idx += 1
|
||||
const i = idx
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
data-idx={i}
|
||||
type="button"
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => go(item)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors ${
|
||||
active === i
|
||||
? 'bg-rose-500/15 text-rose-100'
|
||||
: 'text-zinc-300 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{item.image && (
|
||||
<img
|
||||
src={item.image}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-9 w-16 shrink-0 rounded bg-zinc-800 object-cover"
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm">{item.title}</span>
|
||||
{item.subtitle && (
|
||||
<span className="block truncate text-xs text-zinc-500">
|
||||
{item.subtitle}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
src/components/VideoList.jsx
Normal file
124
src/components/VideoList.jsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { assetUrl } from '../api/client'
|
||||
import { videoTitle, formatDate } from '../lib/format'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
// ---- стили (только внешний вид) ----
|
||||
const gridClass = 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5'
|
||||
|
||||
const cardClass =
|
||||
'group flex flex-col overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/60 ' +
|
||||
'transition duration-300 hover:-translate-y-1 hover:border-rose-500/50 ' +
|
||||
'hover:shadow-xl hover:shadow-rose-500/10'
|
||||
|
||||
const thumbLinkClass =
|
||||
'relative block aspect-video overflow-hidden bg-zinc-800 ' +
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-rose-400'
|
||||
|
||||
const imgClass =
|
||||
'h-full w-full object-cover transition duration-500 group-hover:scale-105'
|
||||
|
||||
const titleClass =
|
||||
'text-sm font-medium leading-snug text-zinc-100 line-clamp-2 transition-colors group-hover:text-white'
|
||||
|
||||
const dateClass = 'shrink-0 text-xs tabular-nums text-zinc-400'
|
||||
|
||||
const channelClass =
|
||||
'truncate text-xs font-medium text-rose-400 transition-colors hover:text-rose-300'
|
||||
|
||||
function PlayOverlay() {
|
||||
return (
|
||||
<>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
|
||||
<div className="pointer-events-none absolute left-1/2 top-1/2 flex h-12 w-12 -translate-x-1/2 -translate-y-1/2 scale-75 items-center justify-center rounded-full bg-rose-500 text-white opacity-0 shadow-lg shadow-rose-500/40 ring-4 ring-rose-500/25 transition duration-300 group-hover:scale-100 group-hover:opacity-100">
|
||||
<svg className="h-6 w-6 translate-x-px" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function VideoList({ data, needChannelName = false}) {
|
||||
const videos = data?.videos ?? []
|
||||
|
||||
if (videos.length === 0) {
|
||||
return (
|
||||
<p className="rounded-2xl border border-dashed border-white/10 px-6 py-12 text-center text-zinc-400">
|
||||
Ничего не найдено.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (needChannelName) {
|
||||
return (
|
||||
<div className={gridClass}>
|
||||
{videos.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className={cardClass}
|
||||
>
|
||||
<Link to={`/video/${v.id}`} className={thumbLinkClass}>
|
||||
<img
|
||||
src={assetUrl(v.preview_path)}
|
||||
alt={videoTitle(v)}
|
||||
className={imgClass}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
<PlayOverlay />
|
||||
</Link>
|
||||
<div className="flex grow flex-col p-4">
|
||||
<Link to={`/video/${v.id}`} className="mb-3 block">
|
||||
<p className={titleClass}>{videoTitle(v)}</p>
|
||||
</Link>
|
||||
<div className="meta mt-auto flex items-center gap-3">
|
||||
{formatDate(v.date) && (
|
||||
<p className={dateClass}>{formatDate(v.date)}</p>
|
||||
)}
|
||||
<Link
|
||||
to={`/channel/${v.channel_id}`}
|
||||
className={channelClass}
|
||||
>
|
||||
{v.channel_name || 'channel'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div className={gridClass}>
|
||||
{videos.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className={cardClass}
|
||||
>
|
||||
<Link to={`/video/${v.id}`} className={thumbLinkClass}>
|
||||
<img
|
||||
src={assetUrl(v.preview_path)}
|
||||
alt={videoTitle(v)}
|
||||
className={imgClass}
|
||||
loading="lazy"
|
||||
/>
|
||||
<PlayOverlay />
|
||||
</Link>
|
||||
<div className="flex grow flex-col p-4">
|
||||
<Link to={`/video/${v.id}`} className="mb-3 block">
|
||||
<p className={titleClass}>{videoTitle(v)}</p>
|
||||
</Link>
|
||||
<div className="meta mt-auto flex items-center gap-3">
|
||||
{formatDate(v.date) && (
|
||||
<p className={dateClass}>{formatDate(v.date)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
514
src/components/VideoPlayer.jsx
Normal file
514
src/components/VideoPlayer.jsx
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
|
||||
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2]
|
||||
const clamp = (v, min, max) => Math.min(max, Math.max(min, v))
|
||||
|
||||
const formatTime = (time) => {
|
||||
if (!Number.isFinite(time) || time < 0) return '0:00'
|
||||
const h = Math.floor(time / 3600)
|
||||
const m = Math.floor((time % 3600) / 60)
|
||||
const s = Math.floor(time % 60)
|
||||
const ss = s.toString().padStart(2, '0')
|
||||
return h > 0 ? `${h}:${m.toString().padStart(2, '0')}:${ss}` : `${m}:${ss}`
|
||||
}
|
||||
|
||||
/* ---------- icons ---------- */
|
||||
|
||||
const Icon = ({ d, className = 'w-6 h-6' }) => (
|
||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d={d} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ICONS = {
|
||||
play: 'M8 5v14l11-7z',
|
||||
pause: 'M6 4h4v16H6V4zm8 0h4v16h-4V4z',
|
||||
replay: 'M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z',
|
||||
muted: 'M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z',
|
||||
volLow: 'M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z',
|
||||
volHigh: 'M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z',
|
||||
enterFs: 'M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z',
|
||||
exitFs: 'M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z',
|
||||
}
|
||||
|
||||
const SkipIcon = ({ forward = false }) => (
|
||||
<svg className="w-7 h-7" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d={ICONS.replay} transform={forward ? 'translate(24 0) scale(-1 1)' : undefined} />
|
||||
<text x="12" y="15.6" textAnchor="middle" fontSize="7" fontWeight="700" fill="currentColor">
|
||||
10
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/* ---------- pointer-driven slider logic (shared by seek bar and volume) ---------- */
|
||||
|
||||
function useSlider({ onChange, onCommit }) {
|
||||
const ref = useRef(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const ratioFrom = (e) => {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
return clamp((e.clientX - rect.left) / rect.width, 0, 1)
|
||||
}
|
||||
|
||||
const bind = {
|
||||
onPointerDown: (e) => {
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
setDragging(true)
|
||||
onChange(ratioFrom(e))
|
||||
},
|
||||
onPointerMove: (e) => {
|
||||
if (dragging) onChange(ratioFrom(e))
|
||||
},
|
||||
onPointerUp: (e) => {
|
||||
if (!dragging) return
|
||||
setDragging(false)
|
||||
onCommit?.(ratioFrom(e))
|
||||
},
|
||||
onPointerCancel: () => setDragging(false),
|
||||
}
|
||||
|
||||
return { ref, dragging, bind, ratioFrom }
|
||||
}
|
||||
|
||||
/* ---------- component ---------- */
|
||||
|
||||
export default function VideoPlayer({ src, poster }) {
|
||||
const videoRef = useRef(null)
|
||||
const containerRef = useRef(null)
|
||||
const menuRef = useRef(null)
|
||||
const hideTimer = useRef(null)
|
||||
const hintTimer = useRef(null)
|
||||
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [ended, setEnded] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [buffered, setBuffered] = useState(0)
|
||||
const [volume, setVolume] = useState(1)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
const [rate, setRate] = useState(1)
|
||||
const [speedOpen, setSpeedOpen] = useState(false)
|
||||
const [showControls, setShowControls] = useState(true)
|
||||
const [isBuffering, setIsBuffering] = useState(false)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
const [scrubTime, setScrubTime] = useState(0)
|
||||
const [hoverRatio, setHoverRatio] = useState(null)
|
||||
const [hint, setHint] = useState(null)
|
||||
|
||||
/* ----- actions ----- */
|
||||
|
||||
const flashHint = useCallback((text) => {
|
||||
setHint({ text, id: Date.now() })
|
||||
clearTimeout(hintTimer.current)
|
||||
hintTimer.current = setTimeout(() => setHint(null), 900)
|
||||
}, [])
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (video.paused || video.ended) {
|
||||
video.play().catch(() => {})
|
||||
} else {
|
||||
video.pause()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const seekTo = useCallback((time) => {
|
||||
const video = videoRef.current
|
||||
if (!video || !Number.isFinite(video.duration)) return
|
||||
video.currentTime = clamp(time, 0, video.duration)
|
||||
setCurrentTime(video.currentTime)
|
||||
}, [])
|
||||
|
||||
const seekBy = useCallback(
|
||||
(delta) => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
seekTo(video.currentTime + delta)
|
||||
flashHint(`${delta > 0 ? '+' : '−'}${Math.abs(delta)} с`)
|
||||
},
|
||||
[seekTo, flashHint]
|
||||
)
|
||||
|
||||
const setVolumeTo = useCallback((v) => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const next = clamp(v, 0, 1)
|
||||
video.volume = next
|
||||
video.muted = next === 0
|
||||
}, [])
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (video.muted || video.volume === 0) {
|
||||
if (video.volume === 0) video.volume = 0.5
|
||||
video.muted = false
|
||||
} else {
|
||||
video.muted = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!document.fullscreenElement) {
|
||||
containerRef.current?.requestFullscreen?.()
|
||||
} else {
|
||||
document.exitFullscreen?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const changeRate = (r) => {
|
||||
videoRef.current.playbackRate = r
|
||||
setSpeedOpen(false)
|
||||
flashHint(`Скорость ${r}×`)
|
||||
}
|
||||
|
||||
const revealControls = useCallback(() => {
|
||||
setShowControls(true)
|
||||
clearTimeout(hideTimer.current)
|
||||
hideTimer.current = setTimeout(() => {
|
||||
if (videoRef.current && !videoRef.current.paused) setShowControls(false)
|
||||
}, 2800)
|
||||
}, [])
|
||||
|
||||
/* ----- sliders ----- */
|
||||
|
||||
const seekSlider = useSlider({
|
||||
onChange: (r) => setScrubTime(r * duration),
|
||||
onCommit: (r) => seekTo(r * duration),
|
||||
})
|
||||
|
||||
const volumeSlider = useSlider({
|
||||
onChange: (r) => setVolumeTo(r),
|
||||
})
|
||||
|
||||
/* ----- video events ----- */
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
const updateBuffered = () => {
|
||||
const t = video.currentTime
|
||||
for (let i = 0; i < video.buffered.length; i++) {
|
||||
if (video.buffered.start(i) <= t && t <= video.buffered.end(i)) {
|
||||
setBuffered(video.buffered.end(i))
|
||||
return
|
||||
}
|
||||
}
|
||||
setBuffered(0)
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
timeupdate: () => {
|
||||
setCurrentTime(video.currentTime)
|
||||
updateBuffered()
|
||||
},
|
||||
progress: updateBuffered,
|
||||
loadedmetadata: () => setDuration(video.duration),
|
||||
durationchange: () => setDuration(video.duration),
|
||||
play: () => {
|
||||
setIsPlaying(true)
|
||||
setEnded(false)
|
||||
},
|
||||
pause: () => setIsPlaying(false),
|
||||
ended: () => {
|
||||
setIsPlaying(false)
|
||||
setEnded(true)
|
||||
setShowControls(true)
|
||||
},
|
||||
waiting: () => setIsBuffering(true),
|
||||
playing: () => setIsBuffering(false),
|
||||
canplay: () => setIsBuffering(false),
|
||||
volumechange: () => {
|
||||
setVolume(video.volume)
|
||||
setIsMuted(video.muted || video.volume === 0)
|
||||
},
|
||||
ratechange: () => setRate(video.playbackRate),
|
||||
error: () => setHasError(true),
|
||||
loadstart: () => setHasError(false),
|
||||
}
|
||||
|
||||
Object.entries(handlers).forEach(([name, fn]) => video.addEventListener(name, fn))
|
||||
if (video.readyState >= 1) setDuration(video.duration)
|
||||
|
||||
const onFullscreenChange = () => setIsFullscreen(document.fullscreenElement === containerRef.current)
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
|
||||
return () => {
|
||||
Object.entries(handlers).forEach(([name, fn]) => video.removeEventListener(name, fn))
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||
clearTimeout(hideTimer.current)
|
||||
clearTimeout(hintTimer.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// close speed menu on outside click
|
||||
useEffect(() => {
|
||||
if (!speedOpen) return
|
||||
const onDown = (e) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target)) setSpeedOpen(false)
|
||||
}
|
||||
document.addEventListener('pointerdown', onDown)
|
||||
return () => document.removeEventListener('pointerdown', onDown)
|
||||
}, [speedOpen])
|
||||
|
||||
/* ----- keyboard ----- */
|
||||
|
||||
const onKeyDown = (e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return
|
||||
// let focused buttons handle Space / Enter themselves
|
||||
if (e.target.tagName === 'BUTTON' && (e.key === ' ' || e.key === 'Enter')) return
|
||||
|
||||
const video = videoRef.current
|
||||
const key = e.key.toLowerCase()
|
||||
let handled = true
|
||||
|
||||
if (key === ' ' || key === 'k') togglePlay()
|
||||
else if (key === 'arrowleft') seekBy(-5)
|
||||
else if (key === 'arrowright') seekBy(5)
|
||||
else if (key === 'j') seekBy(-10)
|
||||
else if (key === 'l') seekBy(10)
|
||||
else if (key === 'arrowup') {
|
||||
setVolumeTo(video.volume + 0.1)
|
||||
flashHint(`Громкость ${Math.round(clamp(video.volume + 0.1, 0, 1) * 100)}%`)
|
||||
} else if (key === 'arrowdown') {
|
||||
setVolumeTo(video.volume - 0.1)
|
||||
flashHint(`Громкость ${Math.round(clamp(video.volume - 0.1, 0, 1) * 100)}%`)
|
||||
} else if (key === 'm') toggleMute()
|
||||
else if (key === 'f') toggleFullscreen()
|
||||
else if (/^[0-9]$/.test(key)) seekTo((Number(key) / 10) * duration)
|
||||
else handled = false
|
||||
|
||||
if (handled) {
|
||||
e.preventDefault()
|
||||
revealControls()
|
||||
}
|
||||
}
|
||||
|
||||
/* ----- derived ----- */
|
||||
|
||||
const shownTime = seekSlider.dragging ? scrubTime : currentTime
|
||||
const progress = duration ? (shownTime / duration) * 100 : 0
|
||||
const bufferedPct = duration ? (buffered / duration) * 100 : 0
|
||||
const controlsVisible = showControls || !isPlaying || speedOpen || seekSlider.dragging
|
||||
const effectiveVolume = isMuted ? 0 : volume
|
||||
const volIcon = isMuted ? ICONS.muted : effectiveVolume < 0.5 ? ICONS.volLow : ICONS.volHigh
|
||||
|
||||
const btn =
|
||||
'flex items-center justify-center rounded-full text-zinc-100 transition-colors hover:text-rose-400 ' +
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
onPointerMove={revealControls}
|
||||
onPointerLeave={() => isPlaying && !speedOpen && setShowControls(false)}
|
||||
className={`relative select-none overflow-hidden bg-black outline-none focus-visible:ring-2 focus-visible:ring-rose-500/60 ${
|
||||
isFullscreen
|
||||
? 'w-screen h-screen rounded-none'
|
||||
: 'w-full aspect-video max-h-[75vh] rounded-2xl shadow-2xl shadow-rose-950/40 ring-1 ring-white/10'
|
||||
} ${isPlaying && !controlsVisible ? 'cursor-none' : ''}`}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
poster={poster}
|
||||
preload="metadata"
|
||||
playsInline
|
||||
className="w-full h-full object-contain"
|
||||
onClick={togglePlay}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
/>
|
||||
|
||||
{/* transient hint: seek / volume / speed */}
|
||||
{hint && (
|
||||
<div
|
||||
key={hint.id}
|
||||
className="pointer-events-none absolute top-5 left-1/2 -translate-x-1/2 rounded-full bg-black/70 px-4 py-1.5 text-sm font-medium text-white backdrop-blur-md ring-1 ring-rose-500/40"
|
||||
>
|
||||
{hint.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* error */}
|
||||
{hasError && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-zinc-950 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-rose-500/15 text-xl font-bold text-rose-400">
|
||||
!
|
||||
</div>
|
||||
<p className="text-sm font-medium text-zinc-200">Не удалось загрузить видео</p>
|
||||
<p className="text-xs text-zinc-500">Проверьте ссылку или формат файла</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* buffering */}
|
||||
{isBuffering && !hasError && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<div className="h-14 w-14 animate-spin rounded-full border-[3px] border-rose-500/25 border-t-rose-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* big center button */}
|
||||
{!isPlaying && !isBuffering && !hasError && (
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
aria-label={ended ? 'Смотреть снова' : 'Смотреть'}
|
||||
className="absolute left-1/2 top-1/2 flex h-20 w-20 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-rose-500 text-white shadow-xl shadow-rose-500/40 ring-8 ring-rose-500/20 transition hover:scale-105 hover:bg-rose-400 focus:outline-none focus-visible:ring-rose-300"
|
||||
>
|
||||
<Icon d={ended ? ICONS.replay : ICONS.play} className={ended ? 'h-9 w-9' : 'h-9 w-9 translate-x-0.5'} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* controls */}
|
||||
<div
|
||||
className={`absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent px-4 pb-3 pt-14 transition-opacity duration-300 ${
|
||||
controlsVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
>
|
||||
{/* seek bar */}
|
||||
<div
|
||||
ref={seekSlider.ref}
|
||||
{...seekSlider.bind}
|
||||
onPointerMove={(e) => {
|
||||
seekSlider.bind.onPointerMove(e)
|
||||
setHoverRatio(seekSlider.ratioFrom(e))
|
||||
}}
|
||||
onPointerLeave={() => setHoverRatio(null)}
|
||||
role="slider"
|
||||
aria-label="Перемотка"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={Math.round(duration)}
|
||||
aria-valuenow={Math.round(shownTime)}
|
||||
aria-valuetext={`${formatTime(shownTime)} из ${formatTime(duration)}`}
|
||||
className="group relative flex h-5 cursor-pointer touch-none items-center"
|
||||
>
|
||||
{/* hover preview */}
|
||||
{hoverRatio !== null && duration > 0 && (
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-6 -translate-x-1/2 rounded-md bg-zinc-900/95 px-2 py-1 text-xs font-medium tabular-nums text-white ring-1 ring-white/10"
|
||||
style={{ left: `clamp(1.5rem, ${hoverRatio * 100}%, calc(100% - 1.5rem))` }}
|
||||
>
|
||||
{formatTime(hoverRatio * duration)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`relative w-full overflow-hidden rounded-full bg-white/20 transition-all ${
|
||||
seekSlider.dragging ? 'h-2' : 'h-1 group-hover:h-2'
|
||||
}`}
|
||||
>
|
||||
<div className="absolute inset-y-0 left-0 bg-white/25" style={{ width: `${bufferedPct}%` }} />
|
||||
{hoverRatio !== null && (
|
||||
<div className="absolute inset-y-0 left-0 bg-white/20" style={{ width: `${hoverRatio * 100}%` }} />
|
||||
)}
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-rose-600 to-rose-400"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`pointer-events-none absolute top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full bg-rose-400 shadow-[0_0_14px_3px_rgba(244,63,94,0.65)] ring-2 ring-white transition-transform ${
|
||||
seekSlider.dragging ? 'scale-110' : 'scale-0 group-hover:scale-100'
|
||||
}`}
|
||||
style={{ left: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* buttons row */}
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<button onClick={togglePlay} className={`${btn} h-10 w-10`} aria-label={isPlaying ? 'Пауза' : 'Играть'} title="Пауза / играть (пробел)">
|
||||
<Icon d={isPlaying ? ICONS.pause : ICONS.play} className="h-8 w-8" />
|
||||
</button>
|
||||
|
||||
<button onClick={() => seekBy(-10)} className={`${btn} h-10 w-10`} aria-label="Назад на 10 секунд" title="Назад на 10 с (J)">
|
||||
<SkipIcon />
|
||||
</button>
|
||||
<button onClick={() => seekBy(10)} className={`${btn} h-10 w-10`} aria-label="Вперёд на 10 секунд" title="Вперёд на 10 с (L)">
|
||||
<SkipIcon forward />
|
||||
</button>
|
||||
|
||||
{/* volume */}
|
||||
<div className="group/vol flex items-center">
|
||||
<button onClick={toggleMute} className={`${btn} h-10 w-10`} aria-label={isMuted ? 'Включить звук' : 'Выключить звук'} title="Звук (M)">
|
||||
<Icon d={volIcon} />
|
||||
</button>
|
||||
<div
|
||||
ref={volumeSlider.ref}
|
||||
{...volumeSlider.bind}
|
||||
role="slider"
|
||||
aria-label="Громкость"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(effectiveVolume * 100)}
|
||||
className={`relative hidden h-10 cursor-pointer touch-none items-center overflow-hidden transition-all duration-200 sm:flex ${
|
||||
volumeSlider.dragging ? 'ml-1 w-20' : 'w-0 group-hover/vol:ml-1 group-hover/vol:w-20'
|
||||
}`}
|
||||
>
|
||||
<div className="relative h-1 w-full rounded-full bg-white/20">
|
||||
<div className="absolute inset-y-0 left-0 rounded-full bg-rose-500" style={{ width: `${effectiveVolume * 100}%` }} />
|
||||
<div
|
||||
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow"
|
||||
style={{ left: `${effectiveVolume * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="ml-1 text-sm tabular-nums text-zinc-200">
|
||||
{formatTime(shownTime)}
|
||||
<span className="mx-1 text-zinc-500">/</span>
|
||||
<span className="text-zinc-400">{formatTime(duration)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
{/* speed */}
|
||||
<div ref={menuRef} className="relative">
|
||||
<button
|
||||
onClick={() => setSpeedOpen((v) => !v)}
|
||||
className={`${btn} h-10 min-w-[2.75rem] px-2 text-sm font-semibold tabular-nums ${rate !== 1 ? 'text-rose-400' : ''}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={speedOpen}
|
||||
aria-label="Скорость воспроизведения"
|
||||
>
|
||||
{rate}×
|
||||
</button>
|
||||
{speedOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute bottom-12 right-0 w-28 overflow-hidden rounded-xl bg-zinc-900/95 py-1 shadow-2xl ring-1 ring-white/10 backdrop-blur-md"
|
||||
>
|
||||
{SPEEDS.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
role="menuitemradio"
|
||||
aria-checked={r === rate}
|
||||
onClick={() => changeRate(r)}
|
||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-sm tabular-nums transition-colors hover:bg-white/10 focus:outline-none focus-visible:bg-white/10 ${
|
||||
r === rate ? 'font-semibold text-rose-400' : 'text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{r === 1 ? 'Обычная' : `${r}×`}
|
||||
{r === rate && <span className="h-1.5 w-1.5 rounded-full bg-rose-500" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button onClick={toggleFullscreen} className={`${btn} h-10 w-10`} aria-label={isFullscreen ? 'Выйти из полноэкранного режима' : 'На весь экран'} title="На весь экран (F)">
|
||||
<Icon d={isFullscreen ? ICONS.exitFs : ICONS.enterFs} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
src/index.css
Normal file
1
src/index.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import "tailwindcss";
|
||||
31
src/lib/format.js
Normal file
31
src/lib/format.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
export function filenameFromPath(path) {
|
||||
if (!path) return ''
|
||||
const clean = path.split('?')[0].split('#')[0]
|
||||
const last = clean.split('/').pop() || ''
|
||||
try {
|
||||
return decodeURIComponent(last)
|
||||
} catch {
|
||||
return last
|
||||
}
|
||||
}
|
||||
|
||||
export function stripExt(name) {
|
||||
if (!name) return ''
|
||||
const i = name.lastIndexOf('.')
|
||||
return i > 0 ? name.slice(0, i) : name
|
||||
}
|
||||
|
||||
export function videoTitle(v) {
|
||||
const t = v?.title?.trim()
|
||||
if (t) return t
|
||||
const src = v?.path ?? v?.preview_path ?? ''
|
||||
return stripExt(filenameFromPath(src))
|
||||
}
|
||||
|
||||
export function formatDate(d) {
|
||||
if (!d) return ''
|
||||
const dt = new Date(d)
|
||||
return isNaN(dt)
|
||||
? ''
|
||||
: dt.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
18
src/main.jsx
Normal file
18
src/main.jsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { router } from './router'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 1 } },
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
54
src/pages/Audio.jsx
Normal file
54
src/pages/Audio.jsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { useState } from "react";
|
||||
import { useAudio } from "../api/audio";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { assetUrl } from "../api/client";
|
||||
import Paginator from "../components/Paginator";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
|
||||
const PER_PAGE=24
|
||||
|
||||
export default function Audio() {
|
||||
const { id } = useParams()
|
||||
const [ page, setPage ] = useState(1)
|
||||
|
||||
const { data, isLoading, isError, error, isFetching} = useAudio({id, page, PER_PAGE})
|
||||
|
||||
if (isLoading) { <p>loading....</p> }
|
||||
if (isError) { <p> error: {error.message} </p> }
|
||||
|
||||
const navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
if (isError && error?.response?.status === 401) {
|
||||
navigate("/login", { replace: true })
|
||||
}
|
||||
}, [isError, error, navigate])
|
||||
|
||||
|
||||
const audios = data?.audios ?? []
|
||||
|
||||
if (audios.length === 0) {
|
||||
<p> empty </p>
|
||||
}
|
||||
|
||||
const hasNext = audios.length === PER_PAGE
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
|
||||
{audios.map((au) => (
|
||||
<Link key={au.id} to={`/audiofile/${au.id}`}>
|
||||
<div className="group rounded-lg overflow-hidden border border-zinc-800 hover:border-rose-500 transition flex flex-col">
|
||||
<img src={assetUrl(au.preview_path)} alt="" />
|
||||
<div className="meta">
|
||||
<p className="p-3">{au.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
<Paginator page={page} hasNext={hasNext} isFetching={isFetching} setPage={setPage}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
src/pages/AudioDetail.jsx
Normal file
52
src/pages/AudioDetail.jsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useAudioFile } from '../api/audio'
|
||||
import { assetUrl } from '../api/client'
|
||||
import AudioPlayer from '../components/AudioPlayer' // <--- импорт
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
|
||||
export default function AudioDetail() {
|
||||
const { id } = useParams()
|
||||
const { data, isLoading, isError, error } = useAudioFile({id})
|
||||
|
||||
const navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
if (isError && error?.response?.status === 401) {
|
||||
navigate("/login", { replace: true })
|
||||
}
|
||||
}, [isError, error, navigate])
|
||||
|
||||
|
||||
if (isLoading) return <p className="text-slate-400">Загрузка…</p>
|
||||
if (isError) return <p className="text-red-400">Ошибка: {error.message}</p>
|
||||
if (!data) return <p className="text-slate-400">Аудио не найдено</p>
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6 space-y-6">
|
||||
<Link to="/audioplaylists" className="text-2xl text-white hover:bg-rose-600 bg-rose-500 rounded-2xl p-4 m-4">
|
||||
back
|
||||
</Link>
|
||||
|
||||
{/* Большая обложка и инфо */}
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<img
|
||||
src={assetUrl(data.preview_path)}
|
||||
alt={data.name}
|
||||
className="w-64 h-64 object-cover rounded-xl shadow-2xl"
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">{data.name}</h1>
|
||||
<p className="text-zinc-400">{data.filename}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Наш кастомный плеер */}
|
||||
<AudioPlayer
|
||||
src={data.path}
|
||||
title={data.name}
|
||||
cover={data.preview_path}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
src/pages/AudioPlaylists.jsx
Normal file
39
src/pages/AudioPlaylists.jsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { usePlaylists } from "../api/audio";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
|
||||
export default function AudioPlaylists() {
|
||||
|
||||
const { data, isLoading, isError, error, isFetching } = usePlaylists({})
|
||||
|
||||
const navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
if (isError && error?.response?.status === 401) {
|
||||
navigate("/login", { replace: true })
|
||||
}
|
||||
}, [isError, error, navigate])
|
||||
|
||||
|
||||
if (isLoading) return <p className="text-slate-400">Загрузка…</p>
|
||||
if (isError) return <p className="text-red-400">Ошибка: {error.message}</p>
|
||||
|
||||
|
||||
const playlists = data?.playlists ?? []
|
||||
|
||||
if (playlists.length === 0) return <p className="text-slate-400">empty</p>
|
||||
|
||||
return (
|
||||
playlists.map((pl) => (
|
||||
<Link key={pl.id} to={`/audio/${pl.id}`} >
|
||||
<div className="channel mt-4 bg-zinc-950 border-2 border-zinc-800 rounded-xl p-3">
|
||||
<h3 className="text-white text-3xl">{pl.title}</h3>
|
||||
<p>videos: {pl.audios_count}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
38
src/pages/Categories.jsx
Normal file
38
src/pages/Categories.jsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { useCategories } from "../api/photo";
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
|
||||
export default function Categories() {
|
||||
|
||||
|
||||
const { data, isLoading, isError, error, isFetching } = useCategories({})
|
||||
|
||||
|
||||
const navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
if (isError && error?.response?.status === 401) {
|
||||
navigate("/login", { replace: true })
|
||||
}
|
||||
}, [isError, error, navigate])
|
||||
|
||||
if (isLoading) return <p className="text-slate-400">Загрузка…</p>
|
||||
if (isError) return <p className="text-red-400">Ошибка: {error.message}</p>
|
||||
|
||||
const categories = data?.categories ?? []
|
||||
|
||||
if (categories.length === 0) return <p className="text-slate-400">Каналов нет.</p>
|
||||
|
||||
return (
|
||||
data.categories.map((ct) => (
|
||||
<Link key={ct.id} to={`/category/${ct.id}`} >
|
||||
<div className="ct mt-4 bg-zinc-950 border-2 border-zinc-800 rounded-xl p-3">
|
||||
<h3 className="text-white text-3xl">{ct.title}</h3>
|
||||
<p>videos: {ct.photos_count}</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
39
src/pages/Category.jsx
Normal file
39
src/pages/Category.jsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { useParams } from "react-router-dom";
|
||||
import { useCategory } from "../api/photo";
|
||||
import { useState } from "react";
|
||||
import PhotoList from "../components/PhotoList";
|
||||
import Paginator from "../components/Paginator";
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
|
||||
const PER_PAGE = 24
|
||||
|
||||
export default function Category({}) {
|
||||
const [page, setPage] = useState(1)
|
||||
const { id } = useParams()
|
||||
|
||||
const {data, isLoading, isError, error, isFetching} = useCategory({
|
||||
id, page, PER_PAGE
|
||||
})
|
||||
|
||||
if (isLoading) return <p className="text-slate-400">Загрузка…</p>
|
||||
if (isError) return <p className="text-red-400">Ошибка: {error.message}</p>
|
||||
|
||||
const navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
if (isError && error?.response?.status === 401) {
|
||||
navigate("/login", { replace: true })
|
||||
}
|
||||
}, [isError, error, navigate])
|
||||
|
||||
|
||||
const hasNext = data.photos.length === PER_PAGE
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PhotoList data={data} />
|
||||
<Paginator page={page} hasNext={hasNext} isFetching={isFetching} setPage={setPage}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
85
src/pages/Channel.jsx
Normal file
85
src/pages/Channel.jsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useChannel } from '../api/channels'
|
||||
import { assetUrl } from '../api/client'
|
||||
import VideoList from '../components/VideoList'
|
||||
import Paginator from '../components/Paginator'
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
|
||||
const PER_PAGE = 24
|
||||
|
||||
export default function Channel() {
|
||||
const [page, setPage] = useState(1)
|
||||
const { id } = useParams()
|
||||
|
||||
const { data, isLoading, isError, error, isFetching } = useChannel({id, page, PER_PAGE})
|
||||
|
||||
const navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
if (isError && error?.response?.status === 401) {
|
||||
navigate("/login", { replace: true })
|
||||
}
|
||||
}, [isError, error, navigate])
|
||||
|
||||
if (isLoading) return <p className="text-slate-400">Загрузка…</p>
|
||||
if (isError) return <p className="text-red-400">Ошибка: {error.message}</p>
|
||||
|
||||
const videos = data?.videos ?? []
|
||||
const playlists = data?.playlists ?? []
|
||||
|
||||
if (videos.length === 0 && playlists.length === 0) {
|
||||
return <p className="text-slate-400">Пусто.</p>
|
||||
}
|
||||
|
||||
const hasNext = videos.length === PER_PAGE
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<h2 className="p-5 text-2xl text-white">
|
||||
{data.videos_count} видео, {data.channel_name}
|
||||
</h2>
|
||||
|
||||
{playlists.length > 0 && (
|
||||
<div className="px-5">
|
||||
<h3 className="text-xl font-semibold text-white mb-4">Плейлисты</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{playlists.map((pl) => (
|
||||
<Link
|
||||
key={pl.id}
|
||||
to={`/playlist/${pl.id}`}
|
||||
className="group rounded-lg overflow-hidden border border-zinc-900 hover:border-rose-500 transition bg-zinc-950 flex flex-col"
|
||||
>
|
||||
{pl.preview_path ? (
|
||||
<img
|
||||
src={assetUrl(pl.preview_path)}
|
||||
alt={pl.name}
|
||||
className="w-full h-32 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-32 bg-zinc-900 flex items-center justify-center text-zinc-500 text-sm">
|
||||
Нет превью
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3">
|
||||
<h3 className="text-white font-medium line-clamp-2 bg-zinc-950">
|
||||
{pl.name || `Плейлист #${pl.id}`}
|
||||
</h3>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-5">
|
||||
<VideoList data={data} />
|
||||
<div className="mt-6">
|
||||
<Paginator page={page} hasNext={hasNext} isFetching={isFetching} setPage={setPage}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
src/pages/Channels.jsx
Normal file
62
src/pages/Channels.jsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { useChannels } from "../api/channels";
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
|
||||
export default function Channels() {
|
||||
|
||||
const { data, isLoading, isError, error, isFetching } = useChannels({})
|
||||
|
||||
const navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
if (isError && error?.response?.status === 401) {
|
||||
navigate("/login", { replace: true })
|
||||
}
|
||||
}, [isError, error, navigate])
|
||||
|
||||
if (isLoading) return <p className="text-zinc-400">Загрузка…</p>
|
||||
if (isError) return (
|
||||
<p className="rounded-xl border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-rose-300">
|
||||
Ошибка: {error.message}
|
||||
</p>
|
||||
)
|
||||
|
||||
const channels = data?.channels ?? []
|
||||
|
||||
if (channels.length === 0) return (
|
||||
<p className="rounded-2xl border border-dashed border-white/10 px-6 py-12 text-center text-zinc-400">
|
||||
Каналов нет.
|
||||
</p>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.channels.map((ch) => (
|
||||
<Link
|
||||
key={ch.id}
|
||||
to={`/channel/${ch.id}`}
|
||||
className="group block rounded-2xl focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400"
|
||||
>
|
||||
<div className="channel flex items-center gap-4 rounded-2xl border border-white/10 bg-zinc-900/60 p-4 transition duration-300 group-hover:-translate-y-1 group-hover:border-rose-500/50 group-hover:shadow-xl group-hover:shadow-rose-500/10">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-rose-500 to-rose-700 text-2xl font-bold text-white shadow-lg shadow-rose-500/25 ring-4 ring-rose-500/15">
|
||||
{ch.name?.trim()?.[0]?.toUpperCase() ?? '?'}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-lg font-semibold text-white transition-colors group-hover:text-rose-200">{ch.name}</h3>
|
||||
<p className="mt-1 text-sm tabular-nums text-zinc-400">videos: {ch.videos_count}</p>
|
||||
</div>
|
||||
<svg
|
||||
className="h-5 w-5 shrink-0 -translate-x-1 text-zinc-600 opacity-0 transition duration-300 group-hover:translate-x-0 group-hover:text-rose-400 group-hover:opacity-100"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6z" />
|
||||
</svg>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
src/pages/Login.jsx
Normal file
68
src/pages/Login.jsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { api } from "../api/client"
|
||||
import { useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
|
||||
export default function LoginPage() {
|
||||
const [error, setError] = useState(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const payload = {
|
||||
username: formData.get("username"),
|
||||
password: formData.get("password"),
|
||||
password_confirm: formData.get("password_confirm")
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.post("http://127.0.0.1:8000/auth/login", payload)
|
||||
navigate("/channel/46")
|
||||
}
|
||||
catch (err) {
|
||||
const message =
|
||||
err.response?.data?.detail || // Django/DRF
|
||||
err.response?.data?.message ||
|
||||
err.response?.data?.non_field_errors?.[0] ||
|
||||
err.message ||
|
||||
"Что-то пошло не так"
|
||||
setError(message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-white flex justify-center px-4">
|
||||
<div className="w-full max-w-lg">
|
||||
<h1 className="text-2xl sm:text-3xl text-white mt-2 sm:mt-12">
|
||||
Login
|
||||
</h1>
|
||||
<form onSubmit={handleSubmit} className="bg-zinc-950 flex flex-col">
|
||||
<input
|
||||
className="focus:outline-none text-white placeholder:text-white border-2 border-zinc-800 rounded-2xl h-12 p-3 w-full mt-6"
|
||||
type="text"
|
||||
placeholder="username"
|
||||
name="username"
|
||||
/>
|
||||
<input
|
||||
className="focus:outline-none ring-0 text-white placeholder:text-white border-2 border-zinc-800 rounded-2xl h-12 p-3 w-full mt-6"
|
||||
type="password"
|
||||
placeholder="password"
|
||||
name="password"
|
||||
/> { error ?
|
||||
<p className="mt-4 text-rose-400 text-sm bg-rose-950/40 border border-rose-900 rounded-xl px-4 py-2">
|
||||
{error}
|
||||
</p>
|
||||
: ""}
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-rose-600 hover:bg-rose-700 transition-colors rounded-2xl mt-8 text-white p-4 text-xl sm:text-2xl font-bold w-full"
|
||||
>
|
||||
login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
src/pages/NotFound.jsx
Normal file
11
src/pages/NotFound.jsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="p-6 text-center">
|
||||
<h1 className="text-3xl font-bold mb-2">Ошибка</h1>
|
||||
<p className="text-slate-400 mb-4">Страница не найдена или произошла ошибка.</p>
|
||||
<Link to="/" className="text-indigo-400 hover:underline">На главную</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
src/pages/Photos.jsx
Normal file
41
src/pages/Photos.jsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { useState } from 'react'
|
||||
import Paginator from '../components/Paginator'
|
||||
import { usePhotos } from '../api/photo'
|
||||
import PhotoList from '../components/PhotoList'
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const category = null
|
||||
const PER_PAGE = 24
|
||||
|
||||
export default function Photos() {
|
||||
const [page, setPage] = useState(1)
|
||||
const { data, isLoading, isError, error, isFetching } = usePhotos({
|
||||
page,
|
||||
perPage: PER_PAGE,
|
||||
category,
|
||||
})
|
||||
|
||||
const navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
if (isError && error?.response?.status === 401) {
|
||||
navigate("/login", { replace: true })
|
||||
}
|
||||
}, [isError, error, navigate])
|
||||
|
||||
if (isLoading) return <p className="text-slate-400">Загрузка…</p>
|
||||
if (isError) return <p className="text-red-400">Ошибка: {error.message}</p>
|
||||
|
||||
|
||||
const hasNext = data.photos.length === PER_PAGE
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold">all photos</h1>
|
||||
|
||||
<PhotoList data={data}/>
|
||||
|
||||
<Paginator page={page} hasNext={hasNext} isFetching={isFetching} setPage={setPage}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
src/pages/Playlist.jsx
Normal file
49
src/pages/Playlist.jsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import VideoList from '../components/VideoList'
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
export default function Playlist() {
|
||||
const { id } = useParams()
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['playlist', id],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/playlist', { params: { id } })
|
||||
return data
|
||||
},
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
const navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
if (isError && error?.response?.status === 401) {
|
||||
navigate("/login", { replace: true })
|
||||
}
|
||||
}, [isError, error, navigate])
|
||||
|
||||
if (isLoading) return <p className="text-slate-400">Загрузка…</p>
|
||||
if (isError) return <p className="text-red-400">Ошибка: {error.message}</p>
|
||||
|
||||
const videos = data?.videos ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link to="/channels" className="text-sm text-rose-400 hover:text-rose-300">
|
||||
← Назад к каналам
|
||||
</Link>
|
||||
|
||||
<h1 className="text-2xl font-semibold text-white">
|
||||
Плейлист (видео: {data?.video_count ?? 0})
|
||||
</h1>
|
||||
|
||||
{videos.length === 0 ? (
|
||||
<p className="text-slate-400">В этом плейлисте пока нет видео.</p>
|
||||
) : (
|
||||
<VideoList data={data} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
src/pages/Register.jsx
Normal file
30
src/pages/Register.jsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { api } from "../api/client"
|
||||
|
||||
export default function Register() {
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
const form = new FormData(e.currentTarget);
|
||||
const payload = {
|
||||
username: form.get("username"),
|
||||
password: form.get("password"),
|
||||
password_confirm: form.get("password_confirm"),
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await api.post("http://127.0.0.1:8000/auth/register", payload)
|
||||
console.log("200")
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input type="text" name="username" placeholder="username"/>
|
||||
<input type="password" name="password" placeholder="password"/>
|
||||
<input type="password" name="password_confirm" placeholder="confirm password"/>
|
||||
<button type="submit">register</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
0
src/pages/Search.jsx
Normal file
0
src/pages/Search.jsx
Normal file
52
src/pages/VideoDetail.jsx
Normal file
52
src/pages/VideoDetail.jsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useVideo } from '../api/videos'
|
||||
import { assetUrl } from '../api/client'
|
||||
import { videoTitle, formatDate } from '../lib/format'
|
||||
import VideoPlayer from '../components/VideoPlayer' // <--- импорт плеера
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
export default function VideoDetail() {
|
||||
const { id } = useParams()
|
||||
const qc = useQueryClient()
|
||||
const { data, isLoading, isError, error } = useVideo(Number(id))
|
||||
|
||||
const navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
if (isError && error?.response?.status === 401) {
|
||||
navigate("/login", { replace: true })
|
||||
}
|
||||
}, [isError, error, navigate])
|
||||
|
||||
const cached = qc
|
||||
.getQueriesData({ queryKey: ['videos'] })
|
||||
.flatMap(([, d]) => d?.videos ?? [])
|
||||
.find((v) => v.id === Number(id))
|
||||
|
||||
if (isLoading) return <p className="text-slate-400">Загрузка…</p>
|
||||
if (isError) return <p className="text-red-400">Ошибка: {error.message}</p>
|
||||
|
||||
const merged = { ...(cached ?? {}), ...data }
|
||||
const poster = merged.preview_path ? assetUrl(merged.preview_path) : undefined
|
||||
const title = videoTitle(merged) || `Видео #${id}`
|
||||
const date = formatDate(merged.date)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* Вот тут теперь твой новый плеер */}
|
||||
<div className="mt-8">
|
||||
<VideoPlayer src={assetUrl(data.path)} poster={poster} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Link to="/" className="w-22 text-white bg-rose-500 p-4 rounded-2xl m-4 text-sm hover:bg-rose-600 transition">
|
||||
video list
|
||||
</Link>
|
||||
<h1 className="text-xl font-semibold text-white">{title}</h1>
|
||||
{date && <p className="text-sm text-slate-400 ml-4">{date}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
src/pages/Videos.jsx
Normal file
39
src/pages/Videos.jsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { useState } from 'react'
|
||||
import { useVideos } from '../api/videos'
|
||||
import VideoList from '../components/VideoList'
|
||||
import Paginator from '../components/Paginator'
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
|
||||
const PER_PAGE = 24
|
||||
|
||||
export default function Videos() {
|
||||
const [page, setPage] = useState(1)
|
||||
const { data, isLoading, isError, error, isFetching } = useVideos({
|
||||
page,
|
||||
perPage: PER_PAGE,
|
||||
})
|
||||
|
||||
const navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
if (isError && error?.response?.status === 401) {
|
||||
navigate("/login", { replace: true })
|
||||
}
|
||||
}, [isError, error, navigate])
|
||||
|
||||
if (isLoading) return <p className="text-slate-400">Загрузка…</p>
|
||||
if (isError) return <p className="text-red-400">Ошибка: {error.message}</p>
|
||||
|
||||
const hasNext = data.videos.length === PER_PAGE
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold">all videos</h1>
|
||||
|
||||
<VideoList data={data} needChannelName={true}/>
|
||||
|
||||
<Paginator page={page} hasNext={hasNext} isFetching={isFetching} setPage={setPage}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
src/router.jsx
Normal file
42
src/router.jsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { createBrowserRouter } from 'react-router-dom'
|
||||
import Layout from './components/Layout'
|
||||
import Videos from './pages/Videos'
|
||||
import Channels from './pages/Channels'
|
||||
import VideoDetail from './pages/VideoDetail'
|
||||
import NotFound from './pages/NotFound'
|
||||
import Channel from './pages/Channel'
|
||||
import Photos from './pages/Photos'
|
||||
import Categories from './pages/Categories'
|
||||
import Category from './pages/Category'
|
||||
import Playlist from './pages/Playlist'
|
||||
import Audio from './pages/Audio'
|
||||
import AudioPlaylists from "./pages/AudioPlaylists"
|
||||
import AudioDetail from './pages/AudioDetail'
|
||||
import LoginPage from './pages/Login'
|
||||
import Register from './pages/Register'
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <Layout />,
|
||||
errorElement: <NotFound />,
|
||||
children: [
|
||||
{ path: "", index: true, element: <Videos /> },
|
||||
{ path: 'video/:id', element: <VideoDetail /> },
|
||||
{ path: 'channels', element: <Channels /> },
|
||||
{ path: 'channel/:id', element: <Channel />},
|
||||
{ path: 'photos', element: <Photos />},
|
||||
{ path: 'categories', element: <Categories />},
|
||||
{ path: 'category/:id', element: <Category />},
|
||||
{ path: 'playlist/:id', element: <Playlist /> },
|
||||
{ path: 'audio/:id', element: <Audio />},
|
||||
{ path: 'audioplaylists', element: <AudioPlaylists />},
|
||||
{ path: 'audiofile/:id', element: <AudioDetail />},
|
||||
// { path: 'register', element: <Register /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
element: <LoginPage />
|
||||
}
|
||||
])
|
||||
8
vite.config.js
Normal file
8
vite.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user