feat: added alternative queue display
Publish Metadata Agent Image / build-and-push-image (push) Successful in 3m58s
Publish Web Player Image / build-and-push-image (push) Successful in 4m16s

This commit is contained in:
Boris Cherepanov
2026-04-04 18:49:29 +03:00
parent daaa3b0814
commit 3199c12af5
6 changed files with 249 additions and 2 deletions
@@ -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/` (один файл без папки) — наследие; **новые** компоненты добавляйте только по структуре выше.
+14 -1
View File
@@ -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">
&#128266;
</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>
&#9776;
</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>
)
}