"use client"; import { useState, useRef } from "react"; import NextImage from "next/image"; import { trpc } from "~/lib/trpc/client.js"; interface CoverArtSectionProps { projectId: string; coverImageUrl?: string | null; coverFocusY?: number; projectColor?: string | null; projectName: string; canEdit: boolean; } export function CoverArtSection({ projectId, coverImageUrl, coverFocusY = 50, projectColor, projectName, canEdit }: CoverArtSectionProps) { const [imageUrl, setImageUrl] = useState(coverImageUrl ?? null); const [focusY, setFocusY] = useState(coverFocusY); const [generating, setGenerating] = useState(false); const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); const [customPrompt, setCustomPrompt] = useState(""); const [showPromptInput, setShowPromptInput] = useState(false); const [showFocusSlider, setShowFocusSlider] = useState(false); const fileInputRef = useRef(null); const utils = trpc.useUtils(); const { data: imageGenStatus } = trpc.project.isImageGenConfigured.useQuery(); const generateMutation = trpc.project.generateCover.useMutation(); const uploadMutation = trpc.project.uploadCover.useMutation(); const removeMutation = trpc.project.removeCover.useMutation(); const focusMutation = trpc.project.updateCoverFocus.useMutation(); const handleGenerate = async () => { setError(null); setGenerating(true); try { const result = await generateMutation.mutateAsync({ projectId, ...(customPrompt.trim() ? { prompt: customPrompt.trim() } : {}), }); setImageUrl(result.coverImageUrl); setShowPromptInput(false); setCustomPrompt(""); void utils.project.getById.invalidate({ id: projectId }); void utils.project.listWithCosts.invalidate(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to generate cover"); } finally { setGenerating(false); } }; /** Compress an image file to WebP/JPEG via canvas, targeting max 1920px and ~200-400KB output */ const compressImage = (file: File, maxDim = 1920, quality = 0.82): Promise => new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { let { width, height } = img; if (width > maxDim || height > maxDim) { const scale = maxDim / Math.max(width, height); width = Math.round(width * scale); height = Math.round(height * scale); } const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx2d = canvas.getContext("2d"); if (!ctx2d) { reject(new Error("Canvas not supported")); return; } ctx2d.drawImage(img, 0, 0, width, height); // Prefer WebP, fall back to JPEG let dataUrl = canvas.toDataURL("image/webp", quality); if (!dataUrl.startsWith("data:image/webp")) { dataUrl = canvas.toDataURL("image/jpeg", quality); } resolve(dataUrl); }; img.onerror = () => reject(new Error("Failed to load image")); img.src = URL.createObjectURL(file); }); const handleFileSelect = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; if (!file.type.startsWith("image/")) { setError("Please select an image file (PNG, JPG, WebP, etc.)"); return; } if (file.size > 10 * 1024 * 1024) { setError("Image too large. Maximum upload size is 10 MB."); return; } setError(null); setUploading(true); try { const dataUrl = await compressImage(file); const result = await uploadMutation.mutateAsync({ projectId, imageDataUrl: dataUrl, }); setImageUrl(result.coverImageUrl); void utils.project.getById.invalidate({ id: projectId }); void utils.project.listWithCosts.invalidate(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to upload image"); } finally { setUploading(false); if (fileInputRef.current) fileInputRef.current.value = ""; } }; const handleRemove = async () => { setError(null); try { await removeMutation.mutateAsync({ projectId }); setImageUrl(null); setShowFocusSlider(false); void utils.project.getById.invalidate({ id: projectId }); void utils.project.listWithCosts.invalidate(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to remove cover"); } }; const handleFocusSave = async () => { try { await focusMutation.mutateAsync({ projectId, coverFocusY: focusY }); setShowFocusSlider(false); void utils.project.getById.invalidate({ id: projectId }); } catch (err) { setError(err instanceof Error ? err.message : "Failed to save focus point"); } }; const initials = projectName .split(/\s+/) .map((w) => w[0]) .filter(Boolean) .slice(0, 2) .join("") .toUpperCase(); return (
{/* Cover image or placeholder */} {imageUrl ? (
{/* Gradient overlay at bottom for readability */}
) : (
{initials} 1024 × 1024 px
)} {/* Controls overlay */} {canEdit && (
{/* Focus point adjuster — only when image exists */} {imageUrl && ( )} {/* Generate with AI */} {imageGenStatus?.configured && ( )} {/* Upload */} {/* Remove */} {imageUrl && ( )}
)} {/* Focus point slider */} {showFocusSlider && imageUrl && canEdit && (
Top setFocusY(Number(e.target.value))} className="flex-1 accent-brand-600" /> Bottom {focusY}%
)} {/* Custom prompt input (shown when AI Cover is clicked) */} {showPromptInput && !showFocusSlider && canEdit && (
setCustomPrompt(e.target.value)} placeholder="Optional: describe the style you want..." className="flex-1 rounded-lg border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200" onKeyDown={(e) => { if (e.key === "Enter") handleGenerate(); if (e.key === "Escape") { setShowPromptInput(false); setCustomPrompt(""); } }} autoFocus />
)} {/* Error message */} {error && (
{error}
)}
); }