feat(tracks): add BPM field to model and CRUD (E1)
- Backend: BPM and MusicalKey in Track model, UpdateTrack handler - track_search_service: enable BPM filter (min_bpm, max_bpm) - Frontend: Track type, UpdateTrackParams, display in TrackDetailPageInfo - TrackMetadataEditModal: BPM input, edit flow for track creator - MSW: bpm, musical_key in mock track, correct response envelope
This commit is contained in:
parent
fc34f4d064
commit
1620819afd
11 changed files with 225 additions and 32 deletions
|
|
@ -38,6 +38,8 @@ export interface UpdateTrackRequest {
|
|||
album?: string;
|
||||
genre?: string;
|
||||
year?: number;
|
||||
bpm?: number | null;
|
||||
musical_key?: string | null;
|
||||
is_public?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* TrackMetadataEditModal — Edit BPM and musical key (E1/E2)
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { updateTrack } from '../services/trackService';
|
||||
import toast from '@/utils/toast';
|
||||
import type { Track } from '../types/track';
|
||||
|
||||
const MUSICAL_KEYS = ['C', 'Cm', 'C#', 'C#m', 'D', 'Dm', 'Eb', 'Ebm', 'E', 'Em', 'F', 'Fm', 'F#', 'F#m', 'G', 'Gm', 'Ab', 'Abm', 'A', 'Am', 'Bb', 'Bbm', 'B', 'Bm'];
|
||||
|
||||
interface TrackMetadataEditModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
track: Track;
|
||||
onUpdated: () => void;
|
||||
}
|
||||
|
||||
export function TrackMetadataEditModal({
|
||||
open,
|
||||
onClose,
|
||||
track,
|
||||
onUpdated,
|
||||
}: TrackMetadataEditModalProps) {
|
||||
const [bpm, setBpm] = useState<string>(() =>
|
||||
track.bpm != null && track.bpm > 0 ? String(track.bpm) : '',
|
||||
);
|
||||
const [musicalKey, setMusicalKey] = useState<string>(
|
||||
track.musical_key ?? (track as { key?: string }).key ?? '',
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setBpm(track.bpm != null && track.bpm > 0 ? String(track.bpm) : '');
|
||||
setMusicalKey(track.musical_key ?? (track as { key?: string }).key ?? '');
|
||||
}
|
||||
}, [open, track]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const bpmNum = bpm.trim() === '' ? null : parseInt(bpm, 10);
|
||||
if (bpmNum != null && (isNaN(bpmNum) || bpmNum < 0 || bpmNum > 300)) {
|
||||
toast.error('BPM must be between 0 and 300');
|
||||
throw new Error('Validation failed');
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateTrack(track.id, {
|
||||
bpm: bpmNum ?? undefined,
|
||||
musical_key: musicalKey.trim() || undefined,
|
||||
});
|
||||
toast.success('Metadata updated');
|
||||
onUpdated();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to update');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Edit metadata"
|
||||
size="md"
|
||||
onConfirm={handleSave}
|
||||
confirmLabel={isSaving ? 'Saving…' : 'Save'}
|
||||
cancelLabel="Cancel"
|
||||
showCancel={true}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="BPM"
|
||||
type="number"
|
||||
min={0}
|
||||
max={300}
|
||||
placeholder="120"
|
||||
value={bpm}
|
||||
onChange={(e) => setBpm(e.target.value)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-mono text-muted-foreground uppercase tracking-widest">
|
||||
Key
|
||||
</label>
|
||||
<select
|
||||
value={musicalKey}
|
||||
onChange={(e) => setMusicalKey(e.target.value)}
|
||||
className="flex h-11 w-full rounded-xl border border-border bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{MUSICAL_KEYS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{k}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -70,7 +70,10 @@ export function TrackDetailPage(props?: TrackDetailPageProps) {
|
|||
|
||||
{/* Info & tabs — stagger delay 2 */}
|
||||
<div className="lg:col-span-8 space-y-8 animate-stagger-in" style={{ animationDelay: '120ms' }}>
|
||||
<TrackDetailPageInfo track={track} />
|
||||
<TrackDetailPageInfo
|
||||
track={track}
|
||||
onTrackUpdated={loadTrack}
|
||||
/>
|
||||
<TrackDetailPageTabs
|
||||
track={track}
|
||||
isShareDialogOpen={isShareDialogOpen}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { useMemo } from 'react';
|
||||
import { Clock, Disc3, Music2, Radio, Calendar, FileAudio } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Clock, Disc3, Music2, Radio, Calendar, FileAudio, Gauge, Music, Pencil } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useUser } from '@/features/auth/hooks/useUser';
|
||||
import { TrackMetadataEditModal } from '../../components/TrackMetadataEditModal';
|
||||
import { formatDuration } from './utils';
|
||||
import type { Track } from '../../types/track';
|
||||
|
||||
interface TrackDetailPageInfoProps {
|
||||
track: Track;
|
||||
onTrackUpdated?: () => void;
|
||||
}
|
||||
|
||||
/* ── Waveform Visualization (placeholder when no server-rendered waveform) ── */
|
||||
|
|
@ -58,7 +62,11 @@ function MetaItem({ icon, label, value }: MetaItemProps) {
|
|||
|
||||
/* ── Main component ── */
|
||||
|
||||
export function TrackDetailPageInfo({ track }: TrackDetailPageInfoProps) {
|
||||
export function TrackDetailPageInfo({ track, onTrackUpdated }: TrackDetailPageInfoProps) {
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const { data: currentUser } = useUser();
|
||||
const canEdit =
|
||||
onTrackUpdated && currentUser && String(currentUser.id) === String(track.creator_id);
|
||||
const waveformPath = track.waveform_path;
|
||||
const album = track.album;
|
||||
const year = track.year;
|
||||
|
|
@ -129,6 +137,19 @@ export function TrackDetailPageInfo({ track }: TrackDetailPageInfoProps) {
|
|||
|
||||
{/* Metadata grid */}
|
||||
<Card variant="glass" className="p-4 bg-muted/20 border-border">
|
||||
{canEdit && (
|
||||
<div className="flex justify-end items-center mb-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsEditModalOpen(true)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-1.5" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<MetaItem
|
||||
icon={<Clock className="w-4 h-4" />}
|
||||
|
|
@ -163,6 +184,20 @@ export function TrackDetailPageInfo({ track }: TrackDetailPageInfoProps) {
|
|||
value={`${(sampleRate / 1000).toFixed(1)} kHz`}
|
||||
/>
|
||||
)}
|
||||
{track.bpm != null && track.bpm > 0 && (
|
||||
<MetaItem
|
||||
icon={<Gauge className="w-4 h-4" />}
|
||||
label="BPM"
|
||||
value={track.bpm}
|
||||
/>
|
||||
)}
|
||||
{(track.musical_key ?? (track as { key?: string }).key) && (
|
||||
<MetaItem
|
||||
icon={<Music className="w-4 h-4" />}
|
||||
label="Key"
|
||||
value={track.musical_key ?? (track as { key?: string }).key ?? ''}
|
||||
/>
|
||||
)}
|
||||
{formattedDate && (
|
||||
<MetaItem
|
||||
icon={<Calendar className="w-4 h-4" />}
|
||||
|
|
@ -172,6 +207,15 @@ export function TrackDetailPageInfo({ track }: TrackDetailPageInfoProps) {
|
|||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{canEdit && (
|
||||
<TrackMetadataEditModal
|
||||
open={isEditModalOpen}
|
||||
onClose={() => setIsEditModalOpen(false)}
|
||||
track={track}
|
||||
onUpdated={onTrackUpdated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@ export interface UpdateTrackParams {
|
|||
album?: string;
|
||||
genre?: string;
|
||||
year?: number;
|
||||
bpm?: number | null;
|
||||
musical_key?: string | null;
|
||||
is_public?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@ export type Track = VezaBackendApiInternalModelsTrack & {
|
|||
status?: TrackStatus | VezaBackendApiInternalModelsTrackStatus;
|
||||
// Stream status with specific union type
|
||||
stream_status?: 'pending' | 'processing' | 'ready' | 'error';
|
||||
// E1/E2: Metadata from migration 040
|
||||
bpm?: number | null;
|
||||
musical_key?: string | null;
|
||||
};
|
||||
|
||||
export interface UploadProgress {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { http, HttpResponse } from 'msw';
|
|||
|
||||
const mockTrack = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'track-1',
|
||||
creator_id: '1',
|
||||
title: 'Storybook Track',
|
||||
artist: 'Test Artist',
|
||||
duration: 240,
|
||||
|
|
@ -20,6 +21,8 @@ const mockTrack = (overrides: Record<string, unknown> = {}) => ({
|
|||
comments_count: 5,
|
||||
is_liked: false,
|
||||
user: { id: 'user-1', username: 'ArtistUser', avatar_url: 'https://i.pravatar.cc/150?u=artist' },
|
||||
bpm: 120,
|
||||
musical_key: 'Cm',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
|
@ -127,12 +130,14 @@ export const handlersTracks = [
|
|||
}),
|
||||
|
||||
http.get('*/api/v1/tracks/:id', ({ params }) => {
|
||||
return HttpResponse.json({
|
||||
const track = {
|
||||
...mockTrack({ id: params.id }),
|
||||
description: 'A test track for Storybook',
|
||||
bpm: 120,
|
||||
key: 'Cm',
|
||||
stats: { plays: 100, likes: 10, downloads: 5, comments: 5 },
|
||||
};
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: { track },
|
||||
});
|
||||
}),
|
||||
|
||||
|
|
@ -210,10 +215,18 @@ export const handlersTracks = [
|
|||
});
|
||||
}),
|
||||
|
||||
http.put('*/api/v1/tracks/:id', ({ params }) => {
|
||||
http.put('*/api/v1/tracks/:id', async ({ params, request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
const track = {
|
||||
...mockTrack({ id: params.id }),
|
||||
...(body.bpm != null && { bpm: body.bpm }),
|
||||
...(body.musical_key != null && { musical_key: body.musical_key }),
|
||||
...(body.title != null && { title: body.title }),
|
||||
...(body.artist != null && { artist: body.artist }),
|
||||
};
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
track: { id: params.id, title: 'Updated Track Title', artist: 'Updated Artist' },
|
||||
data: { track },
|
||||
});
|
||||
}),
|
||||
|
||||
|
|
|
|||
|
|
@ -927,12 +927,14 @@ func (h *TrackHandler) GetTrack(c *gin.Context) {
|
|||
// UpdateTrackRequest représente la requête de mise à jour d'un track
|
||||
// MOD-P1-002: Added validation tags for systematic input validation
|
||||
type UpdateTrackRequest struct {
|
||||
Title *string `json:"title" binding:"omitempty,min=1,max=255" validate:"omitempty,min=1,max=255"`
|
||||
Artist *string `json:"artist" binding:"omitempty,max=255" validate:"omitempty,max=255"`
|
||||
Album *string `json:"album" binding:"omitempty,max=255" validate:"omitempty,max=255"`
|
||||
Genre *string `json:"genre" binding:"omitempty,max=100" validate:"omitempty,max=100"`
|
||||
Year *int `json:"year" binding:"omitempty,min=1900,max=2100" validate:"omitempty,min=1900,max=2100"`
|
||||
IsPublic *bool `json:"is_public"`
|
||||
Title *string `json:"title" binding:"omitempty,min=1,max=255" validate:"omitempty,min=1,max=255"`
|
||||
Artist *string `json:"artist" binding:"omitempty,max=255" validate:"omitempty,max=255"`
|
||||
Album *string `json:"album" binding:"omitempty,max=255" validate:"omitempty,max=255"`
|
||||
Genre *string `json:"genre" binding:"omitempty,max=100" validate:"omitempty,max=100"`
|
||||
Year *int `json:"year" binding:"omitempty,min=1900,max=2100" validate:"omitempty,min=1900,max=2100"`
|
||||
BPM *int `json:"bpm" binding:"omitempty,min=0,max=300" validate:"omitempty,min=0,max=300"`
|
||||
MusicalKey *string `json:"musical_key" binding:"omitempty,max=10" validate:"omitempty,max=10"`
|
||||
IsPublic *bool `json:"is_public"`
|
||||
}
|
||||
|
||||
// UpdateTrack gère la mise à jour d'un track
|
||||
|
|
@ -978,12 +980,14 @@ func (h *TrackHandler) UpdateTrack(c *gin.Context) {
|
|||
|
||||
// Convertir la requête en paramètres de service
|
||||
params := UpdateTrackParams{
|
||||
Title: req.Title,
|
||||
Artist: req.Artist,
|
||||
Album: req.Album,
|
||||
Genre: req.Genre,
|
||||
Year: req.Year,
|
||||
IsPublic: req.IsPublic,
|
||||
Title: req.Title,
|
||||
Artist: req.Artist,
|
||||
Album: req.Album,
|
||||
Genre: req.Genre,
|
||||
Year: req.Year,
|
||||
BPM: req.BPM,
|
||||
MusicalKey: req.MusicalKey,
|
||||
IsPublic: req.IsPublic,
|
||||
}
|
||||
|
||||
// MOD-P1-003: Check if user is admin for ownership bypass
|
||||
|
|
|
|||
|
|
@ -642,12 +642,14 @@ func (s *TrackService) GetTrackByID(ctx context.Context, trackID uuid.UUID) (*mo
|
|||
|
||||
// UpdateTrackParams représente les paramètres de mise à jour d'un track
|
||||
type UpdateTrackParams struct {
|
||||
Title *string `json:"title"`
|
||||
Artist *string `json:"artist"`
|
||||
Album *string `json:"album"`
|
||||
Genre *string `json:"genre"`
|
||||
Year *int `json:"year"`
|
||||
IsPublic *bool `json:"is_public"`
|
||||
Title *string `json:"title"`
|
||||
Artist *string `json:"artist"`
|
||||
Album *string `json:"album"`
|
||||
Genre *string `json:"genre"`
|
||||
Year *int `json:"year"`
|
||||
BPM *int `json:"bpm"`
|
||||
MusicalKey *string `json:"musical_key"`
|
||||
IsPublic *bool `json:"is_public"`
|
||||
}
|
||||
|
||||
// UpdateTrack met à jour les métadonnées d'un track
|
||||
|
|
@ -695,6 +697,15 @@ func (s *TrackService) UpdateTrack(ctx context.Context, trackID uuid.UUID, userI
|
|||
}
|
||||
updates["year"] = *params.Year
|
||||
}
|
||||
if params.BPM != nil {
|
||||
if *params.BPM < 0 || *params.BPM > 300 {
|
||||
return nil, fmt.Errorf("bpm must be between 0 and 300")
|
||||
}
|
||||
updates["bpm"] = *params.BPM
|
||||
}
|
||||
if params.MusicalKey != nil {
|
||||
updates["musical_key"] = *params.MusicalKey
|
||||
}
|
||||
if params.IsPublic != nil {
|
||||
updates["is_public"] = *params.IsPublic
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ type Track struct {
|
|||
Duration int `gorm:"not null" json:"duration" db:"duration"` // seconds
|
||||
Genre string `gorm:"size:100" json:"genre" db:"genre"`
|
||||
Year int `gorm:"default:0" json:"year" db:"year"`
|
||||
BPM *int `gorm:"column:bpm" json:"bpm,omitempty" db:"bpm"`
|
||||
MusicalKey string `gorm:"size:10" json:"musical_key,omitempty" db:"musical_key"`
|
||||
FilePath string `gorm:"not null;size:500" json:"file_path" db:"file_path"`
|
||||
FileSize int64 `gorm:"not null" json:"file_size" db:"file_size"` // bytes
|
||||
Format string `gorm:"size:10" json:"format" db:"format"` // mp3, flac, wav, etc.
|
||||
|
|
|
|||
|
|
@ -78,11 +78,15 @@ func (s *TrackSearchService) SearchTracks(ctx context.Context, params TrackSearc
|
|||
query = query.Where("duration <= ?", *params.MaxDuration)
|
||||
}
|
||||
|
||||
// BPM filter - Note: BPM field not in current model, skipping for now
|
||||
// This can be implemented when BPM field is added to the Track model
|
||||
if params.MinBPM != nil || params.MaxBPM != nil {
|
||||
// BPM functionality would go here when BPM field is added
|
||||
// When implemented, should support combined min/max like duration
|
||||
// BPM filter (E1: BPM in Track model)
|
||||
if params.MinBPM != nil && params.MaxBPM != nil {
|
||||
if *params.MinBPM <= *params.MaxBPM {
|
||||
query = query.Where("bpm >= ? AND bpm <= ?", *params.MinBPM, *params.MaxBPM)
|
||||
}
|
||||
} else if params.MinBPM != nil {
|
||||
query = query.Where("bpm >= ?", *params.MinBPM)
|
||||
} else if params.MaxBPM != nil {
|
||||
query = query.Where("bpm <= ?", *params.MaxBPM)
|
||||
}
|
||||
|
||||
// Genre filter (case-insensitive)
|
||||
|
|
|
|||
Loading…
Reference in a new issue