213 lines
6.7 KiB
TypeScript
213 lines
6.7 KiB
TypeScript
import { useState } from "preact/hooks";
|
|
import { ImageFile } from "../utils/types.ts";
|
|
import { decodeData } from "../utils/urlEncoder.ts";
|
|
import PhotoCropperModal from "./PhotoCropperModal.tsx";
|
|
|
|
interface ImageGeneraterProps {
|
|
text_1: string;
|
|
image_data: string;
|
|
role: string;
|
|
recordingCallback: (data: ImageFile | null) => void;
|
|
}
|
|
|
|
export default function ImageGenerater(
|
|
{ text_1, image_data, role, recordingCallback }: ImageGeneraterProps,
|
|
) {
|
|
const [isVisible, setIsVisible] = useState(true);
|
|
const [displayImage, setDisplayImage] = useState(image_data);
|
|
// User-editable description shown in the input field
|
|
const [nexAIDescription, setNextAiDescription] = useState(
|
|
removeDashAndNumbers(
|
|
removePngExtension(text_1),
|
|
),
|
|
);
|
|
// Actual saved filename from AI generation (with timestamp hash)
|
|
// Falls back to the initial text_1 if no AI generation occurred
|
|
const [actualFileName, setActualFileName] = useState(text_1);
|
|
|
|
const [ai_nameError, setAiGenerateNameError] = useState(false);
|
|
const [showCropperModal, setShowCropperModal] = useState(false);
|
|
const [isAiGenerating, setIsAiGenerating] = useState(false);
|
|
|
|
const backgroundImage = displayImage
|
|
? (displayImage.startsWith("http") || displayImage.startsWith("/"))
|
|
? `url(${CSS.escape(decodeData(displayImage))})`
|
|
: `url(data:image/png;base64,${displayImage})`
|
|
: "none";
|
|
|
|
const handleSave = () => {
|
|
setIsVisible(false);
|
|
// image_file: the actual saved filename (with timestamp hash for AI-generated)
|
|
// image_text: the user-editable description (preserves spaces, casing)
|
|
// image_data: the URL or base64 data
|
|
recordingCallback({
|
|
image_text: nexAIDescription,
|
|
image_file: actualFileName,
|
|
image_data: displayImage,
|
|
});
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
setIsVisible(false);
|
|
recordingCallback(null);
|
|
};
|
|
|
|
if (!isVisible) return null;
|
|
|
|
const handleAiGenerate = async () => {
|
|
if (isAiGenerating) return;
|
|
|
|
const preloadImage = (src: string) =>
|
|
new Promise<void>((resolve) => {
|
|
const img = new Image();
|
|
img.onload = () => resolve();
|
|
img.onerror = () => resolve();
|
|
img.src = src;
|
|
});
|
|
|
|
setIsAiGenerating(true);
|
|
try {
|
|
const id = nexAIDescription.trim().slice(0, 120);
|
|
const apiResponse = await fetch(
|
|
`/api/ai-images/${encodeURIComponent(id)}`,
|
|
{
|
|
method: "GET",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
},
|
|
);
|
|
if (!apiResponse.ok) {
|
|
throw new Error(
|
|
`Failed to fetch image from local API: ${apiResponse.statusText}`,
|
|
);
|
|
}
|
|
const newImageData = await apiResponse.json() as ImageFile;
|
|
|
|
const rawImageData = newImageData.image_data ?? "";
|
|
const nextSrc = rawImageData &&
|
|
(rawImageData.startsWith("http") || rawImageData.startsWith("/"))
|
|
? decodeData(rawImageData)
|
|
: `data:image/png;base64,${rawImageData}`;
|
|
|
|
await preloadImage(nextSrc);
|
|
|
|
// Update display image with the URL/data from API
|
|
setDisplayImage(newImageData.image_data);
|
|
|
|
// Preserve the actual saved filename (with timestamp) from the API response
|
|
// This is the real file on disk, e.g., "a_person_running-20251212123456.png"
|
|
if (newImageData.image_file) {
|
|
setActualFileName(newImageData.image_file);
|
|
}
|
|
} catch (e) {
|
|
console.error("Error generating AI image:", e);
|
|
} finally {
|
|
setIsAiGenerating(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="theme-image-overlay">
|
|
<div
|
|
className="theme-image-container"
|
|
style={{ "--image-url": backgroundImage } as React.CSSProperties}
|
|
>
|
|
<p>
|
|
<span className="strokelittle">
|
|
{role === "admin" && (
|
|
<input
|
|
type="text"
|
|
value={nexAIDescription}
|
|
onChange={(e) => setNextAiDescription(e.currentTarget.value)}
|
|
onKeyDown={() => setAiGenerateNameError(false)}
|
|
placeholder="Quiz Name"
|
|
className={`theme-image-input ${
|
|
ai_nameError ? "border-red-500" : ""
|
|
}`}
|
|
/>
|
|
)}
|
|
</span>
|
|
</p>
|
|
<span className="absolute bottom-0 left-0 right-0 flex justify-center gap-2 pb-4 px-6">
|
|
{role === "admin" && (
|
|
<button
|
|
type="button"
|
|
className={`theme-image-button ${
|
|
isAiGenerating ? "opacity-60 cursor-not-allowed" : ""
|
|
} px-0 py-1 text-xs`}
|
|
onClick={handleAiGenerate}
|
|
disabled={isAiGenerating}
|
|
>
|
|
{isAiGenerating
|
|
? (
|
|
<span className="flex items-center gap-2">
|
|
<span
|
|
className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent"
|
|
aria-hidden="true"
|
|
/>
|
|
Generating
|
|
</span>
|
|
)
|
|
: "AI Generate"}
|
|
</button>
|
|
)}
|
|
{role === "admin" && (
|
|
<button
|
|
type="button"
|
|
className="theme-image-button px-2 py-1 text-xs"
|
|
onClick={() => setShowCropperModal(true)}
|
|
>
|
|
Upload
|
|
</button>
|
|
)}
|
|
{role === "admin" && (
|
|
<button
|
|
type="button"
|
|
className="theme-image-button px-2 py-1 text-xs"
|
|
onClick={handleSave}
|
|
>
|
|
Save
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="theme-image-button px-2 py-1 text-xs"
|
|
onClick={handleCancel}
|
|
>
|
|
{role === "admin" ? "Cancel" : "Close"}
|
|
</button>
|
|
</span>
|
|
</div>
|
|
{showCropperModal && (
|
|
<PhotoCropperModal
|
|
onComplete={(imageData) => {
|
|
setDisplayImage(imageData);
|
|
// For uploaded images (base64), use the description as filename
|
|
// This will be hashed when uploaded via /api/image-blob/
|
|
setActualFileName(nexAIDescription);
|
|
setShowCropperModal(false);
|
|
}}
|
|
onCancel={() => setShowCropperModal(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const removePngExtension = (str: string) => {
|
|
const pngExtension = ".png";
|
|
if (str.toLowerCase().endsWith(pngExtension)) {
|
|
return str.slice(0, -pngExtension.length);
|
|
}
|
|
return str;
|
|
};
|
|
|
|
export const removeDashAndNumbers = (str: string) => {
|
|
const pattern = /-\d{14}$/;
|
|
if (pattern.test(str)) {
|
|
return str.slice(0, -15); // Remove the dash and 14 numeric characters
|
|
}
|
|
return str;
|
|
};
|