feat(v0.10.1): Track edit form - tags/genres (Phase 4.4)

- TrackMetadataEditModal: genres multi-select (max 3) from taxonomy
- Tag input with validation: max 10 tags, 30 chars each
- discoverService.getGenres() for genre list
- UpdateTrackParams/Request: add genres field
This commit is contained in:
senke 2026-03-09 10:00:07 +01:00
parent 4a422fc4c3
commit 130579c12f
4 changed files with 121 additions and 6 deletions

View file

@ -37,6 +37,8 @@ export interface UpdateTrackRequest {
artist?: string;
album?: string;
genre?: string;
/** v0.10.1: Multi-genre (max 3, taxonomy slugs) */
genres?: string[];
year?: number;
bpm?: number | null;
musical_key?: string | null;

View file

@ -1,15 +1,22 @@
/**
* TrackMetadataEditModal Edit BPM, musical key, tags (E1/E2/E4)
* TrackMetadataEditModal Edit BPM, musical key, tags, genres (E1/E2/E4, v0.10.1)
* Tags: max 10, 30 chars each. Genres: max 3 from taxonomy.
*/
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Dialog } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { X, Plus } from 'lucide-react';
import { updateTrack, getSuggestedTags } from '../services/trackService';
import { discoverService } from '@/services/discoverService';
import toast from '@/utils/toast';
import type { Track } from '../types/track';
const MAX_TAGS = 10;
const MAX_TAG_LENGTH = 30;
const MAX_GENRES = 3;
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 {
@ -32,14 +39,29 @@ export function TrackMetadataEditModal({
track.musical_key ?? (track as { key?: string }).key ?? '',
);
const [tags, setTags] = useState<string[]>(() => track.tags ?? []);
const [genres, setGenres] = useState<string[]>(() => {
const g = (track as { genres?: string[] }).genres;
if (g?.length) return g;
if (track.genre) return [track.genre.toLowerCase().replace(/\s+/g, '-')];
return [];
});
const [tagInput, setTagInput] = useState('');
const [suggestions, setSuggestions] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const { data: genreList = [] } = useQuery({
queryKey: ['discoverGenres'],
queryFn: () => discoverService.getGenres(),
enabled: open,
});
useEffect(() => {
if (open) {
setBpm(track.bpm != null && track.bpm > 0 ? String(track.bpm) : '');
setMusicalKey(track.musical_key ?? (track as { key?: string }).key ?? '');
setTags(track.tags ?? []);
const g = (track as { genres?: string[] }).genres;
setGenres(g?.length ? g : track.genre ? [track.genre.toLowerCase().replace(/\s+/g, '-')] : []);
getSuggestedTags({ genre: track.genre ?? undefined, bpm: track.bpm ?? undefined })
.then(setSuggestions)
.catch(() => setSuggestions([]));
@ -47,24 +69,46 @@ export function TrackMetadataEditModal({
}, [open, track]);
const addTag = (tag: string) => {
const t = tag.trim();
if (t && !tags.includes(t) && tags.length < 10) setTags([...tags, t]);
const t = tag.trim().toLowerCase();
if (!t || tags.includes(t) || tags.length >= MAX_TAGS) return;
if (t.length > MAX_TAG_LENGTH) {
toast.error(`Tag max ${MAX_TAG_LENGTH} caractères`);
return;
}
setTags([...tags, t]);
setTagInput('');
};
const removeTag = (tag: string) => setTags(tags.filter((x) => x !== tag));
const toggleGenre = (slug: string) => {
if (genres.includes(slug)) {
setGenres(genres.filter((g) => g !== slug));
} else if (genres.length < MAX_GENRES) {
setGenres([...genres, slug]);
} else {
toast.error(`Max ${MAX_GENRES} genres`);
}
};
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');
}
const invalidTag = tags.find((t) => t.length > MAX_TAG_LENGTH);
if (invalidTag) {
toast.error(`Tag "${invalidTag}" dépasse ${MAX_TAG_LENGTH} caractères`);
throw new Error('Validation failed');
}
setIsSaving(true);
try {
await updateTrack(track.id, {
bpm: bpmNum ?? undefined,
musical_key: musicalKey.trim() || undefined,
tags: tags.length > 0 ? tags : undefined,
genres: genres.length > 0 ? genres : undefined,
});
toast.success('Metadata updated');
onUpdated();
@ -116,7 +160,47 @@ export function TrackMetadataEditModal({
</div>
<div className="space-y-2">
<label className="text-xs font-mono text-muted-foreground uppercase tracking-widest">
Tags
Genres (max {MAX_GENRES})
</label>
<div className="flex flex-wrap gap-2">
{genreList
.filter((g) => genres.includes(g.slug))
.map((g) => (
<span
key={g.slug}
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg bg-primary/10 text-primary text-sm"
>
{g.name}
<button
type="button"
onClick={() => toggleGenre(g.slug)}
className="hover:bg-primary/20 rounded p-0.5"
aria-label={`Retirer ${g.name}`}
>
<X className="w-3 h-3" />
</button>
</span>
))}
{genreList
.filter((g) => !genres.includes(g.slug))
.slice(0, 12)
.map((g) => (
<Button
key={g.slug}
type="button"
variant="outline"
size="sm"
onClick={() => toggleGenre(g.slug)}
className="h-7 text-xs"
>
<Plus className="w-3 h-3 mr-1" /> {g.name}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-mono text-muted-foreground uppercase tracking-widest">
Tags (max {MAX_TAGS}, {MAX_TAG_LENGTH} caractères)
</label>
<div className="flex flex-wrap gap-2 mb-2">
{tags.map((t) => (
@ -129,17 +213,42 @@ export function TrackMetadataEditModal({
type="button"
onClick={() => removeTag(t)}
className="hover:bg-primary/20 rounded p-0.5"
aria-label={`Remove ${t}`}
aria-label={`Retirer ${t}`}
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
{tags.length < MAX_TAGS && (
<div className="flex gap-2">
<Input
placeholder="Ajouter un tag"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag(tagInput);
}
}}
maxLength={MAX_TAG_LENGTH + 1}
className="flex-1"
/>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => addTag(tagInput)}
>
<Plus className="w-4 h-4" />
</Button>
</div>
)}
{suggestions.length > 0 && (
<div className="flex flex-wrap gap-1">
{suggestions
.filter((s) => !tags.includes(s))
.filter((s) => !tags.includes(s.toLowerCase()))
.slice(0, 6)
.map((s) => (
<Button

View file

@ -241,6 +241,8 @@ export interface UpdateTrackParams {
artist?: string;
album?: string;
genre?: string;
/** v0.10.1: Multi-genre (max 3, taxonomy slugs) */
genres?: string[];
year?: number;
bpm?: number | null;
musical_key?: string | null;

View file

@ -49,6 +49,8 @@ export type Track = VezaBackendApiInternalModelsTrack & {
musical_key?: string | null;
// E4: Tags
tags?: string[];
// v0.10.1: Multi-genre (slugs)
genres?: string[];
};
export interface UploadProgress {