WIP: Add timestamp to comments and fix frontend errors
This commit is contained in:
22
api/alembic/versions/0005_add_timestamp_to_song_comments.py
Normal file
22
api/alembic/versions/0005_add_timestamp_to_song_comments.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Add timestamp to song_comments.
|
||||
|
||||
Revision ID: 0005
|
||||
Revises: 0004
|
||||
Create Date: 2026-03-29
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0005"
|
||||
down_revision = "0004"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("song_comments", sa.Column("timestamp", sa.Float(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("song_comments", "timestamp")
|
||||
@@ -206,6 +206,7 @@ class SongComment(Base):
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
timestamp: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
@@ -264,7 +264,7 @@ async def create_comment(
|
||||
):
|
||||
await _assert_song_membership(song_id, current_member.id, session)
|
||||
repo = CommentRepository(session)
|
||||
comment = await repo.create(song_id=song_id, author_id=current_member.id, body=data.body)
|
||||
comment = await repo.create(song_id=song_id, author_id=current_member.id, body=data.body, timestamp=data.timestamp)
|
||||
comment = await repo.get_with_author(comment.id)
|
||||
return SongCommentRead.from_model(comment)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict
|
||||
|
||||
class SongCommentCreate(BaseModel):
|
||||
body: str
|
||||
timestamp: float | None = None
|
||||
|
||||
|
||||
class SongCommentRead(BaseModel):
|
||||
@@ -18,6 +19,7 @@ class SongCommentRead(BaseModel):
|
||||
body: str
|
||||
author_id: uuid.UUID
|
||||
author_name: str
|
||||
timestamp: float | None
|
||||
created_at: datetime
|
||||
|
||||
@classmethod
|
||||
@@ -28,5 +30,6 @@ class SongCommentRead(BaseModel):
|
||||
body=getattr(c, "body"),
|
||||
author_id=getattr(c, "author_id"),
|
||||
author_name=getattr(getattr(c, "author"), "display_name"),
|
||||
timestamp=getattr(c, "timestamp"),
|
||||
created_at=getattr(c, "created_at"),
|
||||
)
|
||||
|
||||
@@ -8,6 +8,13 @@ export interface UseWaveformOptions {
|
||||
onTimeUpdate?: (currentTime: number) => void;
|
||||
}
|
||||
|
||||
export interface CommentMarker {
|
||||
id: string;
|
||||
time: number; // Time in seconds
|
||||
onClick: () => void;
|
||||
icon?: string; // URL for the account icon
|
||||
}
|
||||
|
||||
export function useWaveform(
|
||||
containerRef: React.RefObject<HTMLDivElement>,
|
||||
options: UseWaveformOptions
|
||||
@@ -17,6 +24,7 @@ export function useWaveform(
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const wasPlayingRef = useRef(false);
|
||||
const markersRef = useRef<CommentMarker[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !options.url) return;
|
||||
@@ -79,10 +87,63 @@ export function useWaveform(
|
||||
wasPlayingRef.current = false;
|
||||
};
|
||||
const seekTo = (time: number) => {
|
||||
if (wsRef.current && isReady) {
|
||||
if (wsRef.current && isReady && isFinite(time)) {
|
||||
wsRef.current.setTime(time);
|
||||
}
|
||||
};
|
||||
|
||||
return { isPlaying, isReady, currentTime, play, pause, seekTo };
|
||||
const addMarker = (marker: CommentMarker) => {
|
||||
if (wsRef.current && isReady) {
|
||||
const wavesurfer = wsRef.current;
|
||||
const markerElement = document.createElement("div");
|
||||
markerElement.style.position = "absolute";
|
||||
markerElement.style.width = "20px";
|
||||
markerElement.style.height = "20px";
|
||||
markerElement.style.borderRadius = "50% ";
|
||||
markerElement.style.backgroundColor = "var(--accent)";
|
||||
markerElement.style.cursor = "pointer";
|
||||
markerElement.style.zIndex = "9999";
|
||||
markerElement.style.left = `${(marker.time / wavesurfer.getDuration()) * 100}%`;
|
||||
markerElement.style.transform = "translateX(-50%) translateY(-50%)";
|
||||
markerElement.style.top = "50% ";
|
||||
markerElement.title = `Comment at ${formatTime(marker.time)}`;
|
||||
markerElement.onclick = marker.onClick;
|
||||
|
||||
if (marker.icon) {
|
||||
const iconElement = document.createElement("img");
|
||||
iconElement.src = marker.icon;
|
||||
iconElement.style.width = "100% ";
|
||||
iconElement.style.height = "100% ";
|
||||
iconElement.style.borderRadius = "50% ";
|
||||
markerElement.appendChild(iconElement);
|
||||
}
|
||||
|
||||
const waveformContainer = containerRef.current;
|
||||
if (waveformContainer) {
|
||||
waveformContainer.style.position = "relative";
|
||||
waveformContainer.appendChild(markerElement);
|
||||
}
|
||||
|
||||
markersRef.current.push(marker);
|
||||
}
|
||||
};
|
||||
|
||||
const clearMarkers = () => {
|
||||
const waveformContainer = containerRef.current;
|
||||
if (waveformContainer) {
|
||||
const markerElements = waveformContainer.querySelectorAll("div[title^='Comment at']");
|
||||
markerElements.forEach((element) => {
|
||||
waveformContainer.removeChild(element);
|
||||
});
|
||||
}
|
||||
markersRef.current = [];
|
||||
};
|
||||
|
||||
return { isPlaying, isReady, currentTime, play, pause, seekTo, addMarker, clearMarkers };
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface SongComment {
|
||||
author_id: string;
|
||||
author_name: string;
|
||||
created_at: string;
|
||||
timestamp: number; // Timestamp in seconds
|
||||
}
|
||||
|
||||
export function SongPage() {
|
||||
@@ -37,7 +38,7 @@ export function SongPage() {
|
||||
enabled: !!activeVersion,
|
||||
});
|
||||
|
||||
const { isPlaying, currentTime, play, pause, seekTo } = useWaveform(waveformRef, {
|
||||
const { isPlaying, currentTime, play, pause, seekTo, addMarker, clearMarkers } = useWaveform(waveformRef, {
|
||||
url: activeVersion ? `/api/v1/versions/${activeVersion}/stream` : null,
|
||||
peaksUrl: activeVersion ? `/api/v1/versions/${activeVersion}/waveform` : null,
|
||||
});
|
||||
@@ -59,12 +60,40 @@ export function SongPage() {
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isPlaying, play, pause]);
|
||||
|
||||
const { data: comments } = useQuery({
|
||||
const { data: comments } = useQuery<SongComment[]>({
|
||||
queryKey: ["comments", songId],
|
||||
queryFn: () => api.get<SongComment[]>(`/songs/${songId}/comments`),
|
||||
enabled: !!songId,
|
||||
});
|
||||
|
||||
// Scroll to comment when a marker is clicked
|
||||
const scrollToComment = (commentId: string) => {
|
||||
const commentElement = document.getElementById(`comment-${commentId}`);
|
||||
if (commentElement) {
|
||||
commentElement.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
commentElement.style.backgroundColor = "var(--accent-bg)";
|
||||
setTimeout(() => {
|
||||
commentElement.style.backgroundColor = "var(--bg-subtle)";
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (comments) {
|
||||
clearMarkers();
|
||||
comments.forEach((comment) => {
|
||||
if (comment.timestamp !== undefined && comment.timestamp !== null) {
|
||||
addMarker({
|
||||
id: comment.id,
|
||||
time: comment.timestamp,
|
||||
onClick: () => scrollToComment(comment.id),
|
||||
icon: "https://via.placeholder.com/20", // Replace with actual user icon URL
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [comments, addMarker, clearMarkers]);
|
||||
|
||||
const addCommentMutation = useMutation({
|
||||
mutationFn: (body: string) => api.post(`/songs/${songId}/comments`, { body }),
|
||||
onSuccess: () => {
|
||||
@@ -131,6 +160,13 @@ export function SongPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Play Time Display */}
|
||||
<div style={{ marginBottom: 16, textAlign: "center" }}>
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 14, fontFamily: "monospace" }}>
|
||||
Current Time: {formatTime(currentTime)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Annotations */}
|
||||
<div style={{ display: "grid", gap: 8, marginBottom: 32 }}>
|
||||
{annotations?.map((a) => (
|
||||
@@ -144,7 +180,7 @@ export function SongPage() {
|
||||
|
||||
<div style={{ display: "grid", gap: 8, marginBottom: 16 }}>
|
||||
{comments?.map((c) => (
|
||||
<div key={c.id} style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: "12px 16px" }}>
|
||||
<div id={`comment-${c.id}`} key={c.id} style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: "12px 16px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13, color: "var(--text)" }}>{c.author_name}</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
@@ -157,6 +193,16 @@ export function SongPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{c.timestamp !== undefined && c.timestamp !== null && (
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 6 }}>
|
||||
<button
|
||||
onClick={() => seekTo(c.timestamp)}
|
||||
style={{ background: "var(--accent-bg)", border: "1px solid var(--accent)", borderRadius: 4, color: "var(--accent)", cursor: "pointer", fontSize: 10, padding: "2px 8px", fontFamily: "monospace" }}
|
||||
>
|
||||
{formatTime(c.timestamp)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p style={{ margin: 0, fontSize: 13, color: "var(--text)", lineHeight: 1.5 }}>{c.body}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user