From 6b800897060f28c7977afb0fc84deb50d52262d1 Mon Sep 17 00:00:00 2001 From: senke Date: Fri, 20 Feb 2026 15:36:28 +0100 Subject: [PATCH] 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 --- .../tracks/components/TrackLyricsSection.tsx | 114 ++++++++++++++++++ .../track-detail-page/TrackDetailPageTabs.tsx | 11 +- .../features/tracks/services/trackService.ts | 39 ++++++ apps/web/src/mocks/handlers-tracks.ts | 32 +++++ .../internal/api/routes_tracks.go | 2 + .../internal/core/track/handler.go | 96 +++++++++++++++ .../internal/core/track/service.go | 43 +++++++ .../internal/models/track_lyrics.go | 33 +++++ .../migrations/084_add_track_lyrics.sql | 13 ++ 9 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/features/tracks/components/TrackLyricsSection.tsx create mode 100644 veza-backend-api/internal/models/track_lyrics.go create mode 100644 veza-backend-api/migrations/084_add_track_lyrics.sql diff --git a/apps/web/src/features/tracks/components/TrackLyricsSection.tsx b/apps/web/src/features/tracks/components/TrackLyricsSection.tsx new file mode 100644 index 000000000..a4598a008 --- /dev/null +++ b/apps/web/src/features/tracks/components/TrackLyricsSection.tsx @@ -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(undefined); + const [isExpanded, setIsExpanded] = useState(false); + const [error, setError] = useState(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 ( + +
+
+ +
+

Lyrics

+
+
+ + ); + } + + if (error) { + return ( + +
+
+ +
+

Lyrics

+
+

Unable to load lyrics.

+
+ ); + } + + if (!lyrics || !lyrics.content.trim()) { + return ( + +
+
+ +
+

Lyrics

+
+

No lyrics available for this track.

+
+ ); + } + + 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 ( + +
+
+
+ +
+

Lyrics

+
+ {hasMore && ( + + )} +
+
+        {displayLines.join('\n')}
+      
+
+ ); +} diff --git a/apps/web/src/features/tracks/pages/track-detail-page/TrackDetailPageTabs.tsx b/apps/web/src/features/tracks/pages/track-detail-page/TrackDetailPageTabs.tsx index 17b4c7a8a..ea6b80e5c 100644 --- a/apps/web/src/features/tracks/pages/track-detail-page/TrackDetailPageTabs.tsx +++ b/apps/web/src/features/tracks/pages/track-detail-page/TrackDetailPageTabs.tsx @@ -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({ History + + + Lyrics + @@ -71,6 +76,10 @@ export function TrackDetailPageTabs({ + + + + { /** * 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 { + 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 { + const response = await apiClient.put<{ lyrics: TrackLyrics }>( + `/tracks/${trackId}/lyrics`, + { content }, + ); + return response.data.lyrics; +} + export interface UpdateTrackParams { title?: string; description?: string; diff --git a/apps/web/src/mocks/handlers-tracks.ts b/apps/web/src/mocks/handlers-tracks.ts index fb41225d9..757386665 100644 --- a/apps/web/src/mocks/handlers-tracks.ts +++ b/apps/web/src/mocks/handlers-tracks.ts @@ -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: [ diff --git a/veza-backend-api/internal/api/routes_tracks.go b/veza-backend-api/internal/api/routes_tracks.go index 872f49ece..dca318554 100644 --- a/veza-backend-api/internal/api/routes_tracks.go +++ b/veza-backend-api/internal/api/routes_tracks.go @@ -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) diff --git a/veza-backend-api/internal/core/track/handler.go b/veza-backend-api/internal/core/track/handler.go index 81184f5f1..170b1912c 100644 --- a/veza-backend-api/internal/core/track/handler.go +++ b/veza-backend-api/internal/core/track/handler.go @@ -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 diff --git a/veza-backend-api/internal/core/track/service.go b/veza-backend-api/internal/core/track/service.go index 3b1da0302..5fab2d841 100644 --- a/veza-backend-api/internal/core/track/service.go +++ b/veza-backend-api/internal/core/track/service.go @@ -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 diff --git a/veza-backend-api/internal/models/track_lyrics.go b/veza-backend-api/internal/models/track_lyrics.go new file mode 100644 index 000000000..f24300154 --- /dev/null +++ b/veza-backend-api/internal/models/track_lyrics.go @@ -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 +} diff --git a/veza-backend-api/migrations/084_add_track_lyrics.sql b/veza-backend-api/migrations/084_add_track_lyrics.sql new file mode 100644 index 000000000..41b13eae7 --- /dev/null +++ b/veza-backend-api/migrations/084_add_track_lyrics.sql @@ -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);