502ecba9e9
Schema:
- SystemSettings: geminiApiKey, geminiModel, imageProvider fields
- imageProvider: "dalle" (default) or "gemini"
Gemini Client (packages/api/src/gemini-client.ts):
- Direct HTTP call to Gemini REST API with responseModalities: [TEXT, IMAGE]
- Returns base64 data URL
- Error parsing with user-friendly messages
Router (project.ts):
- generateCover: routes to DALL-E or Gemini based on imageProvider setting
- New isImageGenConfigured query returning { configured, provider }
Admin UI (SystemSettingsClient.tsx):
- "Image Generation" section with provider radio buttons (DALL-E / Gemini)
- Conditional fields: DALL-E config or Gemini API key + model
- Separate save button for image settings
Security:
- geminiApiKey sanitized in audit logs (SENSITIVE_FIELDS)
- API key stored server-side only, never sent to client
Co-Authored-By: claude-flow <ruv@ruv.net>
390 lines
15 KiB
TypeScript
390 lines
15 KiB
TypeScript
"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<string | null>(null);
|
||
const [customPrompt, setCustomPrompt] = useState("");
|
||
const [showPromptInput, setShowPromptInput] = useState(false);
|
||
const [showFocusSlider, setShowFocusSlider] = useState(false);
|
||
const fileInputRef = useRef<HTMLInputElement>(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<string> =>
|
||
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<HTMLInputElement>) => {
|
||
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 (
|
||
<div className="relative overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700">
|
||
{/* Cover image or placeholder */}
|
||
{imageUrl ? (
|
||
<div className="relative">
|
||
<NextImage
|
||
src={imageUrl}
|
||
alt={`Cover art for ${projectName}`}
|
||
width={1024}
|
||
height={1024}
|
||
className="w-full object-cover"
|
||
style={{
|
||
height: "clamp(16rem, 22vw, 22rem)",
|
||
objectPosition: `center ${focusY}%`,
|
||
}}
|
||
unoptimized={imageUrl.startsWith("data:")}
|
||
priority
|
||
/>
|
||
{/* Gradient overlay at bottom for readability */}
|
||
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/40 to-transparent" />
|
||
</div>
|
||
) : (
|
||
<div
|
||
className="flex flex-col items-center justify-center gap-1"
|
||
style={{
|
||
height: "clamp(10rem, 16vw, 14rem)",
|
||
background: projectColor
|
||
? `linear-gradient(135deg, ${projectColor}22, ${projectColor}44)`
|
||
: "linear-gradient(135deg, #e0e7ff, #c7d2fe)",
|
||
}}
|
||
>
|
||
<span
|
||
className="text-4xl font-bold opacity-30"
|
||
style={{ color: projectColor ?? "#6366f1" }}
|
||
>
|
||
{initials}
|
||
</span>
|
||
<span className="text-[10px] font-medium tracking-wide text-gray-400 dark:text-gray-500">
|
||
1024 × 1024 px
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Controls overlay */}
|
||
{canEdit && (
|
||
<div className="absolute right-3 top-3 flex items-center gap-2">
|
||
{/* Focus point adjuster — only when image exists */}
|
||
{imageUrl && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowFocusSlider((v) => !v)}
|
||
className="inline-flex items-center gap-1.5 rounded-lg bg-white/90 px-3 py-1.5 text-xs font-medium text-gray-700 shadow-sm backdrop-blur transition hover:bg-white dark:bg-gray-800/90 dark:text-gray-200 dark:hover:bg-gray-800"
|
||
title="Adjust vertical focus point"
|
||
>
|
||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||
</svg>
|
||
Focus
|
||
</button>
|
||
)}
|
||
|
||
{/* Generate with AI */}
|
||
{imageGenStatus?.configured && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (showPromptInput) {
|
||
handleGenerate();
|
||
} else {
|
||
setShowPromptInput(true);
|
||
}
|
||
}}
|
||
disabled={generating}
|
||
className="inline-flex items-center gap-1.5 rounded-lg bg-white/90 px-3 py-1.5 text-xs font-medium text-gray-700 shadow-sm backdrop-blur transition hover:bg-white disabled:opacity-50 dark:bg-gray-800/90 dark:text-gray-200 dark:hover:bg-gray-800"
|
||
title="Generate cover art with AI"
|
||
>
|
||
{generating ? (
|
||
<>
|
||
<svg className="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||
</svg>
|
||
Generating...
|
||
</>
|
||
) : (
|
||
<>
|
||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||
</svg>
|
||
{showPromptInput ? "Generate" : "AI Cover"}
|
||
</>
|
||
)}
|
||
</button>
|
||
)}
|
||
|
||
{/* Upload */}
|
||
<button
|
||
type="button"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={uploading}
|
||
className="inline-flex items-center gap-1.5 rounded-lg bg-white/90 px-3 py-1.5 text-xs font-medium text-gray-700 shadow-sm backdrop-blur transition hover:bg-white disabled:opacity-50 dark:bg-gray-800/90 dark:text-gray-200 dark:hover:bg-gray-800"
|
||
>
|
||
{uploading ? (
|
||
<>
|
||
<svg className="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||
</svg>
|
||
Uploading...
|
||
</>
|
||
) : (
|
||
<>
|
||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||
</svg>
|
||
Upload
|
||
</>
|
||
)}
|
||
</button>
|
||
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp,image/tiff,image/bmp"
|
||
className="hidden"
|
||
onChange={handleFileSelect}
|
||
/>
|
||
|
||
{/* Remove */}
|
||
{imageUrl && (
|
||
<button
|
||
type="button"
|
||
onClick={handleRemove}
|
||
disabled={removeMutation.isPending}
|
||
className="inline-flex items-center gap-1.5 rounded-lg bg-red-500/80 px-3 py-1.5 text-xs font-medium text-white shadow-sm backdrop-blur transition hover:bg-red-600 disabled:opacity-50"
|
||
title="Remove cover art"
|
||
>
|
||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Focus point slider */}
|
||
{showFocusSlider && imageUrl && canEdit && (
|
||
<div className="absolute inset-x-0 bottom-0 bg-white/95 px-4 py-3 backdrop-blur dark:bg-gray-900/95">
|
||
<div className="flex items-center gap-3">
|
||
<label className="text-xs font-medium text-gray-600 dark:text-gray-300 whitespace-nowrap">
|
||
Focus point
|
||
</label>
|
||
<span className="text-[10px] text-gray-400">Top</span>
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={100}
|
||
value={focusY}
|
||
onChange={(e) => setFocusY(Number(e.target.value))}
|
||
className="flex-1 accent-brand-600"
|
||
/>
|
||
<span className="text-[10px] text-gray-400">Bottom</span>
|
||
<span className="w-8 text-center text-xs tabular-nums text-gray-500">{focusY}%</span>
|
||
<button
|
||
type="button"
|
||
onClick={handleFocusSave}
|
||
disabled={focusMutation.isPending}
|
||
className="rounded-lg bg-brand-600 px-3 py-1 text-xs font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||
>
|
||
{focusMutation.isPending ? "..." : "Save"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setFocusY(coverFocusY);
|
||
setShowFocusSlider(false);
|
||
}}
|
||
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Custom prompt input (shown when AI Cover is clicked) */}
|
||
{showPromptInput && !showFocusSlider && canEdit && (
|
||
<div className="absolute inset-x-0 bottom-0 bg-white/95 p-3 backdrop-blur dark:bg-gray-900/95">
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="text"
|
||
value={customPrompt}
|
||
onChange={(e) => 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
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={handleGenerate}
|
||
disabled={generating}
|
||
className="rounded-lg bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||
>
|
||
{generating ? "..." : "Generate"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowPromptInput(false);
|
||
setCustomPrompt("");
|
||
}}
|
||
className="rounded-lg px-2 py-1.5 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Error message */}
|
||
{error && (
|
||
<div className="absolute inset-x-0 bottom-0 bg-red-50/95 px-3 py-2 text-xs text-red-700 backdrop-blur dark:bg-red-950/90 dark:text-red-300">
|
||
{error}
|
||
<button
|
||
type="button"
|
||
onClick={() => setError(null)}
|
||
className="ml-2 font-medium underline"
|
||
>
|
||
Dismiss
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|