feat(tracks): add lyrics model and endpoints (E3)

- Migration 084: track_lyrics table
- TrackLyrics model, GetLyrics, CreateOrUpdateLyrics in TrackService
- GET /tracks/:id/lyrics, PUT /tracks/:id/lyrics (owner only)
- Frontend: TrackLyricsSection with show/hide toggle, Lyrics tab
- trackService: getLyrics, updateLyrics
- MSW: handlers for lyrics
This commit is contained in:
senke 2026-02-20 15:36:28 +01:00
parent ca7a3e775a
commit 6b80089706
9 changed files with 382 additions and 1 deletions

View file

@ -0,0 +1,114 @@
/**
* TrackLyricsSection Display lyrics with show/hide toggle (E3)
*/
import { useState, useEffect } from 'react';
import { FileText, ChevronDown, ChevronUp } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { getLyrics, type TrackLyrics } from '../services/trackService';
interface TrackLyricsSectionProps {
trackId: string;
}
const MAX_PREVIEW_LINES = 4;
export function TrackLyricsSection({ trackId }: TrackLyricsSectionProps) {
const [lyrics, setLyrics] = useState<TrackLyrics | null | undefined>(undefined);
const [isExpanded, setIsExpanded] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
getLyrics(trackId)
.then((data) => {
if (!cancelled) setLyrics(data);
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err : new Error(String(err)));
});
return () => {
cancelled = true;
};
}, [trackId]);
if (lyrics === undefined) {
return (
<Card variant="glass" className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10">
<FileText className="w-4 h-4 text-primary" />
</div>
<h3 className="text-heading-3">Lyrics</h3>
</div>
<div className="h-20 animate-pulse rounded-lg bg-muted/30" />
</Card>
);
}
if (error) {
return (
<Card variant="glass" className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10">
<FileText className="w-4 h-4 text-primary" />
</div>
<h3 className="text-heading-3">Lyrics</h3>
</div>
<p className="text-sm text-muted-foreground">Unable to load lyrics.</p>
</Card>
);
}
if (!lyrics || !lyrics.content.trim()) {
return (
<Card variant="glass" className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10">
<FileText className="w-4 h-4 text-primary" />
</div>
<h3 className="text-heading-3">Lyrics</h3>
</div>
<p className="text-sm text-muted-foreground">No lyrics available for this track.</p>
</Card>
);
}
const lines = lyrics.content.split(/\r?\n/).filter(Boolean);
const hasMore = lines.length > MAX_PREVIEW_LINES;
const displayLines = isExpanded ? lines : lines.slice(0, MAX_PREVIEW_LINES);
return (
<Card variant="glass" className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10">
<FileText className="w-4 h-4 text-primary" />
</div>
<h3 className="text-heading-3">Lyrics</h3>
</div>
{hasMore && (
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
className="text-muted-foreground hover:text-foreground"
>
{isExpanded ? (
<>
<ChevronUp className="w-4 h-4 mr-1" /> Show less
</>
) : (
<>
<ChevronDown className="w-4 h-4 mr-1" /> Show more
</>
)}
</Button>
)}
</div>
<pre className="text-sm text-foreground whitespace-pre-wrap font-sans leading-relaxed">
{displayLines.join('\n')}
</pre>
</Card>
);
}

View file

@ -1,10 +1,11 @@
import { BarChart3, MessageCircle, Clock3 } from 'lucide-react';
import { BarChart3, MessageCircle, Clock3, FileText } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { CommentSection } from '../../components/CommentSection';
import { ShareDialog } from '../../components/ShareDialog';
import { TrackHistory } from '../../components/TrackHistory';
import { TrackStatsDisplay } from '../../components/TrackStatsDisplay';
import { TrackLyricsSection } from '../../components/TrackLyricsSection';
import type { Track } from '../../types/track';
const tabTriggerClass =
@ -42,6 +43,10 @@ export function TrackDetailPageTabs({
<Clock3 className="w-4 h-4" />
History
</TabsTrigger>
<TabsTrigger value="lyrics" className={tabTriggerClass}>
<FileText className="w-4 h-4" />
Lyrics
</TabsTrigger>
</TabsList>
<TabsContent value="comments" className="animate-fade-in mt-0">
@ -71,6 +76,10 @@ export function TrackDetailPageTabs({
<TrackHistory trackId={track.id} limit={20} />
</Card>
</TabsContent>
<TabsContent value="lyrics" className="animate-fade-in mt-0">
<TrackLyricsSection trackId={track.id} />
</TabsContent>
</Tabs>
<ShareDialog

View file

@ -179,6 +179,45 @@ export async function getTrack(id: string): Promise<Track> {
/**
* Interface pour les paramètres de mise à jour d'un track
*/
export interface TrackLyrics {
id: string;
track_id: string;
content: string;
created_at: string;
updated_at: string;
}
/**
* Récupère les paroles d'un track
*/
export async function getLyrics(trackId: string): Promise<TrackLyrics | null> {
try {
const response = await apiClient.get<{ lyrics: TrackLyrics | null }>(
`/tracks/${trackId}/lyrics`,
);
return response.data.lyrics;
} catch (error: unknown) {
if (error instanceof AxiosError && error.response?.status === 404) {
return null;
}
throw error;
}
}
/**
* Crée ou met à jour les paroles d'un track
*/
export async function updateLyrics(
trackId: string,
content: string,
): Promise<TrackLyrics> {
const response = await apiClient.put<{ lyrics: TrackLyrics }>(
`/tracks/${trackId}/lyrics`,
{ content },
);
return response.data.lyrics;
}
export interface UpdateTrackParams {
title?: string;
description?: string;

View file

@ -141,6 +141,38 @@ export const handlersTracks = [
});
}),
http.get('*/api/v1/tracks/:id/lyrics', ({ params }) => {
return HttpResponse.json({
success: true,
data: {
lyrics: {
id: 'lyrics-1',
track_id: params.id,
content:
'Verse 1\nLine one of the lyrics\nLine two of the lyrics\nLine three\n\nChorus\nSing it out loud\n\nVerse 2\nMore lines here\nAnd here',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
},
});
}),
http.put('*/api/v1/tracks/:id/lyrics', async ({ params, request }) => {
const body = (await request.json()) as { content?: string };
return HttpResponse.json({
success: true,
data: {
lyrics: {
id: 'lyrics-1',
track_id: params.id,
content: body.content ?? '',
created_at: '2024-01-01T00:00:00Z',
updated_at: new Date().toISOString(),
},
},
});
}),
http.get('*/api/v1/tracks/:id/comments', () => {
return HttpResponse.json({
comments: [

View file

@ -79,6 +79,7 @@ func (r *APIRouter) setupTrackRoutes(router *gin.RouterGroup) {
tracks.GET("", trackHandler.ListTracks)
tracks.GET("/search", trackHandler.SearchTracks)
tracks.GET("/:id", trackHandler.GetTrack)
tracks.GET("/:id/lyrics", trackHandler.GetLyrics)
tracks.GET("/:id/stats", trackHandler.GetTrackStats)
tracks.GET("/:id/history", trackHandler.GetTrackHistory)
tracks.GET("/:id/download", trackHandler.DownloadTrack)
@ -106,6 +107,7 @@ func (r *APIRouter) setupTrackRoutes(router *gin.RouterGroup) {
return track.UserID, nil
}
protected.PUT("/:id", r.config.AuthMiddleware.RequireOwnershipOrAdmin("track", trackOwnerResolver), trackHandler.UpdateTrack)
protected.PUT("/:id/lyrics", r.config.AuthMiddleware.RequireOwnershipOrAdmin("track", trackOwnerResolver), trackHandler.UpdateLyrics)
protected.DELETE("/:id", r.config.AuthMiddleware.RequireOwnershipOrAdmin("track", trackOwnerResolver), trackHandler.DeleteTrack)
protected.GET("/:id/status", trackHandler.GetUploadStatus)

View file

@ -1026,6 +1026,102 @@ func (h *TrackHandler) UpdateTrack(c *gin.Context) {
handlers.RespondSuccess(c, http.StatusOK, gin.H{"track": track})
}
// GetLyrics gère la récupération des paroles d'un track (E3)
// @Summary Get Track Lyrics
// @Description Get lyrics for a track (public)
// @Tags Track
// @Produce json
// @Param id path string true "Track ID"
// @Success 200 {object} response.APIResponse{data=object{lyrics=models.TrackLyrics}}
// @Failure 404 {object} response.APIResponse "Track not found"
// @Router /tracks/{id}/lyrics [get]
func (h *TrackHandler) GetLyrics(c *gin.Context) {
trackIDStr := c.Param("id")
if trackIDStr == "" {
h.respondWithError(c, http.StatusBadRequest, "track id is required")
return
}
trackID, err := uuid.Parse(trackIDStr)
if err != nil {
h.respondWithError(c, http.StatusBadRequest, "invalid track id")
return
}
// Verify track exists
if _, err := h.trackService.GetTrackByID(c.Request.Context(), trackID); err != nil {
if errors.Is(err, ErrTrackNotFound) || errors.Is(err, gorm.ErrRecordNotFound) {
h.respondWithError(c, http.StatusNotFound, "track not found")
return
}
h.respondWithError(c, http.StatusInternalServerError, "failed to get track")
return
}
lyrics, err := h.trackService.GetLyrics(c.Request.Context(), trackID)
if err != nil {
h.respondWithError(c, http.StatusInternalServerError, "failed to get lyrics")
return
}
if lyrics == nil {
handlers.RespondSuccess(c, http.StatusOK, gin.H{"lyrics": nil})
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{"lyrics": lyrics})
}
// UpdateLyricsRequest représente la requête pour créer/mettre à jour les paroles
type UpdateLyricsRequest struct {
Content string `json:"content"`
}
// UpdateLyrics gère la création/mise à jour des paroles (E3)
// @Summary Update Track Lyrics
// @Description Create or update lyrics for a track (track owner only)
// @Tags Track
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Track ID"
// @Param body body UpdateLyricsRequest true "Lyrics content"
// @Success 200 {object} response.APIResponse{data=object{lyrics=models.TrackLyrics}}
// @Failure 401 {object} response.APIResponse "Unauthorized"
// @Failure 403 {object} response.APIResponse "Forbidden"
// @Failure 404 {object} response.APIResponse "Track not found"
// @Router /tracks/{id}/lyrics [put]
func (h *TrackHandler) UpdateLyrics(c *gin.Context) {
userID, ok := h.getUserID(c)
if !ok {
return
}
trackIDStr := c.Param("id")
if trackIDStr == "" {
h.respondWithError(c, http.StatusBadRequest, "track id is required")
return
}
trackID, err := uuid.Parse(trackIDStr)
if err != nil {
h.respondWithError(c, http.StatusBadRequest, "invalid track id")
return
}
var req UpdateLyricsRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.respondWithError(c, http.StatusBadRequest, "invalid request body")
return
}
lyrics, err := h.trackService.CreateOrUpdateLyrics(c.Request.Context(), trackID, userID, req.Content)
if err != nil {
if errors.Is(err, ErrTrackNotFound) || errors.Is(err, gorm.ErrRecordNotFound) {
h.respondWithError(c, http.StatusNotFound, "track not found")
return
}
if errors.Is(err, ErrForbidden) {
h.respondWithError(c, http.StatusForbidden, "forbidden")
return
}
h.respondWithError(c, http.StatusInternalServerError, "failed to update lyrics")
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{"lyrics": lyrics})
}
// DeleteTrack gère la suppression d'un track
// @Summary Delete Track
// @Description Permanently delete a track

View file

@ -914,6 +914,49 @@ func (s *TrackService) GetTrackStats(ctx context.Context, trackID uuid.UUID) (*t
return &stats, nil
}
// GetLyrics returns lyrics for a track (E3)
func (s *TrackService) GetLyrics(ctx context.Context, trackID uuid.UUID) (*models.TrackLyrics, error) {
var lyrics models.TrackLyrics
if err := s.forRead().WithContext(ctx).Where("track_id = ?", trackID).First(&lyrics).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil // No lyrics yet
}
return nil, fmt.Errorf("failed to get lyrics: %w", err)
}
return &lyrics, nil
}
// CreateOrUpdateLyrics creates or updates lyrics for a track (E3)
func (s *TrackService) CreateOrUpdateLyrics(ctx context.Context, trackID uuid.UUID, userID uuid.UUID, content string) (*models.TrackLyrics, error) {
// Verify track exists and user owns it
track, err := s.GetTrackByID(ctx, trackID)
if err != nil {
return nil, err
}
if track.UserID != userID {
return nil, ErrForbidden
}
var lyrics models.TrackLyrics
err = s.db.WithContext(ctx).Where("track_id = ?", trackID).First(&lyrics).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("failed to get lyrics: %w", err)
}
lyrics.TrackID = trackID
lyrics.Content = content
if lyrics.ID == uuid.Nil {
if err := s.db.WithContext(ctx).Create(&lyrics).Error; err != nil {
return nil, fmt.Errorf("failed to create lyrics: %w", err)
}
} else {
if err := s.db.WithContext(ctx).Save(&lyrics).Error; err != nil {
return nil, fmt.Errorf("failed to update lyrics: %w", err)
}
}
return &lyrics, nil
}
// BatchDeleteResult représente le résultat d'une suppression en lot
type BatchDeleteResult struct {
Deleted []uuid.UUID `json:"deleted"` // Changed to uuid.UUID

View file

@ -0,0 +1,33 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// TrackLyrics stores lyrics content for a track (E3)
type TrackLyrics struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id" db:"id"`
TrackID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex" json:"track_id" db:"track_id"`
Content string `gorm:"type:text;not null" json:"content" db:"content"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" db:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" db:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" db:"deleted_at"`
Track Track `gorm:"foreignKey:TrackID;constraint:OnDelete:CASCADE" json:"-"`
}
// TableName defines the table name for GORM
func (TrackLyrics) TableName() string {
return "track_lyrics"
}
// BeforeCreate hook for UUID
func (m *TrackLyrics) BeforeCreate(tx *gorm.DB) error {
if m.ID == uuid.Nil {
m.ID = uuid.New()
}
return nil
}

View file

@ -0,0 +1,13 @@
-- 084_add_track_lyrics.sql
-- Store lyrics per track (E3)
CREATE TABLE public.track_lyrics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
track_id UUID NOT NULL REFERENCES public.tracks(id) ON DELETE CASCADE,
content TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(track_id)
);
CREATE INDEX idx_track_lyrics_track_id ON public.track_lyrics(track_id);