first commit

This commit is contained in:
ndmg 2026-11-07 11:48:00 +00:00
commit bb3aeea99b
39 changed files with 5711 additions and 0 deletions

25
.gitignore vendored Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View File

12
src/api/search.js Normal file
View 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
View 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,
})
}

View 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
View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}
}

View 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
View File

@ -0,0 +1 @@
@import "tailwindcss";

31
src/lib/format.js Normal file
View 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
View 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
View 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
View 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>
)
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

52
src/pages/VideoDetail.jsx Normal file
View 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
View 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
View 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
View 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()],
})