feat: added alternative queue display
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
---
|
||||
description: Новые React-компоненты — папка kebab-case, TSX + CSS module, index.ts (furumi-node-player/client/src)
|
||||
globs: furumi-node-player/client/src/**/*
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Структура новых компонентов (furumi-node-player/client)
|
||||
|
||||
**Область:** при добавлении **нового** компонента в `furumi-node-player/client/src/` используйте расположение и файлы ниже.
|
||||
|
||||
## Расположение
|
||||
|
||||
- Базовая папка: `furumi-node-player/client/src/components/`
|
||||
- Для каждого компонента — **отдельная подпапка** с именем в **kebab-case** (например `play-button`, `search-dropdown`).
|
||||
|
||||
## Файлы внутри папки компонента
|
||||
|
||||
1. **Компонент:** `components/<имя-kebab>/<имя-kebab>.tsx` — реализация компонента; к нему подключён CSS-модуль (`import styles from './<имя-kebab>.module.css'`).
|
||||
2. **Стили:** `components/<имя-kebab>/<имя-kebab>.module.css` — модульные стили для этого компонента.
|
||||
3. **Баррель:** `components/<имя-kebab>/index.ts` — **реэкспорт всего** из файла компонента: `export * from './<имя-kebab>'`.
|
||||
|
||||
Импорт снаружи: `import { MyWidget } from './components/my-widget'` (относительный путь к папке), без указания `index.ts`.
|
||||
|
||||
## Пример (`my-widget`)
|
||||
|
||||
```
|
||||
components/my-widget/
|
||||
my-widget.tsx
|
||||
my-widget.module.css
|
||||
index.ts
|
||||
```
|
||||
|
||||
```typescript
|
||||
// my-widget.tsx
|
||||
import styles from './my-widget.module.css'
|
||||
|
||||
export function MyWidget() {
|
||||
return <div className={styles.root}>…</div>
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// index.ts
|
||||
export * from './my-widget'
|
||||
```
|
||||
|
||||
## Примечание
|
||||
|
||||
Существующие файлы-компоненты в корне `components/` (один файл без папки) — наследие; **новые** компоненты добавляйте только по структуре выше.
|
||||
@@ -563,7 +563,20 @@ export function FurumiPlayer() {
|
||||
}
|
||||
/>
|
||||
|
||||
<PlayerBar track={nowPlayingTrack} />
|
||||
<PlayerBar
|
||||
track={nowPlayingTrack}
|
||||
queue={queueItemsView}
|
||||
order={queueOrderView}
|
||||
playingOrigIdx={queuePlayingOrigIdxView}
|
||||
scrollSignal={queueScrollSignal}
|
||||
onQueuePlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
|
||||
onQueueRemove={(origIdx) =>
|
||||
queueActionsRef.current?.removeFromQueue(origIdx)
|
||||
}
|
||||
onQueueMove={(fromPos, toPos) =>
|
||||
queueActionsRef.current?.moveQueueItem(fromPos, toPos)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="toast" id="toast" />
|
||||
<audio ref={audioRef} />
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
import { NowPlaying } from './NowPlaying'
|
||||
import { QueuePopover } from './queue-popover'
|
||||
import type { QueueItem } from './QueueList'
|
||||
|
||||
export function PlayerBar({ track }: { track: QueueItem | null }) {
|
||||
type PlayerBarProps = {
|
||||
track: QueueItem | null
|
||||
queue: QueueItem[]
|
||||
order: number[]
|
||||
playingOrigIdx: number
|
||||
scrollSignal: number
|
||||
onQueuePlay: (origIdx: number) => void
|
||||
onQueueRemove: (origIdx: number) => void
|
||||
onQueueMove: (fromPos: number, toPos: number) => void
|
||||
}
|
||||
|
||||
export function PlayerBar({
|
||||
track,
|
||||
queue,
|
||||
order,
|
||||
playingOrigIdx,
|
||||
scrollSignal,
|
||||
onQueuePlay,
|
||||
onQueueRemove,
|
||||
onQueueMove,
|
||||
}: PlayerBarProps) {
|
||||
return (
|
||||
<div className="player-bar">
|
||||
<NowPlaying track={track} />
|
||||
@@ -30,6 +51,15 @@ export function PlayerBar({ track }: { track: QueueItem | null }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="volume-row">
|
||||
<QueuePopover
|
||||
queue={queue}
|
||||
order={order}
|
||||
playingOrigIdx={playingOrigIdx}
|
||||
scrollSignal={scrollSignal}
|
||||
onPlay={onQueuePlay}
|
||||
onRemove={onQueueRemove}
|
||||
onMove={onQueueMove}
|
||||
/>
|
||||
<span className="vol-icon" id="volIcon">
|
||||
🔊
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './queue-popover'
|
||||
@@ -0,0 +1,68 @@
|
||||
.root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.35rem;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.triggerIcon {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 0.5rem);
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: min(100vw - 2rem, 320px);
|
||||
max-width: min(100vw - 2rem, 360px);
|
||||
max-height: min(50vh, 360px);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-shrink: 0;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.body :global(.queue-empty) {
|
||||
padding: 1.25rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { QueueList, type QueueItem } from '../QueueList'
|
||||
import styles from './queue-popover.module.css'
|
||||
|
||||
export type QueuePopoverProps = {
|
||||
queue: QueueItem[]
|
||||
order: number[]
|
||||
playingOrigIdx: number
|
||||
scrollSignal: number
|
||||
onPlay: (origIdx: number) => void
|
||||
onRemove: (origIdx: number) => void
|
||||
onMove: (fromPos: number, toPos: number) => void
|
||||
}
|
||||
|
||||
export function QueuePopover({
|
||||
queue,
|
||||
order,
|
||||
playingOrigIdx,
|
||||
scrollSignal,
|
||||
onPlay,
|
||||
onRemove,
|
||||
onMove,
|
||||
}: QueuePopoverProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const titleId = useId()
|
||||
const panelId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onDocMouseDown(e: MouseEvent) {
|
||||
const el = rootRef.current
|
||||
if (el && !el.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDocMouseDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocMouseDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className={styles.root} ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
title="Playback queue"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-controls={open ? panelId : undefined}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<span className={styles.triggerIcon} aria-hidden>
|
||||
☰
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
id={panelId}
|
||||
className={styles.popover}
|
||||
role="dialog"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<div className={styles.header} id={titleId}>
|
||||
Queue
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
<QueueList
|
||||
queue={queue}
|
||||
order={order}
|
||||
playingOrigIdx={playingOrigIdx}
|
||||
scrollSignal={scrollSignal}
|
||||
onPlay={onPlay}
|
||||
onRemove={onRemove}
|
||||
onMove={onMove}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user