diff --git a/.cursor/rules/furumi-node-player-client-components.mdc b/.cursor/rules/furumi-node-player-client-components.mdc new file mode 100644 index 0000000..86b9c2b --- /dev/null +++ b/.cursor/rules/furumi-node-player-client-components.mdc @@ -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
+} +``` + +```typescript +// index.ts +export * from './my-widget' +``` + +## Примечание + +Существующие файлы-компоненты в корне `components/` (один файл без папки) — наследие; **новые** компоненты добавляйте только по структуре выше. diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index b7393d8..ba53105 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -563,7 +563,20 @@ export function FurumiPlayer() { } /> - + queueActionsRef.current?.playIndex(origIdx)} + onQueueRemove={(origIdx) => + queueActionsRef.current?.removeFromQueue(origIdx) + } + onQueueMove={(fromPos, toPos) => + queueActionsRef.current?.moveQueueItem(fromPos, toPos) + } + />
+ 🔊 diff --git a/furumi-node-player/client/src/components/queue-popover/index.ts b/furumi-node-player/client/src/components/queue-popover/index.ts new file mode 100644 index 0000000..e2aa02a --- /dev/null +++ b/furumi-node-player/client/src/components/queue-popover/index.ts @@ -0,0 +1 @@ +export * from './queue-popover' diff --git a/furumi-node-player/client/src/components/queue-popover/queue-popover.module.css b/furumi-node-player/client/src/components/queue-popover/queue-popover.module.css new file mode 100644 index 0000000..d16d59f --- /dev/null +++ b/furumi-node-player/client/src/components/queue-popover/queue-popover.module.css @@ -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; +} diff --git a/furumi-node-player/client/src/components/queue-popover/queue-popover.tsx b/furumi-node-player/client/src/components/queue-popover/queue-popover.tsx new file mode 100644 index 0000000..40eb69a --- /dev/null +++ b/furumi-node-player/client/src/components/queue-popover/queue-popover.tsx @@ -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(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 ( +
+ + {open && ( + + )} +
+ ) +}