feat(marketplace): add rich text description with sanitization
This commit is contained in:
parent
d57c45c32e
commit
f25956e9e2
4 changed files with 117 additions and 5 deletions
|
|
@ -1,7 +1,9 @@
|
|||
/**
|
||||
* ProductDetailView — description card.
|
||||
* v0.401 M1.11: Rich text description with sanitization
|
||||
*/
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { sanitizeProductDescription } from '@/utils/sanitize';
|
||||
import type { Product } from '@/types';
|
||||
|
||||
interface ProductDetailViewDescriptionProps {
|
||||
|
|
@ -9,13 +11,19 @@ interface ProductDetailViewDescriptionProps {
|
|||
}
|
||||
|
||||
export function ProductDetailViewDescription({ product }: ProductDetailViewDescriptionProps) {
|
||||
const safeDescription = sanitizeProductDescription(product.description ?? '');
|
||||
|
||||
return (
|
||||
<Card variant="default">
|
||||
<h3 className="font-bold text-foreground text-xl mb-4 border-b border-border pb-2 tracking-tight">
|
||||
Description
|
||||
</h3>
|
||||
<div className="prose prose-invert max-w-none text-foreground">
|
||||
<p>{product.description}</p>
|
||||
{safeDescription ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: safeDescription }} />
|
||||
) : (
|
||||
<p>{product.description || 'No description.'}</p>
|
||||
)}
|
||||
{product.features?.length ? (
|
||||
<ul>
|
||||
{product.features.map((f: string, i: number) => (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useRef } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tag } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tag, Bold, List } from 'lucide-react';
|
||||
|
||||
interface CreateProductViewDetailsCardProps {
|
||||
title: string;
|
||||
|
|
@ -31,6 +33,26 @@ export function CreateProductViewDetailsCard({
|
|||
keyVal,
|
||||
setKey,
|
||||
}: CreateProductViewDetailsCardProps) {
|
||||
const descriptionRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const insertAtCursor = (before: string, after: string) => {
|
||||
const ta = descriptionRef.current;
|
||||
if (!ta) return;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const text = description;
|
||||
const selected = text.slice(start, end) || 'text';
|
||||
const newText = text.slice(0, start) + before + selected + after + text.slice(end);
|
||||
setDescription(newText);
|
||||
setTimeout(() => {
|
||||
ta.focus();
|
||||
ta.setSelectionRange(start + before.length, start + before.length + selected.length);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleBold = () => insertAtCursor('<b>', '</b>');
|
||||
const handleList = () => insertAtCursor('\n<ul>\n<li>', '</li>\n</ul>\n');
|
||||
|
||||
return (
|
||||
<Card variant="default">
|
||||
<h3 className="font-bold text-foreground mb-6 border-b border-border pb-2 tracking-tight">
|
||||
|
|
@ -47,9 +69,19 @@ export function CreateProductViewDetailsCard({
|
|||
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
||||
Description
|
||||
</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleBold} className="h-8 px-3" aria-label="Bold">
|
||||
<Bold className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleList} className="h-8 px-3" aria-label="Insert list">
|
||||
<List className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground self-center">Basic HTML: <b>, <ul><li></span>
|
||||
</div>
|
||||
<textarea
|
||||
ref={descriptionRef}
|
||||
className="w-full bg-muted border border-border rounded-xl p-4 text-foreground focus:border-primary outline-none focus-visible:ring-2 focus-visible:ring-ring min-h-24 transition-colors duration-[var(--sumi-duration-normal)]"
|
||||
placeholder="Describe your sound pack..."
|
||||
placeholder="Describe your sound pack... Use Bold and List for formatting."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -322,6 +322,34 @@ export function sanitizeChatMessage(message: string): string {
|
|||
return sanitizeHTML(message, chatOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitise la description produit (rich text) pour affichage sécurisé
|
||||
* v0.401 M1.11: Bold, listes (ul, ol, li), paragraphes
|
||||
*/
|
||||
export function sanitizeProductDescription(html: string): string {
|
||||
if (!html?.trim()) return '';
|
||||
if (typeof window !== 'undefined' && DOMPurify.isSupported) {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'i', 'b', 'ul', 'ol', 'li', 'span', 'a'],
|
||||
ALLOWED_ATTR: ['class', 'href', 'title', 'target'],
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
||||
KEEP_CONTENT: true,
|
||||
RETURN_DOM: false,
|
||||
RETURN_DOM_FRAGMENT: false,
|
||||
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button'],
|
||||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
|
||||
});
|
||||
}
|
||||
const opts: SanitizeOptions = {
|
||||
allowedTags: ['p', 'br', 'strong', 'em', 'u', 'i', 'b', 'ul', 'ol', 'li', 'span', 'a'],
|
||||
allowedAttributes: { a: ['href', 'title', 'target'], span: ['class'] },
|
||||
allowedSchemes: ['http', 'https', 'mailto'],
|
||||
stripUnknownTags: true,
|
||||
stripEmptyTags: true,
|
||||
};
|
||||
return sanitizeHTML(html, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitise les noms d'utilisateur et autres champs texte
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
|
||||
"veza-backend-api/internal/core/marketplace"
|
||||
"veza-backend-api/internal/response"
|
||||
"veza-backend-api/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
|
@ -78,7 +79,7 @@ func (h *MarketplaceHandler) CreateProduct(c *gin.Context) {
|
|||
product := &marketplace.Product{
|
||||
SellerID: userID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Description: utils.SanitizeHTML(req.Description, 5000),
|
||||
Price: req.Price,
|
||||
ProductType: req.ProductType,
|
||||
LicenseType: marketplace.LicenseType(req.LicenseType),
|
||||
|
|
@ -268,6 +269,49 @@ func (h *MarketplaceHandler) UploadProductPreview(c *gin.Context) {
|
|||
response.Created(c, preview)
|
||||
}
|
||||
|
||||
// GetProduct returns a single product by ID (v0.401 M1 - includes previews and images)
|
||||
// GET /marketplace/products/:id
|
||||
func (h *MarketplaceHandler) GetProduct(c *gin.Context) {
|
||||
productID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid product id")
|
||||
return
|
||||
}
|
||||
product, err := h.service.GetProduct(c.Request.Context(), productID)
|
||||
if err != nil {
|
||||
if err == marketplace.ErrProductNotFound {
|
||||
response.NotFound(c, "Product not found")
|
||||
return
|
||||
}
|
||||
response.InternalServerError(c, "Failed to get product")
|
||||
return
|
||||
}
|
||||
response.Success(c, product)
|
||||
}
|
||||
|
||||
// StreamProductPreview streams the first audio preview for a product (v0.401 M1)
|
||||
// GET /marketplace/products/:id/preview
|
||||
func (h *MarketplaceHandler) StreamProductPreview(c *gin.Context) {
|
||||
productID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid product id")
|
||||
return
|
||||
}
|
||||
product, err := h.service.GetProduct(c.Request.Context(), productID)
|
||||
if err != nil || len(product.Previews) == 0 {
|
||||
response.NotFound(c, "Preview not found")
|
||||
return
|
||||
}
|
||||
preview := product.Previews[0]
|
||||
fullPath := filepath.Join(h.uploadDir, preview.FilePath)
|
||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||
response.NotFound(c, "Preview file not found")
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", "inline")
|
||||
c.File(fullPath)
|
||||
}
|
||||
|
||||
// UpdateProductImagesRequest body for PUT /products/:id/images
|
||||
type UpdateProductImagesRequest struct {
|
||||
Images []struct {
|
||||
|
|
@ -466,7 +510,7 @@ func (h *MarketplaceHandler) UpdateProduct(c *gin.Context) {
|
|||
updates["title"] = *req.Title
|
||||
}
|
||||
if req.Description != nil {
|
||||
updates["description"] = *req.Description
|
||||
updates["description"] = utils.SanitizeHTML(*req.Description, 5000)
|
||||
}
|
||||
if req.Price != nil {
|
||||
updates["price"] = *req.Price
|
||||
|
|
|
|||
Loading…
Reference in a new issue