feat(marketplace): add playable preview and image gallery to ProductDetailView

This commit is contained in:
senke 2026-02-22 14:14:38 +01:00
parent a8549add70
commit 31a27e4724
6 changed files with 84 additions and 9 deletions

View file

@ -28,7 +28,8 @@ export const ProductDetailView: React.FC<ProductDetailViewProps> = ({
activeImage,
setActiveImage,
isPlaying,
setIsPlaying,
handlePlayPause,
audioRef,
selectedLicenseId,
setSelectedLicenseId,
selectedLicense,
@ -53,7 +54,9 @@ export const ProductDetailView: React.FC<ProductDetailViewProps> = ({
activeImage={activeImage || product.coverUrl || ''}
onActiveImageChange={setActiveImage}
isPlaying={isPlaying}
onPlayPause={() => setIsPlaying((p) => !p)}
onPlayPause={handlePlayPause}
previewUrl={product.preview_url}
audioRef={audioRef}
/>
<div className="lg:col-span-7 flex flex-col">
<ProductDetailViewInfo product={product} />

View file

@ -1,5 +1,6 @@
/**
* ProductDetailView main image, thumbnails, audio preview overlay.
* v0.401 M1: Playable preview, images from product.images
*/
import { Play, Pause } from 'lucide-react';
import type { Product } from '@/types';
@ -10,6 +11,13 @@ interface ProductDetailViewGalleryProps {
onActiveImageChange: (url: string) => void;
isPlaying: boolean;
onPlayPause: () => void;
previewUrl?: string;
audioRef?: React.RefObject<HTMLAudioElement | null>;
}
function normalizeImages(images: Product['images']): string[] {
if (!images?.length) return [];
return images.map((img) => (typeof img === 'string' ? img : (img as { url: string }).url));
}
export function ProductDetailViewGallery({
@ -18,9 +26,12 @@ export function ProductDetailViewGallery({
onActiveImageChange,
isPlaying,
onPlayPause,
previewUrl,
audioRef,
}: ProductDetailViewGalleryProps) {
const coverUrl = product.coverUrl ?? (activeImage || '');
const images = product.images?.length ? product.images : (coverUrl ? [coverUrl] : []);
const rawImages = product.images;
const images = rawImages?.length ? normalizeImages(rawImages) : (coverUrl ? [coverUrl] : []);
return (
<div className="lg:col-span-5 space-y-4">
@ -32,10 +43,14 @@ export function ProductDetailViewGallery({
/>
<div className="absolute inset-0 bg-black/20 group-hover:bg-transparent transition-colors" />
<div className="absolute bottom-6 left-6 right-6 bg-black/60 backdrop-blur-md rounded-xl p-4 flex items-center gap-4 border border-white/10">
{previewUrl && (
<audio ref={audioRef} src={previewUrl} preload="metadata" />
)}
<button
type="button"
onClick={onPlayPause}
className="w-10 h-10 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:opacity-90 transition-opacity"
onClick={previewUrl ? onPlayPause : undefined}
disabled={!previewUrl}
className="w-10 h-10 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPlaying ? (
<Pause className="w-5 h-5 fill-current" />
@ -53,7 +68,7 @@ export function ProductDetailViewGallery({
</div>
{images.length > 1 && (
<div className="flex gap-4 overflow-x-auto pb-2">
{images.map((img: string, i: number) => (
{images.map((img, i) => (
<button
key={i}
type="button"

View file

@ -1,7 +1,8 @@
/**
* ProductDetailView state and handlers.
* v0.401 M1: Playable preview via preview_url
*/
import { useState, useCallback } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import { useToast } from '@/components/feedback/ToastProvider';
import type { Product, ProductLicense } from '@/types';
@ -10,7 +11,15 @@ export function useProductDetailView(
_onAddToCart: (product: Product, license?: ProductLicense) => void,
) {
const { addToast } = useToast();
const [activeImage, setActiveImage] = useState(product.coverUrl ?? '');
const audioRef = useRef<HTMLAudioElement | null>(null);
const imgArr = product.images;
const firstImg =
Array.isArray(imgArr) && imgArr.length > 0
? typeof imgArr[0] === 'string'
? imgArr[0]
: (imgArr[0] as { url: string })?.url
: '';
const [activeImage, setActiveImage] = useState(product.coverUrl ?? firstImg ?? '');
const [isPlaying, setIsPlaying] = useState(false);
const [selectedLicenseId, setSelectedLicenseId] = useState<string>(
product.licenses?.[0]?.id ?? '',
@ -20,6 +29,32 @@ export function useProductDetailView(
const selectedLicense = product.licenses?.find((l) => l.id === selectedLicenseId);
const handlePlayPause = useCallback(() => {
if (!product.preview_url) {
setIsPlaying(false);
return;
}
setIsPlaying((p) => {
const next = !p;
if (audioRef.current) {
if (next) {
audioRef.current.play().catch(() => setIsPlaying(false));
} else {
audioRef.current.pause();
}
}
return next;
});
}, [product.preview_url]);
useEffect(() => {
if (!audioRef.current || !product.preview_url) return;
const audio = audioRef.current;
const onEnded = () => setIsPlaying(false);
audio.addEventListener('ended', onEnded);
return () => audio.removeEventListener('ended', onEnded);
}, [product.preview_url]);
const handleReviewSubmit = useCallback(
(_rating: number, _comment: string) => {
addToast('Review submitted for moderation', 'success');
@ -32,6 +67,8 @@ export function useProductDetailView(
setActiveImage,
isPlaying,
setIsPlaying,
handlePlayPause,
audioRef,
selectedLicenseId,
setSelectedLicenseId,
selectedLicense,

View file

@ -71,6 +71,22 @@ export const marketplaceService = {
return marketplaceService.listProducts(filters, pagination);
},
getProduct: async (productId: string) => {
const response = await apiClient.get<Product>(`/marketplace/products/${productId}`);
const product = response.data;
if (product?.images && Array.isArray(product.images) && product.images.length > 0) {
const first = product.images[0];
if (typeof first === 'object' && first !== null && 'url' in first) {
(product as any).images = (product.images as { url: string }[]).map((i) => i.url);
}
}
if (product?.previews?.length && product.id) {
const baseUrl = import.meta.env.VITE_API_URL?.replace(/\/api\/v1\/?$/, '') || '';
(product as any).preview_url = `${baseUrl}/api/v1/marketplace/products/${product.id}/preview`;
}
return product;
},
createProduct: async (productData: Partial<Product>) => {
const response = await apiClient.post<Product>(
'/marketplace/products',

View file

@ -52,7 +52,9 @@ export interface Product {
reviewCount?: number;
features?: string[];
licenses?: ProductLicense[];
images?: string[];
images?: string[] | { url: string; sort_order?: number }[];
previews?: { id: string; file_path: string; duration_sec?: number }[];
preview_url?: string; // Stream URL for first preview
bpm?: string | number;
key?: string;
genre?: string;

View file

@ -36,6 +36,8 @@ func (r *APIRouter) setupMarketplaceRoutes(router *gin.RouterGroup) {
group := router.Group("/marketplace")
group.GET("/products", marketHandler.ListProducts)
group.GET("/products/:id", marketHandler.GetProduct)
group.GET("/products/:id/preview", marketHandler.StreamProductPreview)
if r.config.AuthMiddleware != nil {
protected := group.Group("")