feat: fix song view layout - align waveform top, scrollable comments, compose section always visible

This commit is contained in:
Mistral Vibe
2026-04-07 14:36:14 +00:00
parent 5690c9d375
commit ff4985a719

View File

@@ -116,7 +116,8 @@ function Avatar({
}
return (
<div
style={{
style={
{
width: size,
height: size,
borderRadius: "50%",
@@ -129,7 +130,8 @@ function Avatar({
fontSize: size * 0.38,
fontWeight: 700,
flexShrink: 0,
}}
}
}
>
{getInitials(name)}
</div>
@@ -200,7 +202,8 @@ function WaveformPins({
return (
<div
key={c.id}
style={{
style={
{
position: "absolute",
left,
top: 0,
@@ -212,7 +215,8 @@ function WaveformPins({
zIndex: 10,
transition: "transform 0.12s",
...(isHovered ? { transform: "translateX(-50%) scale(1.15)" } : {}),
}}
}
}
onMouseEnter={() => setHoveredId(c.id)}
onMouseLeave={() => setHoveredId(null)}
onClick={() => {
@@ -223,7 +227,8 @@ function WaveformPins({
{/* Tooltip */}
{isHovered && (
<div
style={{
style={
{
position: "absolute",
bottom: "calc(100% + 6px)",
left: "50%",
@@ -235,11 +240,13 @@ function WaveformPins({
width: 180,
zIndex: 50,
pointerEvents: "none",
}}
}
}
>
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 4 }}>
<div
style={{
style={
{
width: 18,
height: 18,
borderRadius: "50%",
@@ -250,7 +257,8 @@ function WaveformPins({
justifyContent: "center",
fontSize: 8,
fontWeight: 700,
}}
}
}
>
{getInitials(c.author_name)}
</div>
@@ -266,7 +274,8 @@ function WaveformPins({
)}
{/* Avatar circle */}
<div
style={{
style={
{
width: 24,
height: 24,
borderRadius: "50%",
@@ -279,7 +288,8 @@ function WaveformPins({
fontSize: 9,
fontWeight: 700,
boxShadow: "0 2px 8px rgba(0,0,0,0.45)",
}}
}
}
>
{getInitials(c.author_name)}
</div>
@@ -307,7 +317,7 @@ export function SongPage() {
const [composeFocused, setComposeFocused] = useState(false);
const [waveformWidth, setWaveformWidth] = useState(0);
// ── Data fetching ────────────────────────────────────────────────────────
// ── Data fetching ──────────────────────────────────────────────────────
const { data: me } = useQuery({
queryKey: ["me"],
@@ -338,7 +348,7 @@ export function SongPage() {
enabled: !!songId,
});
// ── Version selection ────────────────────────────────────────────────────
// ── Version selection ──────────────────────────────────────────────────
const activeVersion = selectedVersionId ?? versions?.[0]?.id ?? null;
@@ -361,7 +371,7 @@ export function SongPage() {
return () => ro.disconnect();
}, []);
// ── Keyboard shortcut: Space ──────────────────────────────────────────────
// ── Keyboard shortcut: Space ────────────────────────────────────────────
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -401,26 +411,27 @@ export function SongPage() {
}, []);
// ── Styles ────────────────────────────────────────────────────────────────
// ── Styles ──────────────────────────────────────────────────────────────
const border = "rgba(255,255,255,0.055)";
// ── Render ────────────────────────────────────────────────────────────────
// ── Render ──────────────────────────────────────────────────────────────
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden", background: "#0f0f12" }}>
{/* ── Breadcrumb header ──────────────────────────────────────────── */}
<div
style={{
style={
{
padding: "11px 20px",
borderBottom: `1px solid ${border}`,
display: "flex",
alignItems: "center",
gap: 8,
flexShrink: 0,
}}
}
}
>
<div style={{ display: "flex", alignItems: "center", gap: 5, flex: 1, minWidth: 0 }}>
<button
@@ -446,14 +457,16 @@ export function SongPage() {
)}
<span style={{ color: "rgba(255,255,255,0.15)", fontSize: 11 }}></span>
<span
style={{
style={
{
fontSize: 12,
color: "rgba(255,255,255,0.7)",
fontFamily: "'SF Mono', 'Fira Code', monospace",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
}
}
>
{song?.title ?? "…"}
</span>
@@ -466,7 +479,8 @@ export function SongPage() {
<button
key={v.id}
onClick={() => setSelectedVersionId(v.id)}
style={{
style={
{
background: v.id === activeVersion ? "rgba(232,162,42,0.14)" : "transparent",
border: `1px solid ${v.id === activeVersion ? "rgba(232,162,42,0.28)" : "rgba(255,255,255,0.09)"}`,
borderRadius: 6,
@@ -475,7 +489,8 @@ export function SongPage() {
cursor: "pointer",
fontSize: 11,
fontFamily: "monospace",
}}
}
}
>
v{v.version_number}{v.label ? ` · ${v.label}` : ""}
</button>
@@ -484,7 +499,8 @@ export function SongPage() {
)}
<button
style={{
style={
{
background: "transparent",
border: "1px solid rgba(255,255,255,0.09)",
borderRadius: 6,
@@ -494,28 +510,31 @@ export function SongPage() {
padding: "5px 12px",
fontFamily: "inherit",
flexShrink: 0,
}}
}
}
>
Share
</button>
</div>
{/* ── Body: waveform | comments ────────────────────────────────── */}
<div className="song-page-body" style={{ display: "flex", flex: 1, overflow: "hidden" }}>
<div className="song-page-body" style={{ display: "flex", flexDirection: "column", flex: 1, overflow: "hidden" }}>
{/* ── Left: waveform + transport ──────────────────────────────── */}
<div className="waveform-section" style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", padding: "16px 20px" }}>
{/* ── Waveform section (top) ──────────────────────────────── */}
<div className="waveform-section" style={{ display: "flex", flexDirection: "column", overflow: "hidden", padding: "16px 20px" }}>
{/* Waveform card */}
<div
style={{
style={
{
background: "rgba(255,255,255,0.02)",
border: `1px solid ${border}`,
borderRadius: 10,
padding: "14px 14px 10px",
marginBottom: 12,
flexShrink: 0,
}}
}
}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 6 }}>
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.45)", fontFamily: "monospace" }}>
@@ -559,13 +578,15 @@ export function SongPage() {
{/* Transport */}
<div
style={{
style={
{
display: "flex",
justifyContent: "center",
gap: 10,
padding: "4px 0 12px",
flexShrink: 0,
}}
}
}
>
{/* Skip back */}
<TransportButton onClick={() => seekTo(Math.max(0, currentTime - 30))} title="30s">
@@ -576,7 +597,8 @@ export function SongPage() {
<button
onClick={isPlaying ? pause : play}
disabled={!activeVersion}
style={{
style={
{
width: 46,
height: 46,
background: "#e8a22a",
@@ -589,7 +611,8 @@ export function SongPage() {
opacity: activeVersion ? 1 : 0.4,
flexShrink: 0,
transition: "background 0.15s, transform 0.15s",
}}
}
}
onMouseEnter={(e) => { if (activeVersion) e.currentTarget.style.background = "#f0b740"; }}
onMouseLeave={(e) => { e.currentTarget.style.background = "#e8a22a"; }}
>
@@ -606,49 +629,207 @@ export function SongPage() {
</div>
{/* ── Right: comment panel ──────────────────────────────────────── */}
{/* ── Comments section (bottom) ──────────────────────────────── */}
<div
className="comment-panel"
style={{
width: 280,
minWidth: 280,
borderLeft: `1px solid ${border}`,
style={
{
display: "flex",
flexDirection: "column",
overflow: "hidden",
background: "rgba(0,0,0,0.12)",
// Responsive: center on mobile
margin: "0 auto",
}}
borderTop: `1px solid ${border}`,
flex: 1,
}
}
>
{/* Header */}
<div
style={{
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(255,255,255,0.72)" }}>Comments</span>
{comments && comments.length > 0 && (
<span
style={{
style={
{
fontSize: 11,
background: "rgba(232,162,42,0.14)",
color: "#e8a22a",
padding: "1px 8px",
borderRadius: 10,
}}
}
}
>
{comments.length}
</span>
)}
</div>
{/* Comment list */}
{/* Compose section (moved to top) */}
<div
style={
{
padding: "11px 14px",
borderBottom: `1px solid ${border}`,
flexShrink: 0,
}
}
>
<div style={{ display: "flex", gap: 9, alignItems: "flex-start" }}>
{/* My avatar */}
{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 }}>
{/* Timestamp pill */}
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 6 }}>
<div
style={
{
fontSize: 11,
fontFamily: "monospace",
background: "rgba(232,162,42,0.1)",
color: "#e8a22a",
border: "1px solid rgba(232,162,42,0.22)",
padding: "3px 9px",
borderRadius: 20,
display: "flex",
alignItems: "center",
gap: 6,
}
}
>
{isPlaying && (
<div
style={
{
width: 6,
height: 6,
borderRadius: "50%",
background: "#e8a22a",
animation: "rh-blink 1.1s infinite",
}
}
/>
)}
{formatTime(currentTime)}
</div>
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.18)" }}>· pins to playhead</span>
</div>
{/* Textarea */}
<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.05)",
border: composeFocused
? "1px solid rgba(232,162,42,0.35)"
: "1px solid rgba(255,255,255,0.07)",
borderRadius: 7,
padding: "8px 10px",
color: "#e0e0e8",
fontSize: 12,
resize: "none",
outline: "none",
fontFamily: "inherit",
height: composeFocused ? 68 : 42,
transition: "height 0.18s, border-color 0.15s",
boxSizing: "border-box",
}
}
/>
{/* Tag buttons + Post (visible when focused) */}
{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:
selectedTag === tag
? `1px solid ${TAG_STYLES[tag].color}44`
: "1px solid rgba(255,255,255,0.07)",
color:
selectedTag === tag
? TAG_STYLES[tag].color
: "rgba(255,255,255,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: "#e8a22a",
border: "none",
color: "#0f0f12",
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>
{/* Scrollable comment list */}
<div style={{ flex: 1, overflowY: "auto", padding: "12px 14px" }}>
{comments?.map((c) => {
const tagStyle = c.tag ? TAG_STYLES[c.tag] : null;
@@ -658,7 +839,8 @@ export function SongPage() {
<div
key={c.id}
id={`comment-${c.id}`}
style={{
style={
{
marginBottom: 14,
paddingBottom: 14,
borderBottom: "1px solid rgba(255,255,255,0.04)",
@@ -666,7 +848,8 @@ export function SongPage() {
background: isNearPlayhead ? "rgba(232,162,42,0.04)" : undefined,
border: isNearPlayhead ? "1px solid rgba(232,162,42,0.12)" : undefined,
padding: isNearPlayhead ? 8 : undefined,
}}
}
}
>
{/* Author row */}
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 5 }}>
@@ -679,7 +862,8 @@ export function SongPage() {
onClick={() => {
seekTo(c.timestamp!);
}}
style={{
style={
{
marginLeft: "auto",
fontSize: 10,
fontFamily: "monospace",
@@ -689,7 +873,8 @@ export function SongPage() {
borderRadius: 3,
padding: "1px 5px",
cursor: "pointer",
}}
}
}
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(232,162,42,0.2)")}
onMouseLeave={(e) => (e.currentTarget.style.background = "rgba(232,162,42,0.1)")}
>
@@ -698,13 +883,15 @@ export function SongPage() {
)}
{tagStyle && (
<span
style={{
style={
{
fontSize: 10,
padding: "1px 5px",
borderRadius: 3,
background: tagStyle.bg,
color: tagStyle.color,
}}
}
}
>
{c.tag}
</span>
@@ -744,148 +931,6 @@ export function SongPage() {
)}
</div>
{/* Compose */}
<div
style={{
padding: "11px 14px",
borderTop: `1px solid ${border}`,
flexShrink: 0,
}}
>
<div style={{ display: "flex", gap: 9, alignItems: "flex-start" }}>
{/* My avatar */}
{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 }}>
{/* Timestamp pill */}
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 6 }}>
<div
style={{
fontSize: 11,
fontFamily: "monospace",
background: "rgba(232,162,42,0.1)",
color: "#e8a22a",
border: "1px solid rgba(232,162,42,0.22)",
padding: "3px 9px",
borderRadius: 20,
display: "flex",
alignItems: "center",
gap: 6,
}}
>
{isPlaying && (
<div
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: "#e8a22a",
animation: "rh-blink 1.1s infinite",
}}
/>
)}
{formatTime(currentTime)}
</div>
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.18)" }}>· pins to playhead</span>
</div>
{/* Textarea */}
<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.05)",
border: composeFocused
? "1px solid rgba(232,162,42,0.35)"
: "1px solid rgba(255,255,255,0.07)",
borderRadius: 7,
padding: "8px 10px",
color: "#e0e0e8",
fontSize: 12,
resize: "none",
outline: "none",
fontFamily: "inherit",
height: composeFocused ? 68 : 42,
transition: "height 0.18s, border-color 0.15s",
boxSizing: "border-box",
}}
/>
{/* Tag buttons + Post (visible when focused) */}
{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:
selectedTag === tag
? `1px solid ${TAG_STYLES[tag].color}44`
: "1px solid rgba(255,255,255,0.07)",
color:
selectedTag === tag
? TAG_STYLES[tag].color
: "rgba(255,255,255,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: "#e8a22a",
border: "none",
color: "#0f0f12",
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>
</div>
</div>
@@ -928,7 +973,8 @@ function TransportButton({ onClick, title, children }: { onClick: () => void; ti
title={title}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
style={
{
width: 34,
height: 34,
borderRadius: "50%",
@@ -941,11 +987,10 @@ function TransportButton({ onClick, title, children }: { onClick: () => void; ti
color: hovered ? "rgba(255,255,255,0.7)" : "rgba(255,255,255,0.35)",
flexShrink: 0,
transition: "all 0.12s",
}}
}
}
>
{children}
</button>
);
}