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" />
|
<div className="toast" id="toast" />
|
||||||
<audio ref={audioRef} />
|
<audio ref={audioRef} />
|
||||||
|
|||||||
@@ -1,7 +1,28 @@
|
|||||||
import { NowPlaying } from './NowPlaying'
|
import { NowPlaying } from './NowPlaying'
|
||||||
|
import { QueuePopover } from './queue-popover'
|
||||||
import type { QueueItem } from './QueueList'
|
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 (
|
return (
|
||||||
<div className="player-bar">
|
<div className="player-bar">
|
||||||
<NowPlaying track={track} />
|
<NowPlaying track={track} />
|
||||||
@@ -30,6 +51,15 @@ export function PlayerBar({ track }: { track: QueueItem | null }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="volume-row">
|
<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 className="vol-icon" id="volIcon">
|
||||||
🔊
|
🔊
|
||||||
</span>
|
</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