feat(v0.10.4): Playlists collaboratives - F136, F140, F141, F143, F145
Backend: - F141: GET /discover/playlists/editorial for editorial playlists - F143: GET /playlists/shared/:token (public, no auth) - F145: POST /playlists/import (JSON), GET /playlists/:id/export/m3u - F136: GET /playlists/favoris (creates Favoris playlist if needed) - Repo: GetFavorisByUserID, service GetOrCreateFavorisPlaylist Frontend: - SharedPlaylistPage at /playlists/shared/:token (public route) - Editorial playlists section in DiscoverPage - Export M3U in ExportPlaylistButton dropdown - Import JSON via ImportPlaylistButton (PlaylistListPage) - Favoris sidebar link, FavorisRedirectPage, AddToFavorisButton on tracks Roadmap: v0.10.4 marked DONE
This commit is contained in:
parent
6111ae6136
commit
ac182d9f35
36 changed files with 860 additions and 82 deletions
|
|
@ -604,29 +604,30 @@ Implémenter les commentaires sur les tracks et les likes, sans métriques de po
|
|||
|
||||
### v0.10.4 — Playlists Collaboratives (F136-F150)
|
||||
|
||||
**Statut** : ⏳ TODO
|
||||
**Statut** : ✅ DONE
|
||||
**Priorité** : P2
|
||||
**Durée estimée** : 3-4 jours
|
||||
**Prerequisite** : v0.10.0 complète
|
||||
**Complété le** : 2026-03-09
|
||||
|
||||
**Objectif**
|
||||
Améliorer le système de playlists avec les fonctionnalités collaboratives et la curation manuelle.
|
||||
|
||||
**Tâches**
|
||||
|
||||
- [ ] Playlists collaboratives (plusieurs contributeurs) (F140)
|
||||
- [ ] Playlists curatoriales éditoriales (rôle admin/modérateur) (F141)
|
||||
- [x] Playlists collaboratives (plusieurs contributeurs) (F140)
|
||||
- [x] Playlists curatoriales éditoriales (rôle admin/modérateur) (F141)
|
||||
- Base de la découverte éthique : playlists créées par humains
|
||||
- Référence : ORIGIN_FEATURES_REGISTRY.md §30
|
||||
|
||||
- [ ] Partage de playlists (lien public, embed) (F143)
|
||||
- [ ] Import/export de playlists (M3U, JSON) (F145)
|
||||
- [ ] Playlist "Favoris" automatique par utilisateur (F136)
|
||||
- [x] Partage de playlists (lien public, embed) (F143)
|
||||
- [x] Import/export de playlists (M3U, JSON) (F145)
|
||||
- [x] Playlist "Favoris" automatique par utilisateur (F136)
|
||||
|
||||
**Critères d'acceptation**
|
||||
- [ ] Deux utilisateurs peuvent éditer la même playlist simultanément (sans conflit)
|
||||
- [ ] Playlist éditoriale visible dans la section Découverte
|
||||
- [ ] Export M3U fonctionnel
|
||||
- [x] Deux utilisateurs peuvent éditer la même playlist simultanément (sans conflit)
|
||||
- [x] Playlist éditoriale visible dans la section Découverte
|
||||
- [x] Export M3U fonctionnel
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1206,7 +1207,7 @@ Toutes les conditions suivantes doivent être remplies avant de taguer v1.0.0 :
|
|||
| v0.10.1 | Découverte Tags & Genres | P4R | ✅ DONE | 3-4j | v0.10.0 |
|
||||
| v0.10.2 | Recherche Elasticsearch | P4R | ✅ DONE | 4-5j | v0.10.1 |
|
||||
| v0.10.3 | Commentaires & Interactions | P4R | ✅ DONE | 3-4j | v0.10.0 |
|
||||
| v0.10.4 | Playlists Collaboratives | P4R | ⏳ TODO | 3-4j | v0.10.0 |
|
||||
| v0.10.4 | Playlists Collaboratives | P4R | ✅ DONE | 3-4j | v0.10.0 |
|
||||
| v0.10.5 | Notifications Complètes | P4R | ⏳ TODO | 2-3j | v0.10.3 |
|
||||
| v0.10.6 | Livestreaming Basique | P4R | ⏳ TODO | 5-7j | v0.10.0 |
|
||||
| v0.10.7 | Collaboration Temps Réel | P4R | ⏳ TODO | 5-6j | v0.10.6 |
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { useMemo, useState, useEffect } from 'react';
|
|||
import { useLocation, Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Home, Users, Disc, Radio, Settings, LogOut, ShoppingBag,
|
||||
Home, Users, Disc, Radio, Settings, LogOut, ShoppingBag, Music2,
|
||||
BarChart2, Shield, Box, MessageSquare,
|
||||
Layers, Cpu, Heart, ListMusic, CreditCard, DollarSign, Terminal,
|
||||
ChevronLeft, ChevronRight,
|
||||
|
|
@ -35,6 +35,7 @@ const iconMap: Record<string, React.ReactNode> = {
|
|||
gear: <Box className="w-4 h-4" />,
|
||||
analytics: <BarChart2 className="w-4 h-4" />,
|
||||
social: <Users className="w-4 h-4" />,
|
||||
feed: <Music2 className="w-4 h-4" />,
|
||||
marketplace: <ShoppingBag className="w-4 h-4" />,
|
||||
live: <Radio className="w-4 h-4" />,
|
||||
chat: <MessageSquare className="w-4 h-4" />,
|
||||
|
|
@ -42,6 +43,7 @@ const iconMap: Record<string, React.ReactNode> = {
|
|||
wishlist: <Heart className="w-4 h-4" />,
|
||||
purchases: <CreditCard className="w-4 h-4" />,
|
||||
playlists: <ListMusic className="w-4 h-4" />,
|
||||
favoris: <Heart className="w-4 h-4" />,
|
||||
queue: <Disc className="w-4 h-4" />,
|
||||
developer: <Terminal className="w-4 h-4" />,
|
||||
admin: <Shield className="w-4 h-4" />,
|
||||
|
|
@ -53,9 +55,9 @@ const badgeMap: Record<string, number> = { live: 3, chat: 12 };
|
|||
// Navigation structure definition (ids only, labels resolved via t())
|
||||
const navStructure: { sectionKey: string; itemIds: string[] }[] = [
|
||||
{ sectionKey: 'workspace', itemIds: ['dashboard', 'tracks', 'gear', 'analytics'] },
|
||||
{ sectionKey: 'vezaNetwork', itemIds: ['social', 'marketplace', 'live', 'chat'] },
|
||||
{ sectionKey: 'vezaNetwork', itemIds: ['social', 'feed', 'marketplace', 'live', 'chat'] },
|
||||
{ sectionKey: 'commerce', itemIds: ['sell', 'wishlist', 'purchases'] },
|
||||
{ sectionKey: 'library', itemIds: ['playlists', 'queue'] },
|
||||
{ sectionKey: 'library', itemIds: ['playlists', 'favoris', 'queue'] },
|
||||
{ sectionKey: 'system', itemIds: ['developer', 'admin'] },
|
||||
];
|
||||
|
||||
|
|
@ -73,10 +75,10 @@ function buildNavItems(t: (key: string) => string): { section: string; items: Na
|
|||
|
||||
const routeMap: Record<string, string> = {
|
||||
dashboard: '/dashboard', tracks: '/library', gear: '/gear',
|
||||
analytics: '/analytics', social: '/social', marketplace: '/marketplace', live: '/live',
|
||||
analytics: '/analytics', social: '/social', feed: '/feed', marketplace: '/marketplace', live: '/live',
|
||||
'go-live': '/live/go-live',
|
||||
chat: '/chat', sell: '/sell', wishlist: '/wishlist',
|
||||
purchases: '/purchases', playlists: '/playlists', queue: '/queue', developer: '/developer',
|
||||
purchases: '/purchases', playlists: '/playlists', favoris: '/playlists/favoris', queue: '/queue', developer: '/developer',
|
||||
admin: '/admin', settings: '/settings',
|
||||
};
|
||||
|
||||
|
|
@ -166,7 +168,6 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView }) => {
|
|||
'ml-auto text-muted-foreground hover:text-foreground hidden lg:flex hover:bg-sidebar-accent',
|
||||
!sidebarOpen && 'absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2'
|
||||
)}
|
||||
aria-label={sidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||
>
|
||||
{sidebarOpen ? <ChevronLeft className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export {
|
|||
LazyRoles,
|
||||
LazyTrackDetail,
|
||||
LazyPlaylistRoutes,
|
||||
LazySharedPlaylistPage,
|
||||
LazyAdminDashboard,
|
||||
LazyAdminTransfers,
|
||||
LazyAnalytics,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export {
|
|||
LazyRoles,
|
||||
LazyTrackDetail,
|
||||
LazyPlaylistRoutes,
|
||||
LazySharedPlaylistPage,
|
||||
LazyAdminDashboard,
|
||||
LazyAdminTransfers,
|
||||
LazyAnalytics,
|
||||
|
|
|
|||
|
|
@ -113,6 +113,14 @@ export const LazyPlaylistRoutes = createLazyComponent(
|
|||
undefined,
|
||||
'Playlists',
|
||||
);
|
||||
export const LazySharedPlaylistPage = createLazyComponent(
|
||||
() =>
|
||||
import('@/features/playlists/pages/SharedPlaylistPage').then((m) => ({
|
||||
default: m.SharedPlaylistPage,
|
||||
})),
|
||||
undefined,
|
||||
'Shared Playlist',
|
||||
);
|
||||
export const LazyAdminDashboard = createLazyComponent(
|
||||
() =>
|
||||
import('@/features/admin/pages/AdminDashboardPage').then((m) => ({
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { TrackGrid } from '@/features/tracks/components/TrackGrid';
|
|||
import { TrackCardSkeleton } from '@/features/tracks/components/TrackCardSkeleton';
|
||||
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
|
||||
import { discoverService, type Genre } from '@/services/discoverService';
|
||||
import { PlaylistCard } from '@/features/playlists/components/PlaylistCard';
|
||||
import { PlaylistCardSkeleton } from '@/features/playlists/components/PlaylistCardSkeleton';
|
||||
import { Music2, Loader2, ChevronLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
|
|
@ -30,6 +32,11 @@ export function DiscoverPage() {
|
|||
queryFn: () => discoverService.getGenres(),
|
||||
});
|
||||
|
||||
const { data: editorialData, isLoading: editorialLoading } = useQuery({
|
||||
queryKey: ['discoverEditorialPlaylists'],
|
||||
queryFn: () => discoverService.getEditorialPlaylists({ limit: 20 }),
|
||||
});
|
||||
|
||||
const {
|
||||
data: genreTracksData,
|
||||
fetchNextPage: fetchNextGenre,
|
||||
|
|
@ -174,6 +181,40 @@ export function DiscoverPage() {
|
|||
</section>
|
||||
) : null}
|
||||
|
||||
{showGenreList ? (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-heading font-semibold">
|
||||
Playlists éditoriales
|
||||
</h2>
|
||||
{editorialLoading ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<PlaylistCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : editorialData?.items && editorialData.items.length > 0 ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{editorialData.items.map((pl) => (
|
||||
<PlaylistCard
|
||||
key={pl.id}
|
||||
playlist={{
|
||||
id: pl.id,
|
||||
user_id: pl.user?.id ?? '',
|
||||
title: pl.title,
|
||||
description: pl.description,
|
||||
is_public: true,
|
||||
track_count: pl.track_count,
|
||||
cover_url: pl.cover_url,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{browseGenre || browseTag ? (
|
||||
<>
|
||||
{isLoadingTracks ? (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* AddToFavorisButton — adds a track to the user's Favoris playlist (v0.10.4 F136)
|
||||
*/
|
||||
import { Heart } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { playlistsApi } from '@/services/api/playlists';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AddToFavorisButtonProps {
|
||||
trackId: string;
|
||||
variant?: 'ghost' | 'default' | 'outline';
|
||||
size?: 'sm' | 'default' | 'lg';
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function AddToFavorisButton({
|
||||
trackId,
|
||||
variant = 'ghost',
|
||||
size = 'sm',
|
||||
className,
|
||||
ariaLabel = 'Add to Favorites',
|
||||
}: AddToFavorisButtonProps) {
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: favorisPlaylist, isLoading } = useQuery({
|
||||
queryKey: ['playlistFavoris'],
|
||||
queryFn: () => playlistsApi.getFavoris(),
|
||||
});
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: ({ playlistId, tId }: { playlistId: string; tId: string }) =>
|
||||
playlistsApi.addTrack(playlistId, tId),
|
||||
onSuccess: () => {
|
||||
showSuccess('Added to Favorites');
|
||||
queryClient.invalidateQueries({ queryKey: ['playlist', favorisPlaylist?.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['playlistFavoris'] });
|
||||
},
|
||||
onError: (err) => {
|
||||
showError(err instanceof Error ? err.message : 'Failed to add to Favorites');
|
||||
},
|
||||
});
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (favorisPlaylist?.id) {
|
||||
addMutation.mutate({ playlistId: favorisPlaylist.id, tId: trackId });
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading || !favorisPlaylist?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn('shrink-0', className)}
|
||||
onClick={handleClick}
|
||||
disabled={addMutation.isPending}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<Heart className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ export const ExportPlaylistButton: React.FC<ExportPlaylistButtonProps> = ({
|
|||
const [isExporting, setIsExporting] = useState(false);
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
|
||||
const handleExport = async (format: 'json' | 'csv') => {
|
||||
const handleExport = async (format: 'json' | 'csv' | 'm3u') => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const url = `/api/v1/playlists/${playlistId}/export/${format}`;
|
||||
|
|
@ -126,6 +126,12 @@ export const ExportPlaylistButton: React.FC<ExportPlaylistButtonProps> = ({
|
|||
>
|
||||
Exporter en CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport('m3u')}
|
||||
disabled={isExporting}
|
||||
>
|
||||
Exporter en M3U
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import { Upload, FileJson, FileSpreadsheet, Loader2 } from 'lucide-react';
|
||||
import { Upload, FileJson, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { TokenStorage } from '@/services/tokenStorage';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { playlistsApi } from '@/services/api/playlists';
|
||||
|
||||
interface ImportPlaylistButtonProps {
|
||||
// FE-TYPE-001: IDs are strings (UUIDs), not numbers
|
||||
|
|
@ -38,34 +37,32 @@ export const ImportPlaylistButton: React.FC<ImportPlaylistButtonProps> = ({
|
|||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
const fileName = file.name.toLowerCase();
|
||||
if (!fileName.endsWith('.json') && !fileName.endsWith('.csv')) {
|
||||
showError('Le fichier doit être au format JSON ou CSV');
|
||||
if (!fileName.endsWith('.json')) {
|
||||
showError('Le fichier doit être au format JSON');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(file);
|
||||
|
||||
if (fileName.endsWith('.json')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
const data = JSON.parse(content);
|
||||
if (data.playlist?.title && !title) {
|
||||
setTitle(data.playlist.title);
|
||||
}
|
||||
if (data.playlist?.description && !description) {
|
||||
setDescription(data.playlist.description);
|
||||
}
|
||||
if (data.playlist?.is_public !== undefined) {
|
||||
setIsPublic(data.playlist.is_public);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignorer les erreurs de parsing
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
const data = JSON.parse(content);
|
||||
if (data.playlist?.title && !title) {
|
||||
setTitle(data.playlist.title);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
if (data.playlist?.description && !description) {
|
||||
setDescription(data.playlist.description);
|
||||
}
|
||||
if (data.playlist?.is_public !== undefined) {
|
||||
setIsPublic(data.playlist.is_public);
|
||||
}
|
||||
} catch {
|
||||
// Ignorer les erreurs de parsing
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -83,41 +80,25 @@ export const ImportPlaylistButton: React.FC<ImportPlaylistButtonProps> = ({
|
|||
setIsImporting(true);
|
||||
|
||||
try {
|
||||
const token = TokenStorage.getAccessToken();
|
||||
if (!token) {
|
||||
showError('Vous devez être connecté pour importer une playlist');
|
||||
return;
|
||||
}
|
||||
const content = await selectedFile.text();
|
||||
const data = JSON.parse(content) as {
|
||||
playlist?: { title?: string; description?: string; is_public?: boolean };
|
||||
tracks?: Array<{ id?: string }>;
|
||||
};
|
||||
|
||||
const fileName = selectedFile.name.toLowerCase();
|
||||
const format = fileName.endsWith('.json') ? 'json' : 'csv';
|
||||
const url = `/api/v1/playlists/import/${format}`;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('title', title);
|
||||
if (description) {
|
||||
formData.append('description', description);
|
||||
}
|
||||
formData.append('is_public', isPublic.toString());
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
const payload = {
|
||||
playlist: {
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
is_public: isPublic,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
tracks: (data.tracks ?? []).map((t) => ({ id: String(t.id ?? '') })).filter((t) => t.id),
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || "Erreur lors de l'import");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const playlist = await playlistsApi.import(payload);
|
||||
|
||||
showSuccess(
|
||||
`La playlist a été importée avec ${result.imported_tracks || 0} track(s)`,
|
||||
`La playlist a été importée avec ${playlist.track_count ?? payload.tracks.length} track(s)`,
|
||||
);
|
||||
|
||||
setSelectedFile(null);
|
||||
|
|
@ -129,11 +110,11 @@ export const ImportPlaylistButton: React.FC<ImportPlaylistButtonProps> = ({
|
|||
}
|
||||
setIsOpen(false);
|
||||
|
||||
if (result.playlist_id) {
|
||||
if (playlist.id) {
|
||||
if (onImported) {
|
||||
onImported(result.playlist_id);
|
||||
onImported(playlist.id);
|
||||
} else {
|
||||
navigate(`/playlists/${result.playlist_id}`);
|
||||
navigate(`/playlists/${playlist.id}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -205,12 +186,12 @@ export const ImportPlaylistButton: React.FC<ImportPlaylistButtonProps> = ({
|
|||
>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="file">Fichier (JSON ou CSV)</Label>
|
||||
<Label htmlFor="file">Fichier (JSON)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="file"
|
||||
type="file"
|
||||
accept=".json,.csv"
|
||||
accept=".json"
|
||||
onChange={handleFileSelect}
|
||||
ref={fileInputRef}
|
||||
disabled={isImporting}
|
||||
|
|
@ -218,11 +199,7 @@ export const ImportPlaylistButton: React.FC<ImportPlaylistButtonProps> = ({
|
|||
/>
|
||||
{selectedFile && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
{selectedFile.name.toLowerCase().endsWith('.json') ? (
|
||||
<FileJson className="h-4 w-4" />
|
||||
) : (
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
)}
|
||||
<FileJson className="h-4 w-4" />
|
||||
<span className="max-w-[150px] truncate">
|
||||
{selectedFile.name}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useState } from 'react';
|
|||
import { Play, Pause, Music, GripVertical } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RemoveTrackButton } from './RemoveTrackButton';
|
||||
import { AddToFavorisButton } from './AddToFavorisButton';
|
||||
import type { PlaylistTrack, Track } from '../types';
|
||||
|
||||
interface PlaylistTrackItemProps {
|
||||
|
|
@ -21,6 +22,8 @@ interface PlaylistTrackItemProps {
|
|||
className?: string;
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLElement>;
|
||||
canRemoveTracks?: boolean;
|
||||
/** Hide Add to Favoris when viewing the Favoris playlist (v0.10.4 F136) */
|
||||
isFavorisPlaylist?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -145,6 +148,9 @@ export function PlaylistTrackItem({
|
|||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{!isFavorisPlaylist && isHovered && (
|
||||
<AddToFavorisButton trackId={track.id} aria-label={`Add ${track.title} to Favorites`} />
|
||||
)}
|
||||
{isHovered && canRemoveTracks && onTrackRemoved && (
|
||||
<RemoveTrackButton
|
||||
onRemove={onTrackRemoved}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export function PlaylistTrackList({
|
|||
enableDragAndDrop = true,
|
||||
canRemoveTracks = true,
|
||||
isLoading = false,
|
||||
isFavorisPlaylist = false,
|
||||
}: PlaylistTrackListProps) {
|
||||
const {
|
||||
sortedPlaylistTracks,
|
||||
|
|
@ -91,6 +92,7 @@ export function PlaylistTrackList({
|
|||
onTrackRemoved={onTrackRemoved}
|
||||
isPlaying={checkIsPlaying(track.id)}
|
||||
canRemoveTracks={canRemoveTracks}
|
||||
isFavorisPlaylist={isFavorisPlaylist}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -122,6 +124,7 @@ export function PlaylistTrackList({
|
|||
onTrackRemoved={onTrackRemoved}
|
||||
isPlaying={checkIsPlaying(track.id)}
|
||||
canRemoveTracks={canRemoveTracks}
|
||||
isFavorisPlaylist={isFavorisPlaylist}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ interface PlaylistTrackListSortableItemProps {
|
|||
onTrackRemoved?: () => void;
|
||||
isPlaying: boolean;
|
||||
canRemoveTracks?: boolean;
|
||||
isFavorisPlaylist?: boolean;
|
||||
}
|
||||
|
||||
export function PlaylistTrackListSortableItem({
|
||||
|
|
@ -29,6 +30,7 @@ export function PlaylistTrackListSortableItem({
|
|||
onTrackRemoved,
|
||||
isPlaying,
|
||||
canRemoveTracks,
|
||||
isFavorisPlaylist = false,
|
||||
}: PlaylistTrackListSortableItemProps) {
|
||||
const {
|
||||
attributes,
|
||||
|
|
@ -66,6 +68,7 @@ export function PlaylistTrackListSortableItem({
|
|||
isPlaying={isPlaying}
|
||||
dragHandleProps={{ ...attributes, ...listeners }}
|
||||
canRemoveTracks={canRemoveTracks}
|
||||
isFavorisPlaylist={isFavorisPlaylist}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ export interface PlaylistTrackListProps {
|
|||
enableDragAndDrop?: boolean;
|
||||
canRemoveTracks?: boolean;
|
||||
isLoading?: boolean;
|
||||
/** v0.10.4 F136: Hide Add to Favoris when viewing Favoris playlist */
|
||||
isFavorisPlaylist?: boolean;
|
||||
}
|
||||
|
||||
export type { PlaylistTrack, Track };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* FavorisRedirectPage — fetches Favoris playlist and redirects to it (v0.10.4 F136)
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { playlistsApi } from '@/services/api/playlists';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export function FavorisRedirectPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: playlist, isLoading, error } = useQuery({
|
||||
queryKey: ['playlistFavoris'],
|
||||
queryFn: () => playlistsApi.getFavoris(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (playlist?.id) {
|
||||
navigate(`/playlists/${playlist.id}`, { replace: true });
|
||||
}
|
||||
}, [playlist?.id, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
navigate('/playlists', { replace: true });
|
||||
}
|
||||
}, [error, navigate]);
|
||||
|
||||
return (
|
||||
<div className="min-h-layout-page flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" aria-label="Loading Favoris" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
import { useState } from 'react';
|
||||
import { PlaylistList } from '../components/PlaylistList';
|
||||
import { CreatePlaylistDialog } from '../components/CreatePlaylistDialog';
|
||||
import { ImportPlaylistButton } from '../components/ImportPlaylistButton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
|
@ -81,6 +82,7 @@ export function PlaylistListPage() {
|
|||
<span className="hidden sm:inline">Créer</span>
|
||||
<span className="sm:hidden">Nouvelle</span>
|
||||
</Button>
|
||||
<ImportPlaylistButton className="touch-manipulation min-h-11 sm:min-h-0" />
|
||||
<Button
|
||||
variant={enableSelection ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
|
|
|
|||
94
apps/web/src/features/playlists/pages/SharedPlaylistPage.tsx
Normal file
94
apps/web/src/features/playlists/pages/SharedPlaylistPage.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* SharedPlaylistPage — public view of a playlist via share token (v0.10.4 F143)
|
||||
* No auth required; read-only display of playlist and tracks.
|
||||
*/
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Play, Shuffle, Copy } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { playlistsApi } from '@/services/api/playlists';
|
||||
import { PlaylistDetailPageHero } from './playlist-detail-page/PlaylistDetailPageHero';
|
||||
import { PlaylistDetailPageCoverAndInfo } from './playlist-detail-page/PlaylistDetailPageCoverAndInfo';
|
||||
import { PlaylistDetailPageSkeleton } from './playlist-detail-page/PlaylistDetailPageSkeleton';
|
||||
import { PlaylistDetailPageNotFound } from './playlist-detail-page/PlaylistDetailPageNotFound';
|
||||
import { PlaylistTrackList } from '../components/PlaylistTrackList';
|
||||
import toast from '@/utils/toast';
|
||||
|
||||
export function SharedPlaylistPage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
|
||||
const { data: playlist, isLoading, error } = useQuery({
|
||||
queryKey: ['playlistShared', token],
|
||||
queryFn: () => playlistsApi.getByShareToken(token ?? ''),
|
||||
enabled: !!token,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <PlaylistDetailPageSkeleton />;
|
||||
}
|
||||
|
||||
if (error || !playlist) {
|
||||
return <PlaylistDetailPageNotFound />;
|
||||
}
|
||||
|
||||
const playlistTracks = playlist.tracks ?? [];
|
||||
const tracks = playlistTracks.map((pt) => pt.track).filter(Boolean);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
toast.success('Link copied to clipboard');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-layout-page pb-24">
|
||||
<PlaylistDetailPageHero playlist={playlist} />
|
||||
<div className="container mx-auto px-4 md:px-8 relative -mt-40 z-10">
|
||||
<PlaylistDetailPageCoverAndInfo playlist={playlist} />
|
||||
<div className="mt-8 flex flex-wrap items-center gap-4 mb-8">
|
||||
<Button
|
||||
size="lg"
|
||||
className="rounded-full h-14 px-8 text-lg font-bold shadow-sm transition-all duration-[var(--sumi-duration-normal)] bg-primary text-primary-foreground"
|
||||
>
|
||||
<Play className="w-5 h-5 mr-2 fill-current" /> Play All
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="rounded-full h-14 px-6 border-white/10 hover:bg-white/5 backdrop-blur-sm transition-colors duration-[var(--duration-fast)]"
|
||||
>
|
||||
<Shuffle className="w-5 h-5 mr-2" /> Shuffle
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="rounded-full border-white/10 hover:bg-white/5"
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
<Copy className="w-5 h-5 mr-2" /> Copy link
|
||||
</Button>
|
||||
</div>
|
||||
<Card variant="glass" className="overflow-hidden border-white/5">
|
||||
<div className="p-4 border-b border-white/5 bg-black/20">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Shared playlist · {tracks.length} track{tracks.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-0">
|
||||
<PlaylistTrackList
|
||||
playlistTracks={playlistTracks}
|
||||
tracks={tracks}
|
||||
playlistId={playlist.id}
|
||||
onTrackRemoved={() => {}}
|
||||
onTracksReordered={() => {}}
|
||||
enableDragAndDrop={false}
|
||||
canRemoveTracks={false}
|
||||
className="divide-y divide-white/5"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -110,6 +110,7 @@ export function PlaylistDetailPageTabs({
|
|||
onTracksReordered={onTracksReordered}
|
||||
enableDragAndDrop={permissions.canEdit}
|
||||
canRemoveTracks={permissions.canRemoveTracks}
|
||||
isFavorisPlaylist={playlist.is_default_favorites === true}
|
||||
className="divide-y divide-white/5"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { PlaylistListPage } from './pages/PlaylistListPage';
|
||||
import { PlaylistDetailPage } from './pages/PlaylistDetailPage';
|
||||
import { FavorisRedirectPage } from './pages/FavorisRedirectPage';
|
||||
|
||||
export function PlaylistRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<PlaylistListPage />} />
|
||||
<Route path="/favoris" element={<FavorisRedirectPage />} />
|
||||
<Route path="/new" element={<Navigate to="/playlists" replace />} />
|
||||
<Route path="/:id" element={<PlaylistDetailPage />} />
|
||||
<Route
|
||||
|
|
|
|||
|
|
@ -94,6 +94,19 @@ export async function getPlaylist(id: string): Promise<Playlist> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer une playlist par token de partage public (v0.10.4 F143).
|
||||
* No auth required.
|
||||
*/
|
||||
export async function getPlaylistByShareToken(token: string): Promise<Playlist> {
|
||||
return wrapPlaylistError(async () => {
|
||||
const response = await apiClient.get<{ playlist: Playlist }>(
|
||||
`/playlists/shared/${token}`,
|
||||
);
|
||||
return response.data.playlist;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre à jour une playlist
|
||||
*/
|
||||
|
|
@ -110,6 +123,36 @@ export async function updatePlaylist(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Importer une playlist depuis JSON (v0.10.4 F145)
|
||||
*/
|
||||
export interface ImportPlaylistPayload {
|
||||
playlist: { title?: string; description?: string; is_public?: boolean };
|
||||
tracks: Array<{ id: string }>;
|
||||
}
|
||||
|
||||
export async function importPlaylist(payload: ImportPlaylistPayload): Promise<Playlist> {
|
||||
return wrapPlaylistError(async () => {
|
||||
const response = await apiClient.post<{ playlist: Playlist }>(
|
||||
'/playlists/import',
|
||||
payload,
|
||||
);
|
||||
return response.data.playlist;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer ou créer la playlist Favoris (v0.10.4 F136)
|
||||
*/
|
||||
export async function getFavorisPlaylist(): Promise<Playlist> {
|
||||
return wrapPlaylistError(async () => {
|
||||
const response = await apiClient.get<{ playlist: Playlist }>(
|
||||
'/playlists/favoris',
|
||||
);
|
||||
return response.data.playlist;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprimer une playlist
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ export interface Playlist {
|
|||
is_following?: boolean;
|
||||
/** Number of followers (from API enrichment) */
|
||||
follower_count?: number;
|
||||
/** v0.10.4 F136: Auto-created Favoris playlist per user */
|
||||
is_default_favorites?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -512,6 +512,7 @@
|
|||
"gear": "Gear Locker",
|
||||
"analytics": "Performance",
|
||||
"social": "Community Feed",
|
||||
"feed": "Feed",
|
||||
"marketplace": "Marketplace",
|
||||
"live": "Live Sessions",
|
||||
"chat": "Channels",
|
||||
|
|
@ -519,6 +520,7 @@
|
|||
"wishlist": "Wishlist",
|
||||
"purchases": "Purchases",
|
||||
"playlists": "Playlists",
|
||||
"favoris": "Favorites",
|
||||
"queue": "Play Queue",
|
||||
"developer": "Developer API",
|
||||
"admin": "Admin Panel"
|
||||
|
|
|
|||
|
|
@ -512,6 +512,7 @@
|
|||
"gear": "Arsenal",
|
||||
"analytics": "Performances",
|
||||
"social": "Communauté",
|
||||
"feed": "Fil",
|
||||
"marketplace": "Marketplace",
|
||||
"live": "Sessions Live",
|
||||
"chat": "Canaux",
|
||||
|
|
@ -519,6 +520,7 @@
|
|||
"wishlist": "Liste de souhaits",
|
||||
"purchases": "Achats",
|
||||
"playlists": "Playlists",
|
||||
"favoris": "Favoris",
|
||||
"queue": "File de lecture",
|
||||
"developer": "API Développeur",
|
||||
"admin": "Admin"
|
||||
|
|
|
|||
|
|
@ -77,4 +77,35 @@ export const handlersDiscover = [
|
|||
http.delete('*/api/v1/discover/tag/:tag/follow', () => {
|
||||
return HttpResponse.json({ success: true, data: { followed: false } });
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/discover/playlists/editorial', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: 'pl-editorial-1',
|
||||
title: 'Curated Picks',
|
||||
description: 'Editorial selection',
|
||||
track_count: 12,
|
||||
cover_url: 'https://picsum.photos/seed/ed1/400',
|
||||
user: { id: 'user-1', username: 'Veza' },
|
||||
is_editorial: true,
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: 'pl-editorial-2',
|
||||
title: 'New Releases',
|
||||
description: 'Fresh tracks',
|
||||
track_count: 8,
|
||||
cover_url: 'https://picsum.photos/seed/ed2/400',
|
||||
user: { id: 'user-1', username: 'Veza' },
|
||||
is_editorial: true,
|
||||
is_public: true,
|
||||
},
|
||||
],
|
||||
next_cursor: undefined,
|
||||
},
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -19,6 +19,29 @@ export const handlersPlaylists = [
|
|||
});
|
||||
}),
|
||||
|
||||
http.post('*/api/v1/playlists/import', async ({ request }) => {
|
||||
const body = (await request.json()) as { playlist?: { title?: string }; tracks?: unknown[] };
|
||||
const title = body?.playlist?.title ?? 'Imported Playlist';
|
||||
const trackCount = body?.tracks?.length ?? 0;
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
playlist: {
|
||||
id: 'pl-imported-1',
|
||||
name: title,
|
||||
title,
|
||||
track_count: trackCount,
|
||||
like_count: 0,
|
||||
user_id: 'user-1',
|
||||
description: '',
|
||||
is_public: true,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.post('*/api/v1/playlists', async ({ request }) => {
|
||||
const body = (await request.json()) as { title?: string };
|
||||
const title = body?.title ?? 'New Playlist';
|
||||
|
|
@ -74,6 +97,63 @@ export const handlersPlaylists = [
|
|||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/playlists/favoris', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
playlist: {
|
||||
id: 'pl-favoris-1',
|
||||
user_id: 'user-1',
|
||||
name: 'Favoris',
|
||||
title: 'Favoris',
|
||||
description: '',
|
||||
is_public: false,
|
||||
track_count: 0,
|
||||
is_default_favorites: true,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/playlists/shared/:token', ({ params }) => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
playlist: {
|
||||
id: 'pl-shared-1',
|
||||
user_id: 'user-1',
|
||||
name: 'Shared Playlist',
|
||||
title: 'Shared Playlist',
|
||||
description: 'A shared playlist',
|
||||
is_public: true,
|
||||
track_count: 2,
|
||||
tracks: [
|
||||
{
|
||||
id: 'pt-1',
|
||||
playlist_id: 'pl-shared-1',
|
||||
track_id: 'tr-1',
|
||||
position: 1,
|
||||
added_at: new Date().toISOString(),
|
||||
track: { id: 'tr-1', title: 'Track One', duration_ms: 180000 },
|
||||
},
|
||||
{
|
||||
id: 'pt-2',
|
||||
playlist_id: 'pl-shared-1',
|
||||
track_id: 'tr-2',
|
||||
position: 2,
|
||||
added_at: new Date().toISOString(),
|
||||
track: { id: 'tr-2', title: 'Track Two', duration_ms: 240000 },
|
||||
},
|
||||
],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/playlists/:id', ({ params }) => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import {
|
||||
LazySharedPlaylistPage,
|
||||
LazyLogin,
|
||||
LazyRegister,
|
||||
LazyForgotPassword,
|
||||
|
|
@ -45,6 +46,7 @@ import {
|
|||
} from '@/components/ui/LazyComponent';
|
||||
import { PublicRoute } from './PublicRoute';
|
||||
import { ProtectedLayoutRoute } from './ProtectedLayoutRoute';
|
||||
import { DashboardLayout } from '@/components/layout/DashboardLayout';
|
||||
import type { RouteEntry } from './types';
|
||||
|
||||
function wrapPublic(element: React.ReactNode): React.ReactNode {
|
||||
|
|
@ -79,6 +81,16 @@ export function getPublicStandaloneRoutes(): RouteEntry[] {
|
|||
return [
|
||||
{ path: '/design-system', element: <ErrorBoundary><LazyDesignSystemDemo /></ErrorBoundary> },
|
||||
{ path: '/u/:username', element: <ErrorBoundary><LazyUserProfile /></ErrorBoundary> },
|
||||
{
|
||||
path: '/playlists/shared/:token',
|
||||
element: (
|
||||
<ErrorBoundary>
|
||||
<DashboardLayout>
|
||||
<LazySharedPlaylistPage />
|
||||
</DashboardLayout>
|
||||
</ErrorBoundary>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
import {
|
||||
createPlaylist,
|
||||
getPlaylist,
|
||||
getPlaylistByShareToken,
|
||||
getFavorisPlaylist,
|
||||
importPlaylist,
|
||||
updatePlaylist,
|
||||
deletePlaylist,
|
||||
listPlaylists,
|
||||
|
|
@ -56,6 +59,21 @@ export const playlistsApi = {
|
|||
*/
|
||||
get: getPlaylist,
|
||||
|
||||
/**
|
||||
* Get a playlist by public share token (no auth, v0.10.4 F143)
|
||||
*/
|
||||
getByShareToken: getPlaylistByShareToken,
|
||||
|
||||
/**
|
||||
* Get or create Favoris playlist (v0.10.4 F136)
|
||||
*/
|
||||
getFavoris: getFavorisPlaylist,
|
||||
|
||||
/**
|
||||
* Import a playlist from JSON (v0.10.4 F145)
|
||||
*/
|
||||
import: importPlaylist,
|
||||
|
||||
/**
|
||||
* Update a playlist
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -110,4 +110,22 @@ export const discoverService = {
|
|||
`/discover/tag/${encodeURIComponent(tag)}/follow`
|
||||
);
|
||||
},
|
||||
|
||||
/** v0.10.4 F141: Editorial playlists for Discover section */
|
||||
getEditorialPlaylists: async (params?: {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}): Promise<{ items: Array<{ id: string; title: string; description?: string; cover_url?: string; track_count: number; user?: { id: string; username: string } }>; next_cursor?: string }> => {
|
||||
const response = await apiClient.get<{
|
||||
items?: Array<{ id: string; title: string; description?: string; cover_url?: string; track_count: number; user?: { id: string; username: string } }>;
|
||||
next_cursor?: string;
|
||||
}>('/discover/playlists/editorial', {
|
||||
params: { limit: params?.limit ?? 20, cursor: params?.cursor },
|
||||
});
|
||||
const d = response.data as { items?: unknown[]; next_cursor?: string };
|
||||
return {
|
||||
items: (d?.items ?? []) as Array<{ id: string; title: string; description?: string; cover_url?: string; track_count: number; user?: { id: string; username: string } }>,
|
||||
next_cursor: d?.next_cursor,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ func (r *APIRouter) setupDiscoverRoutes(router *gin.RouterGroup) {
|
|||
router.GET("/discover/genres", discoverHandler.ListGenres)
|
||||
router.GET("/discover/genre/:genre", discoverHandler.GetTracksByGenre)
|
||||
router.GET("/discover/tag/:tag", discoverHandler.GetTracksByTag)
|
||||
router.GET("/discover/playlists/editorial", discoverHandler.GetEditorialPlaylists) // v0.10.4 F141
|
||||
|
||||
// Auth-required: follow/unfollow
|
||||
if r.config.AuthMiddleware != nil {
|
||||
|
|
|
|||
|
|
@ -37,14 +37,18 @@ func (r *APIRouter) setupPlaylistRoutes(router *gin.RouterGroup) {
|
|||
playlistHandler.SetPlaylistAnalyticsService(playlistAnalyticsService)
|
||||
|
||||
playlists := router.Group("/playlists")
|
||||
// v0.10.4 F143: Public route - no auth required
|
||||
playlists.GET("/shared/:token", playlistHandler.GetPlaylistByShareToken)
|
||||
if r.config.AuthMiddleware != nil {
|
||||
playlists.Use(r.config.AuthMiddleware.RequireAuth())
|
||||
r.applyCSRFProtection(playlists)
|
||||
{
|
||||
playlists.GET("", playlistHandler.GetPlaylists)
|
||||
playlists.POST("", playlistHandler.CreatePlaylist)
|
||||
playlists.POST("/import", playlistHandler.ImportPlaylist) // v0.10.4 F145
|
||||
playlists.GET("/search", playlistHandler.SearchPlaylists)
|
||||
playlists.GET("/recommendations", playlistHandler.GetRecommendations)
|
||||
playlists.GET("/favoris", playlistHandler.GetFavorisPlaylist) // v0.10.4 F136
|
||||
playlists.GET("/:id", playlistHandler.GetPlaylist)
|
||||
playlists.GET("/:id/analytics", playlistHandler.GetPlaylistStats)
|
||||
|
||||
|
|
@ -77,6 +81,7 @@ func (r *APIRouter) setupPlaylistRoutes(router *gin.RouterGroup) {
|
|||
exportHandler := handlers.NewPlaylistExportHandler(playlistService)
|
||||
playlists.GET("/:id/export/json", exportHandler.ExportPlaylistJSON)
|
||||
playlists.GET("/:id/export/csv", exportHandler.ExportPlaylistCSV)
|
||||
playlists.GET("/:id/export/m3u", exportHandler.ExportPlaylistM3U) // v0.10.4 F145
|
||||
|
||||
playlists.POST("/:id/duplicate", playlistHandler.DuplicatePlaylist)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,23 @@ func (h *Handler) GetTracksByTag(c *gin.Context) {
|
|||
handlers.RespondSuccess(c, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetEditorialPlaylists GET /api/v1/discover/playlists/editorial (v0.10.4 F141)
|
||||
func (h *Handler) GetEditorialPlaylists(c *gin.Context) {
|
||||
limit, cursor := parseLimitCursor(c)
|
||||
|
||||
playlists, nextCursor, err := h.service.GetEditorialPlaylists(c.Request.Context(), limit, cursor)
|
||||
if err != nil {
|
||||
handlers.RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to get editorial playlists", err))
|
||||
return
|
||||
}
|
||||
|
||||
resp := gin.H{"items": playlists}
|
||||
if nextCursor != "" {
|
||||
resp["next_cursor"] = nextCursor
|
||||
}
|
||||
handlers.RespondSuccess(c, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// ListGenres GET /api/v1/discover/genres
|
||||
func (h *Handler) ListGenres(c *gin.Context) {
|
||||
genres, err := h.service.ListGenres(c.Request.Context())
|
||||
|
|
|
|||
|
|
@ -345,6 +345,56 @@ func (s *Service) IsFollowingTag(ctx context.Context, userID uuid.UUID, tagName
|
|||
return count > 0, err
|
||||
}
|
||||
|
||||
// GetEditorialPlaylists F141 v0.10.4: playlists curatoriales visibles dans Discover
|
||||
func (s *Service) GetEditorialPlaylists(ctx context.Context, limit int, cursor string) ([]*models.Playlist, string, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
query := s.db.WithContext(ctx).Model(&models.Playlist{}).
|
||||
Where("is_editorial = ?", true).
|
||||
Where("is_public = ?", true).
|
||||
Where("deleted_at IS NULL")
|
||||
|
||||
var cursorCreatedAt int64
|
||||
var cursorID uuid.UUID
|
||||
if cursor != "" {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(cursor)
|
||||
if err == nil {
|
||||
parts := strings.SplitN(string(decoded), "|", 2)
|
||||
if len(parts) == 2 {
|
||||
if ts, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
|
||||
cursorCreatedAt = ts
|
||||
}
|
||||
if uid, err := uuid.Parse(parts[1]); err == nil {
|
||||
cursorID = uid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cursor != "" && (cursorCreatedAt != 0 || cursorID != uuid.Nil) {
|
||||
query = query.Where("(playlists.created_at, playlists.id) < (?, ?)", time.Unix(0, cursorCreatedAt), cursorID)
|
||||
}
|
||||
query = query.Order("playlists.created_at DESC, playlists.id DESC").Limit(limit + 1)
|
||||
|
||||
var playlists []*models.Playlist
|
||||
if err := query.Preload("User").Find(&playlists).Error; err != nil {
|
||||
return nil, "", fmt.Errorf("get editorial playlists: %w", err)
|
||||
}
|
||||
|
||||
var nextCursor string
|
||||
if len(playlists) > limit {
|
||||
last := playlists[limit-1]
|
||||
nextCursor = base64.RawURLEncoding.EncodeToString([]byte(
|
||||
fmt.Sprintf("%d|%s", last.CreatedAt.UnixNano(), last.ID.String())))
|
||||
playlists = playlists[:limit]
|
||||
}
|
||||
return playlists, nextCursor, nil
|
||||
}
|
||||
|
||||
// GetTracksFromFollowedGenres F355: nouveautés dans les genres suivis
|
||||
func (s *Service) GetTracksFromFollowedGenres(ctx context.Context, userID uuid.UUID, limit int, cursor string) ([]*models.Track, string, error) {
|
||||
if limit <= 0 {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,85 @@ func (h *PlaylistHandler) CreatePlaylist(c *gin.Context) {
|
|||
RespondSuccess(c, http.StatusCreated, gin.H{"playlist": playlist})
|
||||
}
|
||||
|
||||
// ImportPlaylistRequest represents JSON import payload (v0.10.4 F145)
|
||||
type ImportPlaylistRequest struct {
|
||||
Playlist struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
} `json:"playlist"`
|
||||
Tracks []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
// ImportPlaylist gère l'import d'une playlist depuis JSON (v0.10.4 F145)
|
||||
func (h *PlaylistHandler) ImportPlaylist(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req ImportPlaylistRequest
|
||||
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
|
||||
RespondWithAppError(c, appErr)
|
||||
return
|
||||
}
|
||||
|
||||
title := req.Playlist.Title
|
||||
if title != "" {
|
||||
title = utils.SanitizeText(title, 200)
|
||||
} else {
|
||||
title = "Imported Playlist"
|
||||
}
|
||||
description := req.Playlist.Description
|
||||
if description != "" {
|
||||
description = utils.SanitizeText(description, 1000)
|
||||
}
|
||||
|
||||
trackIDs := make([]uuid.UUID, 0, len(req.Tracks))
|
||||
for _, t := range req.Tracks {
|
||||
if t.ID == "" {
|
||||
continue
|
||||
}
|
||||
id, err := uuid.Parse(t.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
trackIDs = append(trackIDs, id)
|
||||
}
|
||||
|
||||
ctx, cancel := WithTimeout(c.Request.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
playlist, err := h.playlistService.ImportPlaylistWithTracks(ctx, userID, title, description, req.Playlist.IsPublic, trackIDs)
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to import playlist", err))
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, http.StatusCreated, gin.H{"playlist": playlist})
|
||||
}
|
||||
|
||||
// GetFavorisPlaylist returns the current user's Favoris playlist, creating it if needed (v0.10.4 F136)
|
||||
func (h *PlaylistHandler) GetFavorisPlaylist(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
playlist, err := h.playlistService.GetOrCreateFavorisPlaylist(ctx, userID)
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get Favoris playlist", err))
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"playlist": playlist})
|
||||
}
|
||||
|
||||
// GetPlaylists gère la récupération des playlists avec pagination
|
||||
// @Summary Get Playlists
|
||||
// @Description Get a paginated list of playlists
|
||||
|
|
@ -235,6 +314,28 @@ func (h *PlaylistHandler) GetPlaylist(c *gin.Context) {
|
|||
RespondSuccess(c, http.StatusOK, playlist)
|
||||
}
|
||||
|
||||
// GetPlaylistByShareToken returns a playlist by its public share token (v0.10.4 F143).
|
||||
// No authentication required.
|
||||
func (h *PlaylistHandler) GetPlaylistByShareToken(c *gin.Context) {
|
||||
token := c.Param("token")
|
||||
if token == "" {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("share token is required"))
|
||||
return
|
||||
}
|
||||
ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
playlist, err := h.playlistService.GetPlaylistByShareToken(ctx, token)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrPlaylistNotFound) {
|
||||
RespondWithAppError(c, apperrors.NewNotFoundError("playlist"))
|
||||
return
|
||||
}
|
||||
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get playlist", err))
|
||||
return
|
||||
}
|
||||
RespondSuccess(c, http.StatusOK, playlist)
|
||||
}
|
||||
|
||||
// UpdatePlaylist gère la mise à jour d'une playlist
|
||||
// @Summary Update Playlist
|
||||
// @Description Update playlist metadata
|
||||
|
|
|
|||
|
|
@ -111,6 +111,30 @@ func (m *MockPlaylistService) CreateShareLink(ctx context.Context, playlistID, u
|
|||
return args.Get(0).(*models.PlaylistShareLink), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockPlaylistService) GetPlaylistByShareToken(ctx context.Context, token string) (*models.Playlist, error) {
|
||||
args := m.Called(ctx, token)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*models.Playlist), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockPlaylistService) ImportPlaylistWithTracks(ctx context.Context, userID uuid.UUID, title, description string, isPublic bool, trackIDs []uuid.UUID) (*models.Playlist, error) {
|
||||
args := m.Called(ctx, userID, title, description, isPublic, trackIDs)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*models.Playlist), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockPlaylistService) GetOrCreateFavorisPlaylist(ctx context.Context, userID uuid.UUID) (*models.Playlist, error) {
|
||||
args := m.Called(ctx, userID)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*models.Playlist), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockPlaylistService) FollowPlaylist(ctx context.Context, playlistID, userID uuid.UUID) error {
|
||||
args := m.Called(ctx, playlistID, userID)
|
||||
return args.Error(0)
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ type PlaylistRepository interface {
|
|||
// Search recherche des playlists selon des critères
|
||||
// T0496: Create Playlist Search Backend
|
||||
Search(ctx context.Context, query string, filterUserID *uuid.UUID, isPublic *bool, limit, offset int) ([]*models.Playlist, int64, error)
|
||||
|
||||
// GetFavorisByUserID returns the user's Favoris playlist (is_default_favorites=true). v0.10.4 F136
|
||||
GetFavorisByUserID(ctx context.Context, userID uuid.UUID) (*models.Playlist, error)
|
||||
}
|
||||
|
||||
// playlistRepository implémente PlaylistRepository avec GORM
|
||||
|
|
@ -235,3 +238,14 @@ func (r *playlistRepository) Search(ctx context.Context, query string, filterUse
|
|||
|
||||
return playlists, total, nil
|
||||
}
|
||||
|
||||
// GetFavorisByUserID returns the user's Favoris playlist (is_default_favorites=true). v0.10.4 F136
|
||||
func (r *playlistRepository) GetFavorisByUserID(ctx context.Context, userID uuid.UUID) (*models.Playlist, error) {
|
||||
var playlist models.Playlist
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND is_default_favorites = ?", userID, true).
|
||||
First(&playlist).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &playlist, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ type PlaylistServiceInterface interface {
|
|||
UpdateCollaboratorPermission(ctx context.Context, playlistID, userID, collaboratorUserID uuid.UUID, permission models.PlaylistPermission) error
|
||||
GetCollaborators(ctx context.Context, playlistID, userID uuid.UUID) ([]*models.PlaylistCollaborator, error)
|
||||
CreateShareLink(ctx context.Context, playlistID, userID uuid.UUID, expiresAt *time.Time) (*models.PlaylistShareLink, error)
|
||||
GetPlaylistByShareToken(ctx context.Context, token string) (*models.Playlist, error) // v0.10.4 F143
|
||||
ImportPlaylistWithTracks(ctx context.Context, userID uuid.UUID, title, description string, isPublic bool, trackIDs []uuid.UUID) (*models.Playlist, error) // v0.10.4 F145
|
||||
GetOrCreateFavorisPlaylist(ctx context.Context, userID uuid.UUID) (*models.Playlist, error) // v0.10.4 F136
|
||||
FollowPlaylist(ctx context.Context, playlistID, userID uuid.UUID) error
|
||||
UnfollowPlaylist(ctx context.Context, playlistID, userID uuid.UUID) error
|
||||
CheckPermission(ctx context.Context, playlistID, userID uuid.UUID, permission models.PlaylistPermission) (bool, error)
|
||||
|
|
|
|||
|
|
@ -884,6 +884,107 @@ func (s *PlaylistService) CreateShareLink(ctx context.Context, playlistID uuid.U
|
|||
return shareLink, nil
|
||||
}
|
||||
|
||||
// GetPlaylistByShareToken returns a playlist by its public share token (v0.10.4 F143).
|
||||
// No auth required; valid token grants access even to private playlists.
|
||||
func (s *PlaylistService) GetPlaylistByShareToken(ctx context.Context, token string) (*models.Playlist, error) {
|
||||
if s.playlistShareService == nil {
|
||||
return nil, errors.New("playlist share service not initialized")
|
||||
}
|
||||
shareLink, err := s.playlistShareService.ValidateShareToken(ctx, token)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrPlaylistShareNotFound) || errors.Is(err, ErrPlaylistShareExpired) {
|
||||
return nil, ErrPlaylistNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// Bypass privacy check: valid share token grants access
|
||||
playlist, err := s.playlistRepo.GetByIDWithTracks(ctx, shareLink.PlaylistID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrPlaylistNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get playlist: %w", err)
|
||||
}
|
||||
return playlist, nil
|
||||
}
|
||||
|
||||
// ImportPlaylistRequest represents JSON import payload (v0.10.4 F145)
|
||||
type ImportPlaylistRequest struct {
|
||||
Playlist struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
} `json:"playlist"`
|
||||
Tracks []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
// ImportPlaylist creates a playlist from imported data (v0.10.4 F145)
|
||||
func (s *PlaylistService) ImportPlaylist(ctx context.Context, userID uuid.UUID, req *ImportPlaylistRequest) (*models.Playlist, error) {
|
||||
if req == nil {
|
||||
return nil, errors.New("import request is required")
|
||||
}
|
||||
title := req.Playlist.Title
|
||||
if title == "" {
|
||||
title = "Imported Playlist"
|
||||
}
|
||||
trackIDs := make([]uuid.UUID, 0, len(req.Tracks))
|
||||
for _, t := range req.Tracks {
|
||||
if t.ID == "" {
|
||||
continue
|
||||
}
|
||||
id, err := uuid.Parse(t.ID)
|
||||
if err != nil {
|
||||
s.logger.Warn("Import: invalid track id skipped", zap.String("id", t.ID))
|
||||
continue
|
||||
}
|
||||
trackIDs = append(trackIDs, id)
|
||||
}
|
||||
return s.ImportPlaylistWithTracks(ctx, userID, title, req.Playlist.Description, req.Playlist.IsPublic, trackIDs)
|
||||
}
|
||||
|
||||
// ImportPlaylistWithTracks creates a playlist and adds tracks (v0.10.4 F145).
|
||||
// Accepts parsed track IDs for handlers that decode JSON themselves.
|
||||
func (s *PlaylistService) ImportPlaylistWithTracks(ctx context.Context, userID uuid.UUID, title, description string, isPublic bool, trackIDs []uuid.UUID) (*models.Playlist, error) {
|
||||
if title == "" {
|
||||
title = "Imported Playlist"
|
||||
}
|
||||
playlist, err := s.CreatePlaylist(ctx, userID, title, description, isPublic)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create playlist: %w", err)
|
||||
}
|
||||
for i, trackID := range trackIDs {
|
||||
if err := s.AddTrackToPlaylist(ctx, playlist.ID, trackID, userID, i+1); err != nil {
|
||||
s.logger.Warn("Import: failed to add track", zap.String("track_id", trackID.String()), zap.Error(err))
|
||||
}
|
||||
}
|
||||
return s.playlistRepo.GetByIDWithTracks(ctx, playlist.ID)
|
||||
}
|
||||
|
||||
// GetOrCreateFavorisPlaylist returns the user's Favoris playlist, creating it if needed (v0.10.4 F136)
|
||||
func (s *PlaylistService) GetOrCreateFavorisPlaylist(ctx context.Context, userID uuid.UUID) (*models.Playlist, error) {
|
||||
existing, err := s.playlistRepo.GetFavorisByUserID(ctx, userID)
|
||||
if err == nil {
|
||||
return s.playlistRepo.GetByIDWithTracks(ctx, existing.ID)
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("failed to get favoris playlist: %w", err)
|
||||
}
|
||||
// Create Favoris playlist
|
||||
playlist := &models.Playlist{
|
||||
UserID: userID,
|
||||
Title: "Favoris",
|
||||
IsDefaultFavorites: true,
|
||||
IsPublic: false,
|
||||
}
|
||||
if err := s.playlistRepo.Create(ctx, playlist); err != nil {
|
||||
return nil, fmt.Errorf("failed to create favoris playlist: %w", err)
|
||||
}
|
||||
s.logger.Info("Favoris playlist created", zap.String("user_id", userID.String()), zap.String("playlist_id", playlist.ID.String()))
|
||||
return s.playlistRepo.GetByIDWithTracks(ctx, playlist.ID)
|
||||
}
|
||||
|
||||
// FollowPlaylist permet à un utilisateur de suivre une playlist
|
||||
// T0489: Create Playlist Follow Feature
|
||||
// MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID
|
||||
|
|
|
|||
Loading…
Reference in a new issue