Redesign responsive MyTube interface

This commit is contained in:
vrubelroman 2026-09-16 21:40:32 +00:00
parent 90eb012e0d
commit c4a772b1e3
23 changed files with 787 additions and 1119 deletions

View file

@ -1,158 +1,45 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import {
createCategory,
deleteCategory,
listCategories,
listChannels,
reorderCategories,
updateCategory,
} from '../api/client'
import { createCategory, deleteCategory, listCategories, reorderCategories, updateCategory } from '../api/client'
import Icon from '../components/Icon'
function Categories() {
const queryClient = useQueryClient()
const [newName, setNewName] = useState('')
const [editingId, setEditingId] = useState<number | null>(null)
const [editingName, setEditingName] = useState('')
const [deletingId, setDeletingId] = useState<number | null>(null)
const cancelDeleteRef = useRef<HTMLButtonElement>(null)
useEffect(() => { if (deletingId !== null) cancelDeleteRef.current?.focus() }, [deletingId])
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
const uncategorizedQuery = useQuery({
queryKey: ['channels', 'uncategorized-count'],
queryFn: () => listChannels({ subscribed: true, uncategorized: true }),
})
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['categories'] })
queryClient.invalidateQueries({ queryKey: ['channels'] })
}
const createMutation = useMutation({
mutationFn: (name: string) => createCategory(name),
onSuccess: () => {
setNewName('')
invalidate()
},
})
const updateMutation = useMutation({
mutationFn: ({ id, name }: { id: number; name: string }) => updateCategory(id, name),
onSuccess: () => {
setEditingId(null)
invalidate()
},
})
const deleteMutation = useMutation({
mutationFn: (id: number) => deleteCategory(id),
onSuccess: invalidate,
})
const reorderMutation = useMutation({
mutationFn: (ids: number[]) => reorderCategories(ids),
onSuccess: invalidate,
})
const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['categories'] }); queryClient.invalidateQueries({ queryKey: ['channels'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }) }
const createMutation = useMutation({ mutationFn: createCategory, onSuccess: () => { setNewName(''); invalidate() } })
const updateMutation = useMutation({ mutationFn: ({ id, name }: { id: number; name: string }) => updateCategory(id, name), onSuccess: () => { setEditingId(null); invalidate() } })
const deleteMutation = useMutation({ mutationFn: deleteCategory, onSuccess: () => { setDeletingId(null); invalidate() } })
const reorderMutation = useMutation({ mutationFn: reorderCategories, onSuccess: invalidate })
const categories = categoriesQuery.data ?? []
const deleting = categories.find((category) => category.id === deletingId)
function move(index: number, direction: -1 | 1) {
const target = index + direction
if (target < 0 || target >= categories.length) return
const ids = categories.map((c) => c.id)
const ids = categories.map((category) => category.id)
;[ids[index], ids[target]] = [ids[target], ids[index]]
reorderMutation.mutate(ids)
}
return (
<div className="categories-screen">
<Link to="/" className="back-link">
← Каналы
</Link>
<h1>Категории</h1>
<form
onSubmit={(e) => {
e.preventDefault()
if (newName.trim()) createMutation.mutate(newName.trim())
}}
className="category-form"
>
<input
type="text"
placeholder="Новая категория"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<button type="submit" disabled={!newName.trim() || createMutation.isPending}>
Создать
</button>
</form>
{createMutation.isError && <p className="error">{(createMutation.error as Error).message}</p>}
<ul className="category-list">
<li className="category-row category-row-virtual">
<div className="reorder-buttons" />
<span className="category-name">Без категории</span>
<span className="category-count">
{uncategorizedQuery.isLoading ? '…' : `${uncategorizedQuery.data?.length ?? 0} канал(ов)`}
</span>
</li>
{categories.map((category, index) => (
<li key={category.id} className="category-row">
<div className="reorder-buttons">
<button onClick={() => move(index, -1)} disabled={index === 0} aria-label="Выше">
↑
</button>
<button onClick={() => move(index, 1)} disabled={index === categories.length - 1} aria-label="Ниже">
↓
</button>
</div>
{editingId === category.id ? (
<form
className="category-edit-form"
onSubmit={(e) => {
e.preventDefault()
if (editingName.trim()) updateMutation.mutate({ id: category.id, name: editingName.trim() })
}}
>
<input value={editingName} onChange={(e) => setEditingName(e.target.value)} autoFocus />
<button type="submit">Сохранить</button>
<button type="button" onClick={() => setEditingId(null)}>
Отмена
</button>
</form>
) : (
<>
<span className="category-name">{category.name}</span>
<span className="category-count">{category.channel_count} канал(ов)</span>
<button
onClick={() => {
setEditingId(category.id)
setEditingName(category.name)
}}
>
Переименовать
</button>
<button
onClick={() => {
if (confirm(`Удалить категорию "${category.name}"? Каналы останутся.`)) {
deleteMutation.mutate(category.id)
}
}}
>
Удалить
</button>
</>
)}
</li>
))}
</ul>
{categories.length === 0 && !categoriesQuery.isLoading && <p>Категорий пока нет.</p>}
</div>
)
return <section className="page categories-screen">
<Link to="/settings" className="back-link"><Icon name="arrow" size={17} /> Настройки</Link>
<div className="page-heading"><div><p className="eyebrow">Моя лента</p><h1>Категории</h1><p className="page-subtitle">Создавай темы и меняй порядок в меню.</p></div></div>
<form className="category-form" onSubmit={(event) => { event.preventDefault(); if (newName.trim()) createMutation.mutate(newName.trim()) }}><label className="sr-only" htmlFor="category-name">Название новой категории</label><input id="category-name" placeholder="Название новой категории" value={newName} onChange={(event) => setNewName(event.target.value)} /><button type="submit" className="button-primary" disabled={!newName.trim() || createMutation.isPending}><Icon name="plus" size={17} /> Создать</button></form>
{createMutation.isError && <p className="error" role="alert">Не удалось создать категорию. Возможно, такое имя уже есть.</p>}
{reorderMutation.isError && <p className="error" role="alert">Не удалось изменить порядок категорий.</p>}
{categoriesQuery.isLoading && <div className="channel-loading">{Array.from({ length: 3 }, (_, index) => <div key={index} className="channel-skeleton"><span /><i /></div>)}</div>}
{categoriesQuery.isError && <div className="empty-state" role="alert"><h2>Не удалось загрузить категории</h2><button className="button-primary" onClick={() => categoriesQuery.refetch()}>Повторить</button></div>}
{categories.length > 0 && <ul className="category-list">{categories.map((category, index) => <li key={category.id} className="category-row"><div className="reorder-buttons"><button type="button" className="icon-button" onClick={() => move(index, -1)} disabled={index === 0 || reorderMutation.isPending} aria-label={`Поднять ${category.name}`}><Icon name="up" size={18} /></button><button type="button" className="icon-button" onClick={() => move(index, 1)} disabled={index === categories.length - 1 || reorderMutation.isPending} aria-label={`Опустить ${category.name}`}><Icon name="down" size={18} /></button></div>{editingId === category.id ? <form className="category-edit-form" onSubmit={(event) => { event.preventDefault(); if (editingName.trim()) updateMutation.mutate({ id: category.id, name: editingName.trim() }) }}><input aria-label="Новое название категории" value={editingName} onChange={(event) => setEditingName(event.target.value)} autoFocus /><button className="button-primary" type="submit" disabled={!editingName.trim() || updateMutation.isPending}>Сохранить</button><button className="button-quiet" type="button" onClick={() => setEditingId(null)}>Отмена</button></form> : <><div className="category-row-label"><span className="category-dot" /><span className="category-name">{category.name}</span><span className="category-count">{category.channel_count} каналов</span></div><div className="category-row-actions"><button type="button" className="icon-button" aria-label={`Переименовать ${category.name}`} title="Переименовать" onClick={() => { setEditingId(category.id); setEditingName(category.name) }}><Icon name="edit" size={18} /></button><button type="button" className="icon-button danger-hover" aria-label={`Удалить ${category.name}`} title="Удалить" onClick={() => setDeletingId(category.id)}><Icon name="trash" size={18} /></button></div></>}</li>)}</ul>}
{updateMutation.isError && <p className="error" role="alert">Не удалось переименовать категорию.</p>}
{!categoriesQuery.isLoading && !categoriesQuery.isError && categories.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name="folder" size={28} /></span><h2>Категорий пока нет</h2><p>Создай первую тему и добавь в неё каналы.</p></div>}
{deleting && <div className="modal-backdrop" onClick={() => setDeletingId(null)} onKeyDown={(event) => { if (event.key === 'Escape') setDeletingId(null) }}><div className="confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="delete-category-title" aria-describedby="delete-category-description" onClick={(event) => event.stopPropagation()}><h2 id="delete-category-title">Удалить «{deleting.name}»?</h2><p id="delete-category-description">Каналы останутся в подписках. Они просто больше не будут относиться к этой категории.</p>{deleteMutation.isError && <p className="error" role="alert">Не удалось удалить категорию.</p>}<div className="dialog-actions"><button ref={cancelDeleteRef} className="button-secondary" onClick={() => setDeletingId(null)}>Отмена</button><button className="button-danger" onClick={() => deleteMutation.mutate(deleting.id)} disabled={deleteMutation.isPending}>Удалить категорию</button></div></div></div>}
</section>
}
export default Categories