2 Commits

Author SHA1 Message Date
Boris Cherepanov 3199c12af5 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
2026-04-04 18:49:29 +03:00
Boris Cherepanov daaa3b0814 feat: update styles 2026-04-04 18:33:45 +03:00
10 changed files with 291 additions and 41 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/` (один файл без папки) — наследие; **новые** компоненты добавляйте только по структуре выше.
+15 -2
View File
@@ -26,7 +26,7 @@ import {
} from './store/slices/queueSlice'
import { attachAudioPlayback } from './audioPlaybackService'
import { fmt } from './utils'
import { Header } from './components/Header'
import { Header } from './components/header'
import { MainPanel, type Crumb } from './components/MainPanel'
import { PlayerBar } from './components/PlayerBar'
import type { Track } from './types'
@@ -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>
@@ -1,4 +1,5 @@
import { SearchDropdown } from './SearchDropdown'
import { SearchDropdown } from '../SearchDropdown'
import styles from './header.module.css'
type SearchResultItem = {
result_type: string
@@ -19,8 +20,8 @@ export function Header({
onSearchSelect,
}: HeaderProps) {
return (
<header className="header">
<div className="header-logo">
<header className={styles.header}>
<div className={styles.headerLogo}>
<button className="btn-menu">&#9776;</button>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="9" cy="18" r="3" />
@@ -28,7 +29,7 @@ export function Header({
<path d="M12 18V6l9-3v3" />
</svg>
Furumi
<span className="header-version">v</span>
<span className={styles.headerVersion}>v</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div className="search-wrap">
@@ -0,0 +1,35 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
z-index: 10;
}
.headerLogo {
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 700;
font-size: 1.1rem;
color: #ffffff;
}
.headerLogo svg {
width: 22px;
height: 22px;
}
.headerVersion {
font-size: 0.7rem;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.05);
padding: 0.1rem 0.4rem;
border-radius: 4px;
margin-left: 0.25rem;
font-weight: 500;
text-decoration: none;
}
@@ -0,0 +1 @@
export * from './Header'
@@ -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>
)
}
@@ -31,40 +31,6 @@
--danger: #f87171;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
z-index: 10;
}
.header-logo {
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 700;
font-size: 1.1rem;
}
.header-logo svg {
width: 22px;
height: 22px;
}
.header-version {
font-size: 0.7rem;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.05);
padding: 0.1rem 0.4rem;
border-radius: 4px;
margin-left: 0.25rem;
font-weight: 500;
text-decoration: none;
}
.btn-menu {
display: none;