integratedhub/islands/ImageGenerater.tsx
2026-01-20 09:02:26 -06:00

218 lines
6.8 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 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={{
backgroundImage: `${
displayImage &&
(displayImage.startsWith("http") || displayImage.startsWith("/"))
? "url(" +
CSS.escape(
decodeData(displayImage),
) + ")"
: "url(data:image/png;base64," + displayImage + ")"
}`,
backgroundSize: "cover",
backgroundPosition: "center",
width: "512px",
height: "512px",
}}
>
<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
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
className="theme-image-button px-2 py-1 text-xs"
onClick={handleSave}
>
Save
</button>
)}
<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;
};