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:
parent
ca7a3e775a
commit
6b80089706
9 changed files with 382 additions and 1 deletions
114
apps/web/src/features/tracks/components/TrackLyricsSection.tsx
Normal file
114
apps/web/src/features/tracks/components/TrackLyricsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
33
veza-backend-api/internal/models/track_lyrics.go
Normal file
33
veza-backend-api/internal/models/track_lyrics.go
Normal 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
|
||||
}
|
||||
13
veza-backend-api/migrations/084_add_track_lyrics.sql
Normal file
13
veza-backend-api/migrations/084_add_track_lyrics.sql
Normal 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);
|
||||
Loading…
Reference in a new issue