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:
parent
4a422fc4c3
commit
130579c12f
4 changed files with 121 additions and 6 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue