feat(ui): implement v2 three-panel layout
- Collapsible sidebar (68px icons / 230px expanded, toggle via logo) - LibraryPanel: sessions expand inline to show tracks, search + filter chips - PlayerPanel: extracted from SongPage, used as embeddable panel - BandPage: Library + Player side by side; song selection via ?song= URL param - SongPage: thin wrapper around PlayerPanel (kept for direct deep-links) - CSS palette updated to v2 violet/cyan/emerald scheme - Mobile (<900px): BandPage shows library or player, never both Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
374
web/src/components/LibraryPanel.tsx
Normal file
374
web/src/components/LibraryPanel.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../api/client";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SessionSummary {
|
||||
id: string;
|
||||
date: string;
|
||||
label: string | null;
|
||||
recording_count: number;
|
||||
}
|
||||
|
||||
interface SongSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
tags: string[];
|
||||
global_key: string | null;
|
||||
global_bpm: number | null;
|
||||
version_count: number;
|
||||
}
|
||||
|
||||
interface SessionDetail {
|
||||
id: string;
|
||||
band_id: string;
|
||||
date: string;
|
||||
label: string | null;
|
||||
notes: string | null;
|
||||
recording_count: number;
|
||||
songs: SongSummary[];
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatSessionDate(iso: string): string {
|
||||
const d = new Date(iso.slice(0, 10) + "T12:00:00");
|
||||
const today = new Date();
|
||||
today.setHours(12, 0, 0, 0);
|
||||
const diffDays = Math.round((today.getTime() - d.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (diffDays === 0) return "Today";
|
||||
if (diffDays === 7) return "Last week";
|
||||
return d.toLocaleDateString(undefined, { weekday: "short", day: "numeric", month: "short", year: "numeric" });
|
||||
}
|
||||
|
||||
function computeWaveBars(seed: string): number[] {
|
||||
let s = seed.split("").reduce((acc, c) => acc + c.charCodeAt(0), 31337);
|
||||
return Array.from({ length: 12 }, () => {
|
||||
s = ((s * 1664525 + 1013904223) & 0xffffffff) >>> 0;
|
||||
return Math.max(15, Math.floor((s / 0xffffffff) * 100));
|
||||
});
|
||||
}
|
||||
|
||||
function MiniWave({ songId, active }: { songId: string; active: boolean }) {
|
||||
const bars = useMemo(() => computeWaveBars(songId), [songId]);
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: "1.5px", height: 18, width: 26, flexShrink: 0 }}>
|
||||
{bars.map((h, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: 2,
|
||||
height: `${h}%`,
|
||||
borderRadius: 1,
|
||||
background: active
|
||||
? `rgba(139,92,246,${0.3 + (h / 100) * 0.5})`
|
||||
: "rgba(255,255,255,0.1)",
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tag badge ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const TAG_COLORS: Record<string, { bg: string; color: string }> = {
|
||||
jam: { bg: "rgba(139,92,246,0.12)", color: "#a78bfa" },
|
||||
riff: { bg: "rgba(34,211,238,0.1)", color: "#67e8f9" },
|
||||
idea: { bg: "rgba(52,211,153,0.1)", color: "#6ee7b7" },
|
||||
};
|
||||
|
||||
function TagBadge({ tag }: { tag: string }) {
|
||||
const style = TAG_COLORS[tag.toLowerCase()] ?? { bg: "rgba(255,255,255,0.06)", color: "rgba(232,233,240,0.45)" };
|
||||
return (
|
||||
<span style={{ fontSize: 9, fontWeight: 700, padding: "2px 6px", borderRadius: 4, letterSpacing: "0.02em", background: style.bg, color: style.color, flexShrink: 0 }}>
|
||||
{tag}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Track row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function TrackRow({
|
||||
song,
|
||||
index,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
song: SongSummary;
|
||||
index: number;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "9px 20px",
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
background: active ? "rgba(139,92,246,0.06)" : hovered ? "rgba(255,255,255,0.025)" : "transparent",
|
||||
transition: "background 0.12s",
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
<div style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: 2, background: "linear-gradient(to bottom, #7b5cf6, #22d3ee)" }} />
|
||||
)}
|
||||
|
||||
<span style={{ fontSize: 10, color: "rgba(232,233,240,0.2)", width: 20, textAlign: "right", flexShrink: 0, fontFamily: "monospace" }}>
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
|
||||
<span style={{ fontSize: 13, fontWeight: 600, flex: 1, color: active ? "#a78bfa" : "#e8e9f0", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", transition: "color 0.12s" }}>
|
||||
{song.title}
|
||||
</span>
|
||||
|
||||
{song.tags[0] && <TagBadge tag={song.tags[0]} />}
|
||||
|
||||
<MiniWave songId={song.id} active={active} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Session group ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SessionGroup({
|
||||
bandId,
|
||||
session,
|
||||
selectedSongId,
|
||||
search,
|
||||
filterTag,
|
||||
onSelectSong,
|
||||
defaultOpen,
|
||||
}: {
|
||||
bandId: string;
|
||||
session: SessionSummary;
|
||||
selectedSongId: string | null;
|
||||
search: string;
|
||||
filterTag: string;
|
||||
onSelectSong: (songId: string) => void;
|
||||
defaultOpen: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
const { data: detail } = useQuery({
|
||||
queryKey: ["session", session.id],
|
||||
queryFn: () => api.get<SessionDetail>(`/bands/${bandId}/sessions/${session.id}`),
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const filteredSongs = useMemo(() => {
|
||||
if (!detail?.songs) return [];
|
||||
return detail.songs.filter((song) => {
|
||||
const matchesSearch = !search || song.title.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesTag = !filterTag || song.tags.some((t) => t.toLowerCase() === filterTag);
|
||||
return matchesSearch && matchesTag;
|
||||
});
|
||||
}, [detail, search, filterTag]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Session header */}
|
||||
<div
|
||||
onClick={() => setIsOpen((o) => !o)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 20px 5px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, letterSpacing: "0.08em", textTransform: "uppercase",
|
||||
background: "linear-gradient(135deg, #7b5cf6, #22d3ee)",
|
||||
WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent",
|
||||
}}>
|
||||
{formatSessionDate(session.date)}
|
||||
</span>
|
||||
{session.label && (
|
||||
<span style={{ fontSize: 11, color: "rgba(232,233,240,0.35)", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{session.label}
|
||||
</span>
|
||||
)}
|
||||
{!session.label && <span style={{ flex: 1 }} />}
|
||||
<span style={{ fontSize: 10, color: "rgba(232,233,240,0.28)", background: "rgba(255,255,255,0.04)", padding: "2px 8px", borderRadius: 20, flexShrink: 0 }}>
|
||||
{session.recording_count}
|
||||
</span>
|
||||
<svg
|
||||
width="12" height="12" viewBox="0 0 12 12" fill="none"
|
||||
style={{ color: "rgba(232,233,240,0.28)", transform: isOpen ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.18s", flexShrink: 0 }}
|
||||
>
|
||||
<path d="M4 2l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Track list */}
|
||||
{isOpen && (
|
||||
<div>
|
||||
{!detail && (
|
||||
<div style={{ padding: "6px 20px 8px", fontSize: 11, color: "rgba(232,233,240,0.25)" }}>
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
{detail && (filteredSongs.length > 0 ? filteredSongs : detail.songs).map((song, i) => (
|
||||
<TrackRow
|
||||
key={song.id}
|
||||
song={song}
|
||||
index={i}
|
||||
active={song.id === selectedSongId}
|
||||
onSelect={() => onSelectSong(song.id)}
|
||||
/>
|
||||
))}
|
||||
{detail && detail.songs.length === 0 && (
|
||||
<div style={{ padding: "6px 20px 8px", fontSize: 11, color: "rgba(232,233,240,0.25)" }}>
|
||||
No recordings yet.
|
||||
</div>
|
||||
)}
|
||||
{detail && search && filteredSongs.length === 0 && detail.songs.length > 0 && (
|
||||
<div style={{ padding: "6px 20px 8px", fontSize: 11, color: "rgba(232,233,240,0.25)" }}>
|
||||
No matches in this session.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Filter chips ──────────────────────────────────────────────────────────────
|
||||
|
||||
const FILTER_CHIPS = [
|
||||
{ label: "All", value: "" },
|
||||
{ label: "Jam", value: "jam" },
|
||||
{ label: "Riff", value: "riff" },
|
||||
{ label: "Idea", value: "idea" },
|
||||
];
|
||||
|
||||
// ── LibraryPanel ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface LibraryPanelProps {
|
||||
bandId: string;
|
||||
selectedSongId: string | null;
|
||||
onSelectSong: (songId: string) => void;
|
||||
}
|
||||
|
||||
export function LibraryPanel({ bandId, selectedSongId, onSelectSong }: LibraryPanelProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [filterTag, setFilterTag] = useState("");
|
||||
|
||||
const { data: sessions } = useQuery({
|
||||
queryKey: ["sessions", bandId],
|
||||
queryFn: () => api.get<SessionSummary[]>(`/bands/${bandId}/sessions`),
|
||||
enabled: !!bandId,
|
||||
});
|
||||
|
||||
const border = "rgba(255,255,255,0.06)";
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 340,
|
||||
minWidth: 280,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "#10131f",
|
||||
borderRight: `1px solid ${border}`,
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ padding: "22px 20px 12px", flexShrink: 0 }}>
|
||||
<h2 style={{ margin: "0 0 14px", fontSize: 22, fontWeight: 800, letterSpacing: -0.5, background: "linear-gradient(135deg, #e8e9f0 30%, rgba(232,233,240,0.5))", WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent" }}>
|
||||
Library
|
||||
</h2>
|
||||
|
||||
{/* Search */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, background: "#151828", border: `1px solid ${border}`, borderRadius: 8, padding: "8px 12px", transition: "border-color 0.15s" }}
|
||||
onFocusCapture={(e) => (e.currentTarget.style.borderColor = "rgba(139,92,246,0.4)")}
|
||||
onBlurCapture={(e) => (e.currentTarget.style.borderColor = border)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ color: "rgba(232,233,240,0.25)", flexShrink: 0 }}>
|
||||
<circle cx="6" cy="6" r="4.5" />
|
||||
<path d="M10 10l2.5 2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search recordings…"
|
||||
style={{ background: "none", border: "none", outline: "none", fontFamily: "inherit", fontSize: 13, color: "#e8e9f0", flex: 1, caretColor: "#a78bfa" }}
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch("")} style={{ background: "none", border: "none", cursor: "pointer", color: "rgba(232,233,240,0.3)", padding: 0, display: "flex", lineHeight: 1 }}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter chips */}
|
||||
<div style={{ display: "flex", gap: 5, padding: "0 20px 10px", borderBottom: `1px solid ${border}`, flexShrink: 0, overflowX: "auto" }}>
|
||||
{FILTER_CHIPS.map((chip) => {
|
||||
const on = filterTag === chip.value;
|
||||
return (
|
||||
<button
|
||||
key={chip.value}
|
||||
onClick={() => setFilterTag(chip.value)}
|
||||
style={{
|
||||
fontSize: 11, fontWeight: 600, padding: "4px 12px", borderRadius: 20, cursor: "pointer", whiteSpace: "nowrap",
|
||||
border: on ? "1px solid rgba(139,92,246,0.4)" : `1px solid ${border}`,
|
||||
background: on ? "rgba(139,92,246,0.1)" : "transparent",
|
||||
color: on ? "#a78bfa" : "rgba(232,233,240,0.35)",
|
||||
fontFamily: "inherit",
|
||||
transition: "all 0.12s",
|
||||
}}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<div style={{ flex: 1, overflowY: "auto" }}>
|
||||
<style>{`
|
||||
::-webkit-scrollbar { width: 3px; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
|
||||
`}</style>
|
||||
{!sessions && (
|
||||
<div style={{ padding: "20px", fontSize: 12, color: "rgba(232,233,240,0.3)" }}>Loading sessions…</div>
|
||||
)}
|
||||
{sessions?.length === 0 && (
|
||||
<div style={{ padding: "20px", fontSize: 12, color: "rgba(232,233,240,0.3)" }}>
|
||||
No sessions yet. Go to Storage settings to scan your Nextcloud folder.
|
||||
</div>
|
||||
)}
|
||||
{sessions?.map((session, i) => (
|
||||
<SessionGroup
|
||||
key={session.id}
|
||||
bandId={bandId}
|
||||
session={session}
|
||||
selectedSongId={selectedSongId}
|
||||
search={search}
|
||||
filterTag={filterTag}
|
||||
onSelectSong={onSelectSong}
|
||||
defaultOpen={i === 0}
|
||||
/>
|
||||
))}
|
||||
{/* Bottom padding for last item breathing room */}
|
||||
<div style={{ height: 20 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
472
web/src/components/PlayerPanel.tsx
Normal file
472
web/src/components/PlayerPanel.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
import { useRef, useState, useCallback, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api/client";
|
||||
import type { MemberRead } from "../api/auth";
|
||||
import { useWaveform } from "../hooks/useWaveform";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SongRead {
|
||||
id: string;
|
||||
band_id: string;
|
||||
session_id: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
tags: string[];
|
||||
global_key: string | null;
|
||||
global_bpm: number | null;
|
||||
version_count: number;
|
||||
}
|
||||
|
||||
interface SongVersion {
|
||||
id: string;
|
||||
version_number: number;
|
||||
label: string | null;
|
||||
analysis_status: string;
|
||||
}
|
||||
|
||||
interface SongComment {
|
||||
id: string;
|
||||
song_id: string;
|
||||
body: string;
|
||||
author_id: string;
|
||||
author_name: string;
|
||||
author_avatar_url: string | null;
|
||||
timestamp: number | null;
|
||||
tag: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface SessionInfo {
|
||||
id: string;
|
||||
band_id: string;
|
||||
date: string;
|
||||
label: string | null;
|
||||
songs: { id: string; title: string; status: string; tags: string[] }[];
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name.split(/\s+/).map((w) => w[0]).join("").toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
const MEMBER_COLORS = [
|
||||
{ bg: "rgba(91,156,240,0.18)", border: "rgba(91,156,240,0.6)", text: "#7aabf0" },
|
||||
{ bg: "rgba(200,90,180,0.18)", border: "rgba(200,90,180,0.6)", text: "#d070c0" },
|
||||
{ bg: "rgba(52,211,153,0.18)", border: "rgba(52,211,153,0.6)", text: "#34d399" },
|
||||
{ bg: "rgba(139,92,246,0.18)", border: "rgba(139,92,246,0.6)", text: "#a78bfa" },
|
||||
{ bg: "rgba(34,211,238,0.18)", border: "rgba(34,211,238,0.6)", text: "#22d3ee" },
|
||||
];
|
||||
|
||||
function memberColor(authorId: string) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < authorId.length; i++) h = (h * 31 + authorId.charCodeAt(i)) >>> 0;
|
||||
return MEMBER_COLORS[h % MEMBER_COLORS.length];
|
||||
}
|
||||
|
||||
const TAG_STYLES: Record<string, { bg: string; color: string }> = {
|
||||
suggestion: { bg: "rgba(91,156,240,0.1)", color: "#7aabf0" },
|
||||
issue: { bg: "rgba(244,63,94,0.1)", color: "#f87171" },
|
||||
keeper: { bg: "rgba(52,211,153,0.1)", color: "#34d399" },
|
||||
};
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function Avatar({ name, avatarUrl, authorId, size = 24 }: { name: string; avatarUrl: string | null; authorId: string; size?: number }) {
|
||||
const mc = memberColor(authorId);
|
||||
if (avatarUrl) return <img src={avatarUrl} alt={name} style={{ width: size, height: size, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }} />;
|
||||
return (
|
||||
<div style={{ width: size, height: size, borderRadius: "50%", background: mc.bg, border: `1.5px solid ${mc.border}`, color: mc.text, display: "flex", alignItems: "center", justifyContent: "center", fontSize: size * 0.38, fontWeight: 700, flexShrink: 0 }}>
|
||||
{getInitials(name)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TransportBtn({ onClick, title, children }: { onClick: () => void; title?: string; children: React.ReactNode }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
return (
|
||||
<button onClick={onClick} title={title} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}
|
||||
style={{ width: 36, height: 36, borderRadius: "50%", background: hovered ? "rgba(255,255,255,0.08)" : "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.07)", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", color: hovered ? "rgba(232,233,240,0.72)" : "rgba(232,233,240,0.35)", flexShrink: 0, transition: "all 0.12s" }}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function WaveformPins({
|
||||
comments, duration, containerWidth, onSeek, onScrollToComment,
|
||||
}: {
|
||||
comments: SongComment[]; duration: number; containerWidth: number;
|
||||
onSeek: (t: number) => void; onScrollToComment: (id: string) => void;
|
||||
}) {
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
const pinned = comments.filter((c) => c.timestamp != null);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", height: 44, overflow: "visible" }}>
|
||||
{pinned.map((c) => {
|
||||
const pct = duration > 0 ? c.timestamp! / duration : 0;
|
||||
const left = Math.round(pct * containerWidth);
|
||||
const mc = memberColor(c.author_id);
|
||||
const isHovered = hoveredId === c.id;
|
||||
|
||||
return (
|
||||
<div key={c.id}
|
||||
style={{ position: "absolute", left, top: 0, transform: "translateX(-50%)", display: "flex", flexDirection: "column", alignItems: "center", cursor: "pointer", zIndex: 10, transition: "transform 0.12s", ...(isHovered ? { transform: "translateX(-50%) scale(1.15)" } : {}) }}
|
||||
onMouseEnter={() => setHoveredId(c.id)} onMouseLeave={() => setHoveredId(null)}
|
||||
onClick={() => { onSeek(c.timestamp!); onScrollToComment(c.id); }}
|
||||
>
|
||||
{isHovered && (
|
||||
<div style={{ position: "absolute", bottom: "calc(100% + 6px)", left: "50%", transform: "translateX(-50%)", background: "#1a1e30", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, padding: "8px 10px", width: 180, zIndex: 50, pointerEvents: "none" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 4 }}>
|
||||
<div style={{ width: 18, height: 18, borderRadius: "50%", background: mc.bg, color: mc.text, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 8, fontWeight: 700 }}>
|
||||
{getInitials(c.author_name)}
|
||||
</div>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: "rgba(232,233,240,0.72)" }}>{c.author_name}</span>
|
||||
<span style={{ fontSize: 10, fontFamily: "monospace", color: "#a78bfa", marginLeft: "auto" }}>{formatTime(c.timestamp!)}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "rgba(232,233,240,0.42)", lineHeight: 1.4 }}>
|
||||
{c.body.length > 80 ? c.body.slice(0, 80) + "…" : c.body}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ width: 22, height: 22, borderRadius: "50%", background: mc.bg, border: `2px solid ${mc.border}`, color: mc.text, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 8, fontWeight: 700, boxShadow: "0 2px 8px rgba(0,0,0,0.45)" }}>
|
||||
{getInitials(c.author_name)}
|
||||
</div>
|
||||
<div style={{ width: 1.5, height: 14, background: mc.text, opacity: 0.3, marginTop: 2 }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Icons ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function IconSkipBack() {
|
||||
return <svg width="13" height="13" viewBox="0 0 13 13" fill="currentColor"><path d="M6.5 2.5a4 4 0 1 0 3.46 2H8a2.5 2.5 0 1 1-1.5-2.28V4L9.5 2 6.5 0v2.5z" /></svg>;
|
||||
}
|
||||
function IconSkipFwd() {
|
||||
return <svg width="13" height="13" viewBox="0 0 13 13" fill="currentColor" style={{ transform: "scaleX(-1)" }}><path d="M6.5 2.5a4 4 0 1 0 3.46 2H8a2.5 2.5 0 1 1-1.5-2.28V4L9.5 2 6.5 0v2.5z" /></svg>;
|
||||
}
|
||||
function IconPlay() {
|
||||
return <svg width="16" height="16" viewBox="0 0 16 16" fill="white"><path d="M4 2l10 6-10 6V2z" /></svg>;
|
||||
}
|
||||
function IconPause() {
|
||||
return <svg width="16" height="16" viewBox="0 0 16 16" fill="white"><path d="M4 2h3v12H4zm6 0h3v12H10z" /></svg>;
|
||||
}
|
||||
|
||||
// ── PlayerPanel ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface PlayerPanelProps {
|
||||
songId: string;
|
||||
bandId: string;
|
||||
/** Called when back button is clicked. If omitted, navigates to /bands/:bandId */
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function PlayerPanel({ songId, bandId, onBack }: PlayerPanelProps) {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const waveformRef = useRef<HTMLDivElement>(null);
|
||||
const waveformContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
|
||||
const [commentBody, setCommentBody] = useState("");
|
||||
const [selectedTag, setSelectedTag] = useState("");
|
||||
const [composeFocused, setComposeFocused] = useState(false);
|
||||
const [waveformWidth, setWaveformWidth] = useState(0);
|
||||
// State resets automatically because BandPage passes key={selectedSongId} to PlayerPanel
|
||||
|
||||
// ── Queries ──────────────────────────────────────────────────────────────
|
||||
|
||||
const { data: me } = useQuery({ queryKey: ["me"], queryFn: () => api.get<MemberRead>("/auth/me") });
|
||||
|
||||
const { data: song } = useQuery({
|
||||
queryKey: ["song", songId],
|
||||
queryFn: () => api.get<SongRead>(`/songs/${songId}`),
|
||||
enabled: !!songId,
|
||||
});
|
||||
|
||||
const { data: versions } = useQuery({
|
||||
queryKey: ["versions", songId],
|
||||
queryFn: () => api.get<SongVersion[]>(`/songs/${songId}/versions`),
|
||||
enabled: !!songId,
|
||||
});
|
||||
|
||||
const { data: session } = useQuery({
|
||||
queryKey: ["session", song?.session_id],
|
||||
queryFn: () => api.get<SessionInfo>(`/bands/${bandId}/sessions/${song!.session_id}`),
|
||||
enabled: !!song?.session_id && !!bandId,
|
||||
});
|
||||
|
||||
const { data: comments } = useQuery<SongComment[]>({
|
||||
queryKey: ["comments", songId],
|
||||
queryFn: () => api.get<SongComment[]>(`/songs/${songId}/comments`),
|
||||
enabled: !!songId,
|
||||
});
|
||||
|
||||
const activeVersion = selectedVersionId ?? versions?.[0]?.id ?? null;
|
||||
|
||||
// ── Waveform ──────────────────────────────────────────────────────────────
|
||||
|
||||
const { isPlaying, isReady, currentTime, duration, play, pause, seekTo, error } = useWaveform(waveformRef, {
|
||||
url: activeVersion ? `/api/v1/versions/${activeVersion}/stream` : null,
|
||||
peaksUrl: activeVersion ? `/api/v1/versions/${activeVersion}/waveform` : null,
|
||||
songId,
|
||||
bandId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = waveformContainerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => setWaveformWidth(entries[0].contentRect.width));
|
||||
ro.observe(el);
|
||||
setWaveformWidth(el.offsetWidth);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Space bar shortcut
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === "TEXTAREA" || target.tagName === "INPUT") return;
|
||||
if (e.code === "Space") { e.preventDefault(); if (isPlaying) pause(); else if (isReady) play(); }
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isPlaying, isReady, play, pause]);
|
||||
|
||||
// ── Mutations ─────────────────────────────────────────────────────────────
|
||||
|
||||
const addCommentMutation = useMutation({
|
||||
mutationFn: ({ body, timestamp, tag }: { body: string; timestamp: number; tag: string }) =>
|
||||
api.post(`/songs/${songId}/comments`, { body, timestamp, tag: tag || null }),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["comments", songId] }); setCommentBody(""); setSelectedTag(""); },
|
||||
});
|
||||
|
||||
const deleteCommentMutation = useMutation({
|
||||
mutationFn: (commentId: string) => api.delete(`/comments/${commentId}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["comments", songId] }),
|
||||
});
|
||||
|
||||
const scrollToComment = useCallback((commentId: string) => {
|
||||
document.getElementById(`comment-${commentId}`)?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}, []);
|
||||
|
||||
const handleBack = () => {
|
||||
if (onBack) onBack();
|
||||
else navigate(`/bands/${bandId}`);
|
||||
};
|
||||
|
||||
const border = "rgba(255,255,255,0.06)";
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", flex: 1, height: "100%", overflow: "hidden", background: "#0c0e1a", minWidth: 0 }}>
|
||||
|
||||
{/* Breadcrumb / header */}
|
||||
<div style={{ padding: "11px 20px", borderBottom: `1px solid ${border}`, display: "flex", alignItems: "center", gap: 8, flexShrink: 0 }}>
|
||||
<button onClick={handleBack}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", color: "rgba(232,233,240,0.28)", fontSize: 11, padding: 0, fontFamily: "inherit" }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.65)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.28)")}
|
||||
>
|
||||
← Library
|
||||
</button>
|
||||
{session && (
|
||||
<>
|
||||
<span style={{ color: "rgba(232,233,240,0.2)", fontSize: 11 }}>›</span>
|
||||
<button onClick={() => navigate(`/bands/${bandId}/sessions/${session.id}`)}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", color: "rgba(232,233,240,0.28)", fontSize: 11, padding: 0, fontFamily: "inherit" }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.65)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.28)")}
|
||||
>
|
||||
{session.date}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<span style={{ color: "rgba(232,233,240,0.2)", fontSize: 11 }}>›</span>
|
||||
<span style={{ fontSize: 12, color: "rgba(232,233,240,0.72)", fontFamily: "monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 }}>
|
||||
{song?.title ?? "…"}
|
||||
</span>
|
||||
|
||||
{/* Version selector */}
|
||||
{versions && versions.length > 1 && (
|
||||
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
||||
{versions.map((v) => (
|
||||
<button key={v.id} onClick={() => setSelectedVersionId(v.id)}
|
||||
style={{ background: v.id === activeVersion ? "rgba(139,92,246,0.12)" : "transparent", border: `1px solid ${v.id === activeVersion ? "rgba(139,92,246,0.3)" : "rgba(255,255,255,0.09)"}`, borderRadius: 6, padding: "4px 10px", color: v.id === activeVersion ? "#a78bfa" : "rgba(232,233,240,0.38)", cursor: "pointer", fontSize: 11, fontFamily: "monospace" }}>
|
||||
v{v.version_number}{v.label ? ` · ${v.label}` : ""}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button style={{ background: "transparent", border: `1px solid rgba(255,255,255,0.09)`, borderRadius: 6, color: "rgba(232,233,240,0.38)", cursor: "pointer", fontSize: 12, padding: "5px 12px", fontFamily: "inherit", flexShrink: 0 }}>
|
||||
Share
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ display: "flex", flexDirection: "column", flex: 1, overflow: "hidden" }}>
|
||||
|
||||
{/* Waveform section */}
|
||||
<div style={{ padding: "16px 20px", flexShrink: 0 }}>
|
||||
<div style={{ background: "rgba(255,255,255,0.02)", border: `1px solid ${border}`, borderRadius: 10, padding: "14px 14px 10px", marginBottom: 12 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 11, color: "rgba(232,233,240,0.45)", fontFamily: "monospace" }}>{song?.title ?? "…"}</span>
|
||||
<span style={{ fontSize: 10, fontFamily: "monospace", color: "rgba(232,233,240,0.28)" }}>{isReady ? formatTime(duration) : "—"}</span>
|
||||
</div>
|
||||
|
||||
{/* Pin layer + canvas */}
|
||||
<div ref={waveformContainerRef} style={{ position: "relative" }}>
|
||||
{isReady && duration > 0 && comments && (
|
||||
<WaveformPins comments={comments} duration={duration} containerWidth={waveformWidth} onSeek={seekTo} onScrollToComment={scrollToComment} />
|
||||
)}
|
||||
{!isReady && <div style={{ height: 44 }} />}
|
||||
<div ref={waveformRef} />
|
||||
{error && <div style={{ color: "#f87171", fontSize: 12, padding: "8px 0", textAlign: "center", fontFamily: "monospace" }}>Audio error: {error}</div>}
|
||||
{!isReady && !error && <div style={{ color: "rgba(232,233,240,0.28)", fontSize: 12, padding: "8px 0", textAlign: "center", fontFamily: "monospace" }}>Loading audio…</div>}
|
||||
</div>
|
||||
|
||||
{/* Time bar */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 6, padding: "0 1px" }}>
|
||||
<span style={{ fontSize: 10, fontFamily: "monospace", color: "#a78bfa" }}>{formatTime(currentTime)}</span>
|
||||
<span style={{ fontSize: 10, fontFamily: "monospace", color: "rgba(232,233,240,0.22)" }}>{isReady && duration > 0 ? formatTime(duration / 2) : "—"}</span>
|
||||
<span style={{ fontSize: 10, fontFamily: "monospace", color: "rgba(232,233,240,0.22)" }}>{isReady ? formatTime(duration) : "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transport */}
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 10, padding: "4px 0 4px" }}>
|
||||
<TransportBtn onClick={() => seekTo(Math.max(0, currentTime - 30))} title="−30s"><IconSkipBack /></TransportBtn>
|
||||
<button
|
||||
onClick={isPlaying ? pause : play}
|
||||
disabled={!activeVersion}
|
||||
style={{ width: 46, height: 46, background: "linear-gradient(135deg, #7b5cf6, #06b6d4)", borderRadius: "50%", border: "none", display: "flex", alignItems: "center", justifyContent: "center", cursor: activeVersion ? "pointer" : "default", opacity: activeVersion ? 1 : 0.4, flexShrink: 0, transition: "transform 0.12s, box-shadow 0.12s", boxShadow: "0 4px 16px rgba(139,92,246,0.35)" }}
|
||||
onMouseEnter={(e) => { if (activeVersion) { e.currentTarget.style.boxShadow = "0 6px 24px rgba(139,92,246,0.55)"; e.currentTarget.style.transform = "scale(1.04)"; } }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = "0 4px 16px rgba(139,92,246,0.35)"; e.currentTarget.style.transform = "scale(1)"; }}
|
||||
>
|
||||
{isPlaying ? <IconPause /> : <IconPlay />}
|
||||
</button>
|
||||
<TransportBtn onClick={() => seekTo(currentTime + 30)} title="+30s"><IconSkipFwd /></TransportBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments section */}
|
||||
<div style={{ display: "flex", flexDirection: "column", flex: 1, overflow: "hidden", borderTop: `1px solid ${border}`, background: "rgba(0,0,0,0.1)" }}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ padding: "12px 15px", borderBottom: `1px solid ${border}`, display: "flex", alignItems: "center", justifyContent: "space-between", flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: "rgba(232,233,240,0.72)" }}>Comments</span>
|
||||
{comments && comments.length > 0 && (
|
||||
<span style={{ fontSize: 11, background: "rgba(139,92,246,0.12)", color: "#a78bfa", padding: "1px 8px", borderRadius: 10 }}>{comments.length}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compose */}
|
||||
<div style={{ padding: "11px 14px", borderBottom: `1px solid ${border}`, flexShrink: 0 }}>
|
||||
<div style={{ display: "flex", gap: 9, alignItems: "flex-start" }}>
|
||||
{me ? (
|
||||
<Avatar name={me.display_name} avatarUrl={me.avatar_url ?? null} authorId={me.id} size={26} />
|
||||
) : (
|
||||
<div style={{ width: 26, height: 26, borderRadius: "50%", background: "rgba(255,255,255,0.06)", flexShrink: 0 }} />
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 6 }}>
|
||||
<div style={{ fontSize: 11, fontFamily: "monospace", background: "rgba(139,92,246,0.1)", color: "#a78bfa", border: "1px solid rgba(139,92,246,0.22)", padding: "3px 9px", borderRadius: 20, display: "flex", alignItems: "center", gap: 6 }}>
|
||||
{isPlaying && <div style={{ width: 6, height: 6, borderRadius: "50%", background: "#a78bfa", animation: "pp-blink 1.1s infinite" }} />}
|
||||
{formatTime(currentTime)}
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: "rgba(232,233,240,0.2)" }}>· pins to playhead</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={commentBody}
|
||||
onChange={(e) => setCommentBody(e.target.value)}
|
||||
onFocus={() => setComposeFocused(true)}
|
||||
onBlur={() => { if (!commentBody.trim()) setComposeFocused(false); }}
|
||||
placeholder="What do you hear at this moment…"
|
||||
style={{ width: "100%", background: "rgba(255,255,255,0.04)", border: composeFocused ? "1px solid rgba(139,92,246,0.35)" : `1px solid rgba(255,255,255,0.07)`, borderRadius: 7, padding: "8px 10px", color: "#e8e9f0", fontSize: 12, resize: "none", outline: "none", fontFamily: "inherit", height: composeFocused ? 68 : 42, transition: "height 0.18s, border-color 0.15s", boxSizing: "border-box" }}
|
||||
/>
|
||||
{composeFocused && (
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 7 }}>
|
||||
<div style={{ display: "flex", gap: 5 }}>
|
||||
{(["suggestion", "issue", "keeper"] as const).map((tag) => (
|
||||
<button key={tag} onClick={() => setSelectedTag((t) => (t === tag ? "" : tag))}
|
||||
style={{ fontSize: 11, padding: "3px 8px", borderRadius: 4, cursor: "pointer", fontFamily: "inherit", background: selectedTag === tag ? TAG_STYLES[tag].bg : "rgba(255,255,255,0.05)", border: `1px solid ${selectedTag === tag ? TAG_STYLES[tag].color + "44" : "rgba(255,255,255,0.07)"}`, color: selectedTag === tag ? TAG_STYLES[tag].color : "rgba(232,233,240,0.32)", transition: "all 0.12s" }}>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { if (commentBody.trim()) addCommentMutation.mutate({ body: commentBody.trim(), timestamp: currentTime, tag: selectedTag }); }}
|
||||
disabled={!commentBody.trim() || addCommentMutation.isPending}
|
||||
style={{ padding: "5px 14px", borderRadius: 6, background: "linear-gradient(135deg, #7b5cf6, #06b6d4)", border: "none", color: "white", cursor: commentBody.trim() ? "pointer" : "default", fontSize: 12, fontWeight: 600, fontFamily: "inherit", opacity: commentBody.trim() ? 1 : 0.35, transition: "opacity 0.12s" }}>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comment list */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "12px 14px" }}>
|
||||
{comments?.map((c) => {
|
||||
const tagStyle = c.tag ? TAG_STYLES[c.tag] : null;
|
||||
const isNearPlayhead = isReady && c.timestamp != null && Math.abs(c.timestamp - currentTime) < 5;
|
||||
|
||||
return (
|
||||
<div key={c.id} id={`comment-${c.id}`}
|
||||
style={{ marginBottom: 14, paddingBottom: 14, borderBottom: `1px solid rgba(255,255,255,0.04)`, borderRadius: isNearPlayhead ? 6 : undefined, background: isNearPlayhead ? "rgba(139,92,246,0.04)" : undefined, border: isNearPlayhead ? "1px solid rgba(139,92,246,0.12)" : undefined, padding: isNearPlayhead ? 8 : undefined }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 5 }}>
|
||||
<Avatar name={c.author_name} avatarUrl={c.author_avatar_url} authorId={c.author_id} size={21} />
|
||||
<span style={{ fontSize: 12, fontWeight: 500, color: "rgba(232,233,240,0.72)" }}>{c.author_name}</span>
|
||||
{c.timestamp != null && (
|
||||
<button onClick={() => seekTo(c.timestamp!)}
|
||||
style={{ marginLeft: "auto", fontSize: 10, fontFamily: "monospace", color: "#a78bfa", background: "rgba(139,92,246,0.1)", border: "none", borderRadius: 3, padding: "1px 5px", cursor: "pointer" }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(139,92,246,0.2)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = "rgba(139,92,246,0.1)")}
|
||||
>
|
||||
{formatTime(c.timestamp)}
|
||||
</button>
|
||||
)}
|
||||
{tagStyle && <span style={{ fontSize: 10, padding: "1px 5px", borderRadius: 3, background: tagStyle.bg, color: tagStyle.color }}>{c.tag}</span>}
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: 12, color: "rgba(232,233,240,0.45)", lineHeight: 1.55 }}>{c.body}</p>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 4 }}>
|
||||
<span style={{ fontSize: 11, color: "rgba(232,233,240,0.18)", cursor: "pointer" }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.5)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.18)")}
|
||||
>↩ Reply</span>
|
||||
{me && c.author_id === me.id && (
|
||||
<button onClick={() => deleteCommentMutation.mutate(c.id)}
|
||||
style={{ background: "none", border: "none", color: "rgba(232,233,240,0.15)", cursor: "pointer", fontSize: 11, padding: 0, fontFamily: "inherit" }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = "#f87171")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.15)")}
|
||||
>Delete</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{comments?.length === 0 && <p style={{ color: "rgba(232,233,240,0.22)", fontSize: 12 }}>No comments yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes pp-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,60 +8,70 @@ import { getInitials } from "../utils";
|
||||
import type { MemberRead } from "../api/auth";
|
||||
import { usePlayerStore } from "../stores/playerStore";
|
||||
|
||||
// ── Icons (inline SVG) ──────────────────────────────────────────────────────
|
||||
function IconWaveform() {
|
||||
// ── Icons ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function IconMenu() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<rect x="1" y="1.5" width="12" height="2" rx="1" fill="white" opacity=".9" />
|
||||
<rect x="1" y="5.5" width="9" height="2" rx="1" fill="white" opacity=".7" />
|
||||
<rect x="1" y="9.5" width="11" height="2" rx="1" fill="white" opacity=".8" />
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path d="M3 5h12M3 9h12M3 13h8" stroke="white" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconLibrary() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||
<path d="M2 3.5h10v1.5H2zm0 3h10v1.5H2zm0 3h7v1.5H2z" />
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<rect x="2" y="3.5" width="14" height="2" rx="1" fill="currentColor" />
|
||||
<rect x="2" y="8" width="14" height="2" rx="1" fill="currentColor" />
|
||||
<rect x="2" y="12.5" width="14" height="2" rx="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconPlay() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||
<path d="M3 2l9 5-9 5V2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconSettings() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
|
||||
<circle cx="7" cy="7" r="2" />
|
||||
<path d="M7 1v1.5M7 11.5V13M1 7h1.5M11.5 7H13" />
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
|
||||
<path d="M5 3l11 6-11 6V3z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconMembers() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||
<circle cx="5" cy="4.5" r="2" />
|
||||
<path d="M1 12c0-2.2 1.8-3.5 4-3.5s4 1.3 4 3.5H1z" />
|
||||
<circle cx="10.5" cy="4.5" r="1.5" opacity=".6" />
|
||||
<path d="M10.5 8.5c1.4 0 2.5 1 2.5 2.5H9.5" opacity=".6" />
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<circle cx="7" cy="6.5" r="2.5" stroke="currentColor" strokeWidth="1.4" />
|
||||
<path d="M1.5 14.5c0-2.761 2.462-4.5 5.5-4.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||
<circle cx="13" cy="11" r="2" stroke="currentColor" strokeWidth="1.4" />
|
||||
<path d="M16 16c0-1.657-1.343-2.5-3-2.5S10 14.343 10 16" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconSettings() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<circle cx="9" cy="9" r="2.5" stroke="currentColor" strokeWidth="1.4" />
|
||||
<path d="M9 2v1.5M9 14.5V16M2 9h1.5M14.5 9H16M3.7 3.7l1.06 1.06M13.24 13.24l1.06 1.06M14.3 3.7l-1.06 1.06M4.76 13.24l-1.06 1.06" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconStorage() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||
<rect x="1" y="3" width="12" height="3" rx="1.5" />
|
||||
<rect x="1" y="8" width="12" height="3" rx="1.5" />
|
||||
<circle cx="11" cy="4.5" r=".75" fill="#0b0b0e" />
|
||||
<circle cx="11" cy="9.5" r=".75" fill="#0b0b0e" />
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
|
||||
<rect x="1" y="4" width="16" height="3.5" rx="1.5" />
|
||||
<rect x="1" y="10.5" width="16" height="3.5" rx="1.5" />
|
||||
<circle cx="14" cy="5.75" r="0.9" fill="#0c0e1a" />
|
||||
<circle cx="14" cy="12.25" r="0.9" fill="#0c0e1a" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconSignOut() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5.5 2.5H3A1.5 1.5 0 0 0 1.5 4v7A1.5 1.5 0 0 0 3 12.5H5.5" />
|
||||
<path d="M10 10.5l3-3-3-3M13 7.5H6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -74,39 +84,33 @@ function IconChevron() {
|
||||
);
|
||||
}
|
||||
|
||||
function IconSignOut() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 2H2.5A1.5 1.5 0 0 0 1 3.5v7A1.5 1.5 0 0 0 2.5 12H5" />
|
||||
<path d="M9 10l3-3-3-3M12 7H5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
// ── NavItem ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── NavItem ─────────────────────────────────────────────────────────────────
|
||||
interface NavItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
badge?: number;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
function NavItem({ icon, label, active, onClick, disabled }: NavItemProps) {
|
||||
function NavItem({ icon, label, active, onClick, disabled, badge, collapsed }: NavItemProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const color = active
|
||||
? "#e8a22a"
|
||||
const fg = active
|
||||
? "#a78bfa"
|
||||
: disabled
|
||||
? "rgba(255,255,255,0.18)"
|
||||
? "rgba(255,255,255,0.16)"
|
||||
: hovered
|
||||
? "rgba(255,255,255,0.7)"
|
||||
: "rgba(255,255,255,0.35)";
|
||||
? "rgba(232,233,240,0.7)"
|
||||
: "rgba(232,233,240,0.35)";
|
||||
|
||||
const bg = active
|
||||
? "rgba(232,162,42,0.12)"
|
||||
? "rgba(139,92,246,0.12)"
|
||||
: hovered && !disabled
|
||||
? "rgba(255,255,255,0.045)"
|
||||
? "rgba(255,255,255,0.04)"
|
||||
: "transparent";
|
||||
|
||||
return (
|
||||
@@ -115,34 +119,61 @@ function NavItem({ icon, label, active, onClick, disabled }: NavItemProps) {
|
||||
disabled={disabled}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
title={collapsed ? label : undefined}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 9,
|
||||
gap: 10,
|
||||
width: "100%",
|
||||
padding: "7px 10px",
|
||||
borderRadius: 7,
|
||||
padding: "9px 10px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
color,
|
||||
color: fg,
|
||||
background: bg,
|
||||
fontSize: 12,
|
||||
textAlign: "left",
|
||||
marginBottom: 1,
|
||||
transition: "background 0.12s, color 0.12s",
|
||||
transition: "background 0.15s, color 0.15s",
|
||||
fontFamily: "inherit",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* Active indicator */}
|
||||
{active && (
|
||||
<div style={{
|
||||
position: "absolute", left: 0, top: "20%", bottom: "20%",
|
||||
width: 2, borderRadius: "0 2px 2px 0",
|
||||
background: "linear-gradient(to bottom, #7b5cf6, #22d3ee)",
|
||||
}} />
|
||||
)}
|
||||
<span style={{ width: 20, height: 20, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<span style={{ fontSize: 13, fontWeight: 600, flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{!collapsed && badge != null && badge > 0 && (
|
||||
<span style={{
|
||||
fontSize: 9, fontWeight: 700, padding: "2px 6px", borderRadius: 20,
|
||||
background: "linear-gradient(135deg, #7b5cf6, #22d3ee)", color: "white",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sidebar ────────────────────────────────────────────────────────────────
|
||||
// ── Sidebar ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function Sidebar({ children }: { children: React.ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -152,14 +183,12 @@ export function Sidebar({ children }: { children: React.ReactNode }) {
|
||||
queryFn: () => api.get<MemberRead>("/auth/me"),
|
||||
});
|
||||
|
||||
// Derive active band from the current URL
|
||||
const bandMatch =
|
||||
matchPath("/bands/:bandId/*", location.pathname) ??
|
||||
matchPath("/bands/:bandId", location.pathname);
|
||||
const activeBandId = bandMatch?.params?.bandId ?? null;
|
||||
const activeBand = bands?.find((b) => b.id === activeBandId) ?? null;
|
||||
|
||||
// Nav active states
|
||||
const isLibrary = !!(
|
||||
matchPath({ path: "/bands/:bandId", end: true }, location.pathname) ||
|
||||
matchPath("/bands/:bandId/sessions/:sessionId", location.pathname) ||
|
||||
@@ -170,11 +199,9 @@ export function Sidebar({ children }: { children: React.ReactNode }) {
|
||||
const isBandSettings = !!matchPath("/bands/:bandId/settings/*", location.pathname);
|
||||
const bandSettingsPanel = matchPath("/bands/:bandId/settings/:panel", location.pathname)?.params?.panel ?? null;
|
||||
|
||||
// Player state
|
||||
const { currentSongId, currentBandId: playerBandId, isPlaying: isPlayerPlaying } = usePlayerStore();
|
||||
const hasActiveSong = !!currentSongId && !!playerBandId;
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!dropdownOpen) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
@@ -186,231 +213,126 @@ export function Sidebar({ children }: { children: React.ReactNode }) {
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [dropdownOpen]);
|
||||
|
||||
const sidebarWidth = collapsed ? 68 : 230;
|
||||
const border = "rgba(255,255,255,0.06)";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
height: "100vh",
|
||||
overflow: "hidden",
|
||||
background: "#0f0f12",
|
||||
color: "#eeeef2",
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{/* ── Sidebar ──────────────────────────────────────────────────── */}
|
||||
<aside
|
||||
style={{
|
||||
width: 210,
|
||||
minWidth: 210,
|
||||
background: "#0b0b0e",
|
||||
<div style={{ display: "flex", height: "100vh", overflow: "hidden", background: "#0c0e1a", color: "#e8e9f0", fontFamily: "-apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif", fontSize: 13 }}>
|
||||
|
||||
{/* ── Sidebar ── */}
|
||||
<aside style={{
|
||||
width: sidebarWidth,
|
||||
minWidth: sidebarWidth,
|
||||
background: "#10131f",
|
||||
borderRight: `1px solid ${border}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div
|
||||
style={{
|
||||
padding: "17px 14px 14px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
borderBottom: `1px solid ${border}`,
|
||||
transition: "width 0.22s cubic-bezier(0.4,0,0.2,1), min-width 0.22s cubic-bezier(0.4,0,0.2,1)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
zIndex: 20,
|
||||
}}>
|
||||
|
||||
{/* Logo / toggle */}
|
||||
<div style={{ padding: "18px 14px 14px", display: "flex", alignItems: "center", gap: 10, borderBottom: `1px solid ${border}`, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
title={collapsed ? "Expand menu" : "Collapse menu"}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
background: "#e8a22a",
|
||||
borderRadius: 7,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
width: 40, height: 40, borderRadius: 12, flexShrink: 0,
|
||||
background: "linear-gradient(135deg, #7b5cf6, #06b6d4)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
border: "none", cursor: "pointer",
|
||||
boxShadow: "0 0 20px rgba(139,92,246,0.3)",
|
||||
transition: "box-shadow 0.2s",
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = "0 0 30px rgba(139,92,246,0.5)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "0 0 20px rgba(139,92,246,0.3)")}
|
||||
>
|
||||
<IconWaveform />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: "#eeeef2", letterSpacing: -0.2 }}>
|
||||
<IconMenu />
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<div style={{ overflow: "hidden" }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: "#e8e9f0", letterSpacing: -0.3, whiteSpace: "nowrap" }}>
|
||||
RehearsalHub
|
||||
</div>
|
||||
{activeBand && (
|
||||
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.25)", marginTop: 1 }}>
|
||||
{activeBand.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Band switcher */}
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
style={{
|
||||
padding: "10px 8px",
|
||||
borderBottom: `1px solid ${border}`,
|
||||
position: "relative",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div ref={dropdownRef} style={{ padding: "10px 12px", borderBottom: `1px solid ${border}`, position: "relative", flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => setDropdownOpen((o) => !o)}
|
||||
title={collapsed ? (activeBand?.name ?? "Select band") : undefined}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "7px 9px",
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
width: "100%", display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "7px 8px",
|
||||
background: "rgba(255,255,255,0.04)",
|
||||
border: "1px solid rgba(255,255,255,0.07)",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
color: "#eeeef2",
|
||||
textAlign: "left",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
background: "rgba(232,162,42,0.15)",
|
||||
border: "1px solid rgba(232,162,42,0.3)",
|
||||
borderRadius: 7,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: "#e8a22a",
|
||||
flexShrink: 0,
|
||||
borderRadius: 8, cursor: "pointer", color: "#e8e9f0",
|
||||
textAlign: "left", fontFamily: "inherit",
|
||||
transition: "border-color 0.15s",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.borderColor = "rgba(255,255,255,0.12)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.borderColor = "rgba(255,255,255,0.07)")}
|
||||
>
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: 8, flexShrink: 0,
|
||||
background: "rgba(139,92,246,0.15)",
|
||||
border: "1px solid rgba(139,92,246,0.3)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: 10, fontWeight: 800, color: "#a78bfa",
|
||||
}}>
|
||||
{activeBand ? getInitials(activeBand.name) : "?"}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span style={{ flex: 1, fontSize: 12, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{activeBand?.name ?? "Select a band"}
|
||||
</span>
|
||||
<span style={{ opacity: 0.3, flexShrink: 0, display: "flex" }}>
|
||||
<IconChevron />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "calc(100% - 2px)",
|
||||
left: 8,
|
||||
right: 8,
|
||||
background: "#18181e",
|
||||
<div style={{
|
||||
position: "absolute", top: "calc(100% - 2px)",
|
||||
left: 12, right: 12,
|
||||
background: "#1a1e30",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
borderRadius: 10,
|
||||
padding: 6,
|
||||
zIndex: 100,
|
||||
borderRadius: 10, padding: 6, zIndex: 100,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
{bands?.map((band) => (
|
||||
<button
|
||||
key={band.id}
|
||||
onClick={() => {
|
||||
navigate(`/bands/${band.id}`);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
onClick={() => { navigate(`/bands/${band.id}`); setDropdownOpen(false); }}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "7px 9px",
|
||||
marginBottom: 1,
|
||||
background: band.id === activeBandId ? "rgba(232,162,42,0.08)" : "transparent",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
color: "#eeeef2",
|
||||
textAlign: "left",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 5,
|
||||
background: "rgba(232,162,42,0.15)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: "#e8a22a",
|
||||
flexShrink: 0,
|
||||
width: "100%", display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "7px 9px", marginBottom: 1,
|
||||
background: band.id === activeBandId ? "rgba(139,92,246,0.1)" : "transparent",
|
||||
border: "none", borderRadius: 6, cursor: "pointer",
|
||||
color: "#e8e9f0", textAlign: "left", fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 22, height: 22, borderRadius: 6, background: "rgba(139,92,246,0.15)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 9, fontWeight: 700, color: "#a78bfa", flexShrink: 0 }}>
|
||||
{getInitials(band.name)}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
color: "rgba(255,255,255,0.62)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "rgba(232,233,240,0.7)" }}>
|
||||
{band.name}
|
||||
</span>
|
||||
{band.id === activeBandId && (
|
||||
<span style={{ fontSize: 10, color: "#e8a22a", flexShrink: 0 }}>✓</span>
|
||||
)}
|
||||
{band.id === activeBandId && <span style={{ fontSize: 10, color: "#a78bfa", flexShrink: 0 }}>✓</span>}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid rgba(255,255,255,0.06)",
|
||||
marginTop: 4,
|
||||
paddingTop: 4,
|
||||
}}
|
||||
>
|
||||
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)", marginTop: 4, paddingTop: 4 }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate("/");
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "7px 9px",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
color: "rgba(255,255,255,0.35)",
|
||||
fontSize: 12,
|
||||
textAlign: "left",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
onClick={() => { navigate("/"); setDropdownOpen(false); }}
|
||||
style={{ width: "100%", display: "flex", alignItems: "center", gap: 8, padding: "7px 9px", background: "transparent", border: "none", borderRadius: 6, cursor: "pointer", color: "rgba(232,233,240,0.35)", fontSize: 12, textAlign: "left", fontFamily: "inherit" }}
|
||||
>
|
||||
<span style={{ fontSize: 14, opacity: 0.5 }}>+</span>
|
||||
Create new band
|
||||
@@ -421,205 +343,75 @@ export function Sidebar({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav style={{ flex: 1, padding: "10px 8px", overflowY: "auto" }}>
|
||||
<nav style={{ flex: 1, padding: "10px 12px", overflowY: "auto", overflowX: "hidden", display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{activeBand && (
|
||||
<>
|
||||
<SectionLabel>{activeBand.name}</SectionLabel>
|
||||
<NavItem
|
||||
icon={<IconLibrary />}
|
||||
label="Library"
|
||||
active={isLibrary}
|
||||
onClick={() => navigate(`/bands/${activeBand.id}`)}
|
||||
/>
|
||||
<NavItem icon={<IconLibrary />} label="Library" active={isLibrary} onClick={() => navigate(`/bands/${activeBand.id}`)} collapsed={collapsed} />
|
||||
<NavItem
|
||||
icon={<IconPlay />}
|
||||
label="Player"
|
||||
label="Now Playing"
|
||||
active={hasActiveSong && (isPlayer || isPlayerPlaying)}
|
||||
onClick={() => {
|
||||
if (hasActiveSong) {
|
||||
navigate(`/bands/${playerBandId}/songs/${currentSongId}`);
|
||||
}
|
||||
}}
|
||||
onClick={() => { if (hasActiveSong) navigate(`/bands/${playerBandId}/songs/${currentSongId}`); }}
|
||||
disabled={!hasActiveSong}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeBand && (
|
||||
<>
|
||||
<SectionLabel style={{ paddingTop: 14 }}>Band Settings</SectionLabel>
|
||||
<NavItem
|
||||
icon={<IconMembers />}
|
||||
label="Members"
|
||||
active={isBandSettings && bandSettingsPanel === "members"}
|
||||
onClick={() => navigate(`/bands/${activeBand.id}/settings/members`)}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<IconStorage />}
|
||||
label="Storage"
|
||||
active={isBandSettings && bandSettingsPanel === "storage"}
|
||||
onClick={() => navigate(`/bands/${activeBand.id}/settings/storage`)}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<IconSettings />}
|
||||
label="Band Settings"
|
||||
active={isBandSettings && bandSettingsPanel === "band"}
|
||||
onClick={() => navigate(`/bands/${activeBand.id}/settings/band`)}
|
||||
/>
|
||||
<div style={{ height: 1, background: border, margin: "10px 0", flexShrink: 0 }} />
|
||||
<NavItem icon={<IconMembers />} label="Members" active={isBandSettings && bandSettingsPanel === "members"} onClick={() => navigate(`/bands/${activeBand.id}/settings/members`)} collapsed={collapsed} />
|
||||
<NavItem icon={<IconStorage />} label="Storage" active={isBandSettings && bandSettingsPanel === "storage"} onClick={() => navigate(`/bands/${activeBand.id}/settings/storage`)} collapsed={collapsed} />
|
||||
<NavItem icon={<IconSettings />} label="Band Settings" active={isBandSettings && bandSettingsPanel === "band"} onClick={() => navigate(`/bands/${activeBand.id}/settings/band`)} collapsed={collapsed} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<SectionLabel style={{ paddingTop: 14 }}>Account</SectionLabel>
|
||||
<NavItem
|
||||
icon={<IconSettings />}
|
||||
label="Settings"
|
||||
active={isSettings}
|
||||
onClick={() => navigate("/settings")}
|
||||
/>
|
||||
<div style={{ height: 1, background: border, margin: "10px 0", flexShrink: 0 }} />
|
||||
<NavItem icon={<IconSettings />} label="Settings" active={isSettings} onClick={() => navigate("/settings")} collapsed={collapsed} />
|
||||
</nav>
|
||||
|
||||
{/* User row */}
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
borderTop: `1px solid ${border}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "10px 12px", borderTop: `1px solid ${border}`, flexShrink: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<button
|
||||
onClick={() => navigate("/settings")}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "6px 8px",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
color: "#eeeef2",
|
||||
textAlign: "left",
|
||||
minWidth: 0,
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
title={collapsed ? (me?.display_name ?? "Account") : undefined}
|
||||
style={{ flex: 1, display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", background: "transparent", border: "none", borderRadius: 8, cursor: "pointer", color: "#e8e9f0", textAlign: "left", minWidth: 0, fontFamily: "inherit", overflow: "hidden" }}
|
||||
>
|
||||
{me?.avatar_url ? (
|
||||
<img
|
||||
src={me.avatar_url}
|
||||
alt=""
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<img src={me.avatar_url} alt="" style={{ width: 28, height: 28, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }} />
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(232,162,42,0.18)",
|
||||
border: "1.5px solid rgba(232,162,42,0.35)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: "#e8a22a",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 28, height: 28, borderRadius: "50%", background: "rgba(52,211,153,0.15)", border: "1.5px solid rgba(52,211,153,0.3)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 10, fontWeight: 700, color: "#34d399", flexShrink: 0 }}>
|
||||
{getInitials(me?.display_name ?? "?")}
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
color: "rgba(255,255,255,0.55)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{!collapsed && (
|
||||
<span style={{ flex: 1, fontSize: 12, color: "rgba(232,233,240,0.55)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{me?.display_name ?? "…"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<button
|
||||
onClick={() => logout()}
|
||||
title="Sign out"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 7,
|
||||
background: "transparent",
|
||||
border: "1px solid transparent",
|
||||
cursor: "pointer",
|
||||
color: "rgba(255,255,255,0.2)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "border-color 0.12s, color 0.12s",
|
||||
padding: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "rgba(255,255,255,0.1)";
|
||||
e.currentTarget.style.color = "rgba(255,255,255,0.5)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "transparent";
|
||||
e.currentTarget.style.color = "rgba(255,255,255,0.2)";
|
||||
}}
|
||||
style={{ flexShrink: 0, width: 30, height: 30, borderRadius: 7, background: "transparent", border: "1px solid transparent", cursor: "pointer", color: "rgba(255,255,255,0.2)", display: "flex", alignItems: "center", justifyContent: "center", transition: "border-color 0.12s, color 0.12s", padding: 0 }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "rgba(255,255,255,0.1)"; e.currentTarget.style.color = "rgba(255,255,255,0.5)"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "transparent"; e.currentTarget.style.color = "rgba(255,255,255,0.2)"; }}
|
||||
>
|
||||
<IconSignOut />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── Main content ──────────────────────────────────────────────── */}
|
||||
<main
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "#0f0f12",
|
||||
}}
|
||||
>
|
||||
{/* ── Main content ── */}
|
||||
<main style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column", background: "#0c0e1a", minWidth: 0 }}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
color: "rgba(255,255,255,0.2)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.7px",
|
||||
padding: "0 6px 5px",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,23 +16,30 @@ input, textarea, button, select {
|
||||
|
||||
/* ── Design system (dark only — no light mode in v1) ─────────────────────── */
|
||||
:root {
|
||||
--bg: #0f0f12;
|
||||
/* v2 dark-space palette */
|
||||
--bg: #0c0e1a;
|
||||
--bg-card: #10131f;
|
||||
--bg-raised: #151828;
|
||||
--bg-hover: #1a1e30;
|
||||
--bg-subtle: rgba(255,255,255,0.025);
|
||||
--bg-inset: rgba(255,255,255,0.04);
|
||||
--border: rgba(255,255,255,0.08);
|
||||
--border-subtle: rgba(255,255,255,0.05);
|
||||
--text: #eeeef2;
|
||||
--text-muted: rgba(255,255,255,0.35);
|
||||
--text-subtle: rgba(255,255,255,0.22);
|
||||
--accent: #e8a22a;
|
||||
--accent-hover: #f0b740;
|
||||
--accent-bg: rgba(232,162,42,0.1);
|
||||
--accent-border: rgba(232,162,42,0.28);
|
||||
--accent-fg: #0f0f12;
|
||||
--teal: #4dba85;
|
||||
--teal-bg: rgba(61,200,120,0.1);
|
||||
--danger: #e07070;
|
||||
--danger-bg: rgba(220,80,80,0.1);
|
||||
--border: rgba(255,255,255,0.06);
|
||||
--border-bright: rgba(255,255,255,0.12);
|
||||
--border-subtle: rgba(255,255,255,0.04);
|
||||
--text: #e8e9f0;
|
||||
--text-muted: rgba(232,233,240,0.55);
|
||||
--text-subtle: rgba(232,233,240,0.28);
|
||||
/* Violet accent */
|
||||
--accent: #8b5cf6;
|
||||
--accent-light: #a78bfa;
|
||||
--accent-hover: #9f70f8;
|
||||
--accent-bg: rgba(139,92,246,0.12);
|
||||
--accent-border: rgba(139,92,246,0.3);
|
||||
--accent-fg: #ffffff;
|
||||
--teal: #34d399;
|
||||
--teal-bg: rgba(52,211,153,0.1);
|
||||
--danger: #f43f5e;
|
||||
--danger-bg: rgba(244,63,94,0.1);
|
||||
}
|
||||
|
||||
/* ── Responsive Layout ──────────────────────────────────────────────────── */
|
||||
|
||||
@@ -1,50 +1,44 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getBand } from "../api/bands";
|
||||
import { api } from "../api/client";
|
||||
import { LibraryPanel } from "../components/LibraryPanel";
|
||||
import { PlayerPanel } from "../components/PlayerPanel";
|
||||
|
||||
interface SongSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
tags: string[];
|
||||
global_key: string | null;
|
||||
global_bpm: number | null;
|
||||
version_count: number;
|
||||
// ── Empty player state ────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyPlayer() {
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", background: "#0c0e1a", gap: 12, padding: 40 }}>
|
||||
<div style={{ width: 64, height: 64, borderRadius: 20, background: "rgba(139,92,246,0.08)", border: "1px solid rgba(139,92,246,0.15)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||
<path d="M7 14c0-4.5 3.5-7 7-7s7 2.5 7 7v5L23 22H5l2-3v-5z" stroke="rgba(139,92,246,0.5)" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M10 22a4 4 0 0 0 8 0" stroke="rgba(139,92,246,0.5)" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: "rgba(232,233,240,0.28)", margin: 0, textAlign: "center" }}>
|
||||
Select a track from the library to start listening
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SessionSummary {
|
||||
id: string;
|
||||
date: string;
|
||||
label: string | null;
|
||||
recording_count: number;
|
||||
}
|
||||
|
||||
type FilterPill = "all" | "full band" | "guitar" | "vocals" | "drums" | "keys" | "commented";
|
||||
|
||||
const PILLS: FilterPill[] = ["all", "full band", "guitar", "vocals", "drums", "keys", "commented"];
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso.slice(0, 10) + "T12:00:00");
|
||||
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function formatDateLabel(iso: string): string {
|
||||
const d = new Date(iso.slice(0, 10) + "T12:00:00");
|
||||
const today = new Date();
|
||||
today.setHours(12, 0, 0, 0);
|
||||
const diffDays = Math.round((today.getTime() - d.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (diffDays === 0) {
|
||||
return "Today — " + d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
// ── BandPage ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BandPage() {
|
||||
const { bandId } = useParams<{ bandId: string }>();
|
||||
const [librarySearch, setLibrarySearch] = useState("");
|
||||
const [activePill, setActivePill] = useState<FilterPill>("all");
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// selectedSongId is kept in URL as ?song=<id> so deep-links and browser back work
|
||||
const selectedSongId = searchParams.get("song");
|
||||
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 900);
|
||||
check();
|
||||
window.addEventListener("resize", check);
|
||||
return () => window.removeEventListener("resize", check);
|
||||
}, []);
|
||||
|
||||
const { data: band, isLoading } = useQuery({
|
||||
queryKey: ["band", bandId],
|
||||
@@ -52,239 +46,63 @@ export function BandPage() {
|
||||
enabled: !!bandId,
|
||||
});
|
||||
|
||||
const { data: sessions } = useQuery({
|
||||
queryKey: ["sessions", bandId],
|
||||
queryFn: () => api.get<SessionSummary[]>(`/bands/${bandId}/sessions`),
|
||||
enabled: !!bandId,
|
||||
});
|
||||
function selectSong(songId: string) {
|
||||
setSearchParams({ song: songId }, { replace: false });
|
||||
}
|
||||
|
||||
const { data: unattributedSongs } = useQuery({
|
||||
queryKey: ["songs-unattributed", bandId],
|
||||
queryFn: () => api.get<SongSummary[]>(`/bands/${bandId}/songs/search?unattributed=true`),
|
||||
enabled: !!bandId,
|
||||
});
|
||||
function clearSong() {
|
||||
setSearchParams({}, { replace: false });
|
||||
}
|
||||
|
||||
const filteredSessions = useMemo(() => {
|
||||
return (sessions ?? []).filter((s) => {
|
||||
if (!librarySearch) return true;
|
||||
const haystack = [s.label ?? "", s.date, formatDate(s.date)].join(" ").toLowerCase();
|
||||
return haystack.includes(librarySearch.toLowerCase());
|
||||
});
|
||||
}, [sessions, librarySearch]);
|
||||
if (isLoading) return <div style={{ color: "rgba(232,233,240,0.35)", padding: 32 }}>Loading…</div>;
|
||||
if (!band || !bandId) return <div style={{ color: "#f87171", padding: 32 }}>Band not found</div>;
|
||||
|
||||
const filteredUnattributed = useMemo(() => {
|
||||
return (unattributedSongs ?? []).filter((song) => {
|
||||
const matchesSearch =
|
||||
!librarySearch || song.title.toLowerCase().includes(librarySearch.toLowerCase());
|
||||
const matchesPill =
|
||||
activePill === "all" ||
|
||||
activePill === "commented" ||
|
||||
song.tags.some((t) => t.toLowerCase() === activePill);
|
||||
return matchesSearch && matchesPill;
|
||||
});
|
||||
}, [unattributedSongs, librarySearch, activePill]);
|
||||
|
||||
|
||||
if (isLoading) return <div style={{ color: "var(--text-muted)", padding: 32 }}>Loading...</div>;
|
||||
if (!band) return <div style={{ color: "var(--danger)", padding: 32 }}>Band not found</div>;
|
||||
|
||||
const hasResults = filteredSessions.length > 0 || filteredUnattributed.length > 0;
|
||||
// ── Mobile: show library OR player, not both ──────────────────────────────
|
||||
|
||||
if (isMobile) {
|
||||
if (selectedSongId) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", maxWidth: 760, margin: "0 auto" }}>
|
||||
|
||||
{/* ── Header ─────────────────────────────────────────────── */}
|
||||
<div style={{ padding: "18px 26px 0", flexShrink: 0, borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
|
||||
{/* Title row + search + actions */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 11 }}>
|
||||
<h1 style={{ fontSize: 17, fontWeight: 500, color: "#eeeef2", margin: 0, flexShrink: 0 }}>
|
||||
Library
|
||||
</h1>
|
||||
|
||||
{/* Search input */}
|
||||
<div style={{ position: "relative", flex: 1, maxWidth: 280 }}>
|
||||
<svg
|
||||
style={{ position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)", opacity: 0.3, pointerEvents: "none", color: "#eeeef2" }}
|
||||
width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" strokeWidth="1.5"
|
||||
>
|
||||
<circle cx="5.5" cy="5.5" r="3.5" />
|
||||
<path d="M8.5 8.5l3 3" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
value={librarySearch}
|
||||
onChange={(e) => setLibrarySearch(e.target.value)}
|
||||
placeholder="Search recordings, comments…"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "7px 12px 7px 30px",
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
borderRadius: 7,
|
||||
color: "#e2e2e8",
|
||||
fontSize: 13,
|
||||
fontFamily: "inherit",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
onFocus={(e) => (e.currentTarget.style.borderColor = "rgba(232,162,42,0.35)")}
|
||||
onBlur={(e) => (e.currentTarget.style.borderColor = "rgba(255,255,255,0.08)")}
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
<PlayerPanel
|
||||
key={selectedSongId}
|
||||
songId={selectedSongId}
|
||||
bandId={bandId}
|
||||
onBack={clearSong}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter pills */}
|
||||
<div style={{ display: "flex", gap: 5, flexWrap: "wrap", paddingBottom: 14 }}>
|
||||
{PILLS.map((pill) => {
|
||||
const active = activePill === pill;
|
||||
return (
|
||||
<button
|
||||
key={pill}
|
||||
onClick={() => setActivePill(pill)}
|
||||
style={{
|
||||
padding: "3px 10px",
|
||||
borderRadius: 20,
|
||||
cursor: "pointer",
|
||||
border: `1px solid ${active ? "rgba(232,162,42,0.28)" : "rgba(255,255,255,0.08)"}`,
|
||||
background: active ? "rgba(232,162,42,0.1)" : "transparent",
|
||||
color: active ? "#e8a22a" : "rgba(255,255,255,0.3)",
|
||||
fontSize: 11,
|
||||
fontFamily: "inherit",
|
||||
transition: "all 0.12s",
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
>
|
||||
{pill}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
return (
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
<LibraryPanel
|
||||
bandId={bandId}
|
||||
selectedSongId={selectedSongId}
|
||||
onSelectSong={selectSong}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* ── Scrollable content ────────────────────────────────── */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "4px 26px 26px" }}>
|
||||
// ── Desktop: three-panel (Sidebar is handled by AppShell, we add Library + Player) ──
|
||||
|
||||
{/* Sessions — one date group per session */}
|
||||
{filteredSessions.map((s) => (
|
||||
<div key={s.id} style={{ marginTop: 18 }}>
|
||||
{/* Date group header */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 500, color: "rgba(255,255,255,0.32)", textTransform: "uppercase", letterSpacing: "0.6px", whiteSpace: "nowrap" }}>
|
||||
{formatDateLabel(s.date)}{s.label ? ` — ${s.label}` : ""}
|
||||
</span>
|
||||
<div style={{ flex: 1, height: 1, background: "rgba(255,255,255,0.05)" }} />
|
||||
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.18)", whiteSpace: "nowrap" }}>
|
||||
{s.recording_count} recording{s.recording_count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100%", overflow: "hidden" }}>
|
||||
<LibraryPanel
|
||||
bandId={bandId}
|
||||
selectedSongId={selectedSongId}
|
||||
onSelectSong={selectSong}
|
||||
/>
|
||||
|
||||
{/* Session row */}
|
||||
<Link
|
||||
to={`/bands/${bandId}/sessions/${s.id}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 11,
|
||||
padding: "9px 13px",
|
||||
borderRadius: 8,
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
border: "1px solid rgba(255,255,255,0.04)",
|
||||
textDecoration: "none",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.12s, border-color 0.12s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.048)";
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.08)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.02)";
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.04)";
|
||||
}}
|
||||
>
|
||||
{/* Session name */}
|
||||
<span style={{ flex: 1, fontSize: 13, color: "#c8c8d0", fontFamily: "'SF Mono','Fira Code',monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{s.label ?? formatDate(s.date)}
|
||||
</span>
|
||||
|
||||
{/* Recording count */}
|
||||
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{s.recording_count}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Unattributed recordings */}
|
||||
{filteredUnattributed.length > 0 && (
|
||||
<div style={{ marginTop: filteredSessions.length > 0 ? 28 : 18 }}>
|
||||
{/* Section header */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 500, color: "rgba(255,255,255,0.32)", textTransform: "uppercase", letterSpacing: "0.6px", whiteSpace: "nowrap" }}>
|
||||
Unattributed
|
||||
</span>
|
||||
<div style={{ flex: 1, height: 1, background: "rgba(255,255,255,0.05)" }} />
|
||||
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.18)", whiteSpace: "nowrap" }}>
|
||||
{filteredUnattributed.length} track{filteredUnattributed.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: 3 }}>
|
||||
{filteredUnattributed.map((song) => (
|
||||
<Link
|
||||
key={song.id}
|
||||
to={`/bands/${bandId}/songs/${song.id}`}
|
||||
style={{ display: "flex", alignItems: "center", gap: 11, padding: "9px 13px", borderRadius: 8, background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.04)", textDecoration: "none", transition: "background 0.12s, border-color 0.12s" }}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.048)";
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.08)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.02)";
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.04)";
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, color: "#c8c8d0", fontFamily: "'SF Mono','Fira Code',monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", marginBottom: 3 }}>
|
||||
{song.title}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
|
||||
{song.tags.map((t) => (
|
||||
<span key={t} style={{ background: "rgba(61,200,120,0.08)", color: "#4dba85", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
{song.global_key && (
|
||||
<span style={{ background: "rgba(255,255,255,0.05)", color: "rgba(255,255,255,0.28)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>
|
||||
{song.global_key}
|
||||
</span>
|
||||
{selectedSongId ? (
|
||||
<PlayerPanel
|
||||
key={selectedSongId}
|
||||
songId={selectedSongId}
|
||||
bandId={bandId}
|
||||
onBack={clearSong}
|
||||
/>
|
||||
) : (
|
||||
<EmptyPlayer />
|
||||
)}
|
||||
{song.global_bpm && (
|
||||
<span style={{ background: "rgba(255,255,255,0.05)", color: "rgba(255,255,255,0.28)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>
|
||||
{song.global_bpm.toFixed(0)} BPM
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{song.version_count} ver{song.version_count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!hasResults && (
|
||||
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13, padding: "24px 0 8px" }}>
|
||||
{librarySearch
|
||||
? "No results match your search."
|
||||
: "No sessions yet. Go to Storage settings to scan your Nextcloud folder."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user