Compare commits
2 Commits
e42566f44e
...
3199c12af5
| Author | SHA1 | Date | |
|---|---|---|---|
| 3199c12af5 | |||
| daaa3b0814 |
@@ -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/` (один файл без папки) — наследие; **новые** компоненты добавляйте только по структуре выше.
|
||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
} from './store/slices/queueSlice'
|
} from './store/slices/queueSlice'
|
||||||
import { attachAudioPlayback } from './audioPlaybackService'
|
import { attachAudioPlayback } from './audioPlaybackService'
|
||||||
import { fmt } from './utils'
|
import { fmt } from './utils'
|
||||||
import { Header } from './components/Header'
|
import { Header } from './components/header'
|
||||||
import { MainPanel, type Crumb } from './components/MainPanel'
|
import { MainPanel, type Crumb } from './components/MainPanel'
|
||||||
import { PlayerBar } from './components/PlayerBar'
|
import { PlayerBar } from './components/PlayerBar'
|
||||||
import type { Track } from './types'
|
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" />
|
<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>
|
||||||
|
|||||||
+5
-4
@@ -1,4 +1,5 @@
|
|||||||
import { SearchDropdown } from './SearchDropdown'
|
import { SearchDropdown } from '../SearchDropdown'
|
||||||
|
import styles from './header.module.css'
|
||||||
|
|
||||||
type SearchResultItem = {
|
type SearchResultItem = {
|
||||||
result_type: string
|
result_type: string
|
||||||
@@ -19,8 +20,8 @@ export function Header({
|
|||||||
onSearchSelect,
|
onSearchSelect,
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header className="header">
|
<header className={styles.header}>
|
||||||
<div className="header-logo">
|
<div className={styles.headerLogo}>
|
||||||
<button className="btn-menu">☰</button>
|
<button className="btn-menu">☰</button>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<circle cx="9" cy="18" r="3" />
|
<circle cx="9" cy="18" r="3" />
|
||||||
@@ -28,7 +29,7 @@ export function Header({
|
|||||||
<path d="M12 18V6l9-3v3" />
|
<path d="M12 18V6l9-3v3" />
|
||||||
</svg>
|
</svg>
|
||||||
Furumi
|
Furumi
|
||||||
<span className="header-version">v</span>
|
<span className={styles.headerVersion}>v</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
<div className="search-wrap">
|
<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>
|
||||||
|
☰
|
||||||
|
</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;
|
--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 {
|
.btn-menu {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user