Files
CapaKraken/apps/web/src/components/projects/CoverArtSection.tsx
T
Hartmut 502ecba9e9 feat: Google Gemini image generation for project covers
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>
2026-03-23 15:02:35 +01:00

390 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}