///
/**
* Photo Module - Pattern for photo upload with optional label
*
* A composable pattern that can be used standalone or embedded in containers
* like Record. Supports uploading a single photo with an optional label.
* Demonstrates the settingsUI pattern for module configuration.
*/
import {
Cell,
type Default,
handler,
ifElse,
ImageData,
lift,
NAME,
recipe,
str,
UI,
} from "commontools";
import type { ModuleMetadata } from "./container-protocol.ts";
// ===== Self-Describing Metadata =====
export const MODULE_METADATA: ModuleMetadata = {
type: "photo",
label: "Photo",
icon: "\u{1F4F7}", // 📷 camera emoji
schema: {
photoUrl: { type: "string", description: "Photo data URL" },
photoLabel: { type: "string", description: "Photo label/name" },
},
fieldMapping: ["photoUrl", "photoLabel"],
allowMultiple: true,
hasSettings: true,
};
// ===== Types =====
export interface PhotoModuleInput {
/** The uploaded image data (null if no image) */
image: Default;
/** User-defined label for the photo */
label: Default;
}
// Output interface with unknown for UI properties to prevent OOM (CT-1148)
// TypeScript infers deeply nested VNode types without this, causing memory explosion
interface PhotoModuleOutput {
[NAME]: unknown;
[UI]: unknown;
settingsUI: unknown;
image: ImageData | null;
label: string;
}
// ===== Handlers =====
// Handler to clear the photo
const clearPhoto = handler<
unknown,
{ images: Cell }
>((_event, { images }) => {
images.set([]);
});
// ===== The Pattern =====
export const PhotoModule = recipe(
"PhotoModule",
({ image: inputImage, label }) => {
// We use an array internally for ct-image-input compatibility
// but the module only supports a single image
// NOTE: Cell.of must use empty array to avoid TypeScript OOM (CT-1148)
// Using input params in Cell.of() causes deep type inference explosion
const images = Cell.of([]);
// Sync image Cell with images array (first element)
// Also handles initialization from inputImage for import/restore
const syncedImage = lift(
({ input, arr }: { input: ImageData | null; arr: ImageData[] }) => {
// If we have stored images, use the first one
if (arr && arr.length > 0) return arr[0];
// Otherwise, use the input image (for initialization)
return input;
},
)({ input: inputImage, arr: images });
// Check if we have a photo - use lift for reactive boolean
// Checks both stored images and input image
const hasPhoto = lift(
({ input, arr }: { input: ImageData | null; arr: ImageData[] }) => {
return (arr && arr.length > 0) || !!input;
},
)({ input: inputImage, arr: images });
// Display text for NAME
const displayText = lift(
({
input,
arr,
photoLabel,
}: {
input: ImageData | null;
arr: ImageData[];
photoLabel: string;
}) => {
const hasImage = (arr && arr.length > 0) || !!input;
if (photoLabel && hasImage) return photoLabel;
if (hasImage) return "Photo uploaded";
return "No photo";
},
)({ input: inputImage, arr: images, photoLabel: label });
// Get the image URL reactively
const imageUrl = lift(({ img }: { img: ImageData | null }) => {
return img?.url || "";
})({ img: syncedImage });
// Check if label is set
const hasLabel = lift(({ l }: { l: string }) => !!l)({ l: label });
return {
[NAME]: str`${MODULE_METADATA.icon} ${displayText}`,
[UI]: (
{ifElse(
hasPhoto,
// Photo is uploaded - show image with clear button
{/* Display the uploaded image */}

{/* Clear button */}
{/* Label display (if set) */}
{ifElse(
hasLabel,
{label}
,
null,
)}
,
// No photo yet - show upload input
,
)}
),
// Settings UI - for configuring the label
settingsUI: (
),
image: syncedImage,
label,
};
},
);
export default PhotoModule;