From d73377ec2fc47600deb416126bb8d0b9dbbbcbee Mon Sep 17 00:00:00 2001 From: Mistral Vibe Date: Thu, 9 Apr 2026 23:40:37 +0200 Subject: [PATCH] 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 --- web/src/components/LibraryPanel.tsx | 374 ++++++++++ web/src/components/PlayerPanel.tsx | 472 ++++++++++++ web/src/components/Sidebar.tsx | 620 ++++++---------- web/src/index.css | 37 +- web/src/pages/BandPage.tsx | 346 +++------ web/src/pages/SongPage.tsx | 1031 +-------------------------- 6 files changed, 1168 insertions(+), 1712 deletions(-) create mode 100644 web/src/components/LibraryPanel.tsx create mode 100644 web/src/components/PlayerPanel.tsx diff --git a/web/src/components/LibraryPanel.tsx b/web/src/components/LibraryPanel.tsx new file mode 100644 index 0000000..08801f2 --- /dev/null +++ b/web/src/components/LibraryPanel.tsx @@ -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 ( +
+ {bars.map((h, i) => ( +
+ ))} +
+ ); +} + +// ── Tag badge ───────────────────────────────────────────────────────────────── + +const TAG_COLORS: Record = { + 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 ( + + {tag} + + ); +} + +// ── Track row ───────────────────────────────────────────────────────────────── + +function TrackRow({ + song, + index, + active, + onSelect, +}: { + song: SongSummary; + index: number; + active: boolean; + onSelect: () => void; +}) { + const [hovered, setHovered] = useState(false); + + return ( +
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 && ( +
+ )} + + + {String(index + 1).padStart(2, "0")} + + + + {song.title} + + + {song.tags[0] && } + + +
+ ); +} + +// ── 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(`/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 ( +
+ {/* Session header */} +
setIsOpen((o) => !o)} + style={{ + display: "flex", + alignItems: "center", + gap: 8, + padding: "8px 20px 5px", + cursor: "pointer", + }} + > + + {formatSessionDate(session.date)} + + {session.label && ( + + {session.label} + + )} + {!session.label && } + + {session.recording_count} + + + + +
+ + {/* Track list */} + {isOpen && ( +
+ {!detail && ( +
+ Loading… +
+ )} + {detail && (filteredSongs.length > 0 ? filteredSongs : detail.songs).map((song, i) => ( + onSelectSong(song.id)} + /> + ))} + {detail && detail.songs.length === 0 && ( +
+ No recordings yet. +
+ )} + {detail && search && filteredSongs.length === 0 && detail.songs.length > 0 && ( +
+ No matches in this session. +
+ )} +
+ )} +
+ ); +} + +// ── 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(`/bands/${bandId}/sessions`), + enabled: !!bandId, + }); + + const border = "rgba(255,255,255,0.06)"; + + return ( +
+ + {/* Header */} +
+

+ Library +

+ + {/* Search */} +
(e.currentTarget.style.borderColor = "rgba(139,92,246,0.4)")} + onBlurCapture={(e) => (e.currentTarget.style.borderColor = border)} + > + + + + + 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 && ( + + )} +
+
+ + {/* Filter chips */} +
+ {FILTER_CHIPS.map((chip) => { + const on = filterTag === chip.value; + return ( + + ); + })} +
+ + {/* Session list */} +
+ + {!sessions && ( +
Loading sessions…
+ )} + {sessions?.length === 0 && ( +
+ No sessions yet. Go to Storage settings to scan your Nextcloud folder. +
+ )} + {sessions?.map((session, i) => ( + + ))} + {/* Bottom padding for last item breathing room */} +
+
+
+ ); +} diff --git a/web/src/components/PlayerPanel.tsx b/web/src/components/PlayerPanel.tsx new file mode 100644 index 0000000..03de6e5 --- /dev/null +++ b/web/src/components/PlayerPanel.tsx @@ -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 = { + 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 {name}; + return ( +
+ {getInitials(name)} +
+ ); +} + +function TransportBtn({ onClick, title, children }: { onClick: () => void; title?: string; children: React.ReactNode }) { + const [hovered, setHovered] = useState(false); + return ( + + ); +} + +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(null); + const pinned = comments.filter((c) => c.timestamp != null); + + return ( +
+ {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 ( +
setHoveredId(c.id)} onMouseLeave={() => setHoveredId(null)} + onClick={() => { onSeek(c.timestamp!); onScrollToComment(c.id); }} + > + {isHovered && ( +
+
+
+ {getInitials(c.author_name)} +
+ {c.author_name} + {formatTime(c.timestamp!)} +
+
+ {c.body.length > 80 ? c.body.slice(0, 80) + "…" : c.body} +
+
+ )} +
+ {getInitials(c.author_name)} +
+
+
+ ); + })} +
+ ); +} + +// ── Icons ───────────────────────────────────────────────────────────────────── + +function IconSkipBack() { + return ; +} +function IconSkipFwd() { + return ; +} +function IconPlay() { + return ; +} +function IconPause() { + return ; +} + +// ── 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(null); + const waveformContainerRef = useRef(null); + + const [selectedVersionId, setSelectedVersionId] = useState(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("/auth/me") }); + + const { data: song } = useQuery({ + queryKey: ["song", songId], + queryFn: () => api.get(`/songs/${songId}`), + enabled: !!songId, + }); + + const { data: versions } = useQuery({ + queryKey: ["versions", songId], + queryFn: () => api.get(`/songs/${songId}/versions`), + enabled: !!songId, + }); + + const { data: session } = useQuery({ + queryKey: ["session", song?.session_id], + queryFn: () => api.get(`/bands/${bandId}/sessions/${song!.session_id}`), + enabled: !!song?.session_id && !!bandId, + }); + + const { data: comments } = useQuery({ + queryKey: ["comments", songId], + queryFn: () => api.get(`/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 ( +
+ + {/* Breadcrumb / header */} +
+ + {session && ( + <> + + + + )} + + + {song?.title ?? "…"} + + + {/* Version selector */} + {versions && versions.length > 1 && ( +
+ {versions.map((v) => ( + + ))} +
+ )} + + +
+ + {/* Body */} +
+ + {/* Waveform section */} +
+
+
+ {song?.title ?? "…"} + {isReady ? formatTime(duration) : "—"} +
+ + {/* Pin layer + canvas */} +
+ {isReady && duration > 0 && comments && ( + + )} + {!isReady &&
} +
+ {error &&
Audio error: {error}
} + {!isReady && !error &&
Loading audio…
} +
+ + {/* Time bar */} +
+ {formatTime(currentTime)} + {isReady && duration > 0 ? formatTime(duration / 2) : "—"} + {isReady ? formatTime(duration) : "—"} +
+
+ + {/* Transport */} +
+ seekTo(Math.max(0, currentTime - 30))} title="−30s"> + + seekTo(currentTime + 30)} title="+30s"> +
+
+ + {/* Comments section */} +
+ + {/* Header */} +
+ Comments + {comments && comments.length > 0 && ( + {comments.length} + )} +
+ + {/* Compose */} +
+
+ {me ? ( + + ) : ( +
+ )} +
+
+
+ {isPlaying &&
} + {formatTime(currentTime)} +
+ · pins to playhead +
+