Redesign responsive MyTube interface
This commit is contained in:
parent
90eb012e0d
commit
c4a772b1e3
23 changed files with 787 additions and 1119 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue