/// /** * 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 */}
{label {/* 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;