feat(marketplace): add playable preview and image gallery to ProductDetailView
This commit is contained in:
parent
a8549add70
commit
31a27e4724
6 changed files with 84 additions and 9 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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("")
|
||||
|
|
|
|||
Loading…
Reference in a new issue