/** * AppPickerPanel * * Self-contained app picker UI rendered inside the GenerateDialog when the user * expands "Choose a specific app (optional)". * * Features: * - Debounced search-as-you-type — typing triggers `onSearchSubmit(query)` * after 500ms of inactivity. Parent fires apps.discover(query) and pipes * results back via `apps`. Empty query → parent should revert to apps.list(). * - Modality toggle — when apps carry modality metadata, default-filter to * the field's modality with a "Show all" escape. * - Card grid — 16:9 thumbnails (image or video preview), 2 columns, * responsive. Cards without thumbnails get a uniform "No preview" placeholder. * * The parent (GenerateDialog) owns the apps[] state and load logic; this * component is pure presentation + local search-input state + debounce. */ import { Box, Button, Card, Flex, Spinner, Stack, Text, TextInput, } from '@sanity/ui'; import { CheckmarkCircleIcon, SearchIcon } from '@sanity/icons'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import type { LaminaPreset } from '../types.js'; const SEARCH_DEBOUNCE_MS = 500; // ─── Types ────────────────────────────────────────────────────────────────── export type AppPickerMode = 'list' | 'discover'; export interface AppPickerEntry { appId: string; name: string; description: string | null; modality?: string | null; outputFormats?: string[]; thumbnail?: { url: string; type: 'image' | 'video' } | null; } interface AppPickerPanelProps { /** Loaded apps (already mapped from list/discover response). */ apps: AppPickerEntry[]; /** Currently selected appId (highlighted in the grid). */ selectedAppId: string | null; /** Mode of the loaded apps: 'list' = all available, 'discover' = ranked-by-search-query. */ mode: AppPickerMode; /** True while the parent is loading apps. */ loading: boolean; /** Error from a failed load (or null). */ error: string | null; /** Field's target modality (e.g. 'image', 'video') used by the modality toggle. */ targetModality: string | null; /** Pin/unpin an app. Parent handles routing memory + estimate fetch. */ onSelect: (app: AppPickerEntry) => void; /** * Fired AFTER the user pauses typing for SEARCH_DEBOUNCE_MS. * Parent should call apps.discover(query) when query is non-empty, * or apps.list() when query is empty (to revert to "all apps"). */ onSearchSubmit: (query: string) => void; /** Clear the pinned selection. */ onClearSelection: () => void; } // ─── Component ────────────────────────────────────────────────────────────── export function AppPickerPanel({ apps, selectedAppId, mode, loading, error, targetModality, onSelect, onSearchSubmit, onClearSelection, }: AppPickerPanelProps) { const [searchTerm, setSearchTerm] = useState(''); const [showAllModalities, setShowAllModalities] = useState(false); // Debounce: when the user stops typing for SEARCH_DEBOUNCE_MS, fire the // submit callback. Parent calls apps.discover(query) (or list() on empty). // We track the last-submitted value to avoid redundant calls when state // re-renders without an actual text change. const lastSubmittedRef = useRef(null); useEffect(() => { const trimmed = searchTerm.trim(); if (lastSubmittedRef.current === trimmed) return; const timer = setTimeout(() => { lastSubmittedRef.current = trimmed; onSearchSubmit(trimmed); }, SEARCH_DEBOUNCE_MS); return () => clearTimeout(timer); }, [searchTerm, onSearchSubmit]); const hasAppsWithModality = apps.some((a) => Boolean(a.modality)); const filterByModality = hasAppsWithModality && !showAllModalities && targetModality; // Modality is the only client-side filter — text matching is server-side via // discover(). Keeps the result set consistent with what the server scored. const visibleApps = useMemo(() => { if (!filterByModality) return apps; return apps.filter((app) => !app.modality || app.modality === targetModality); }, [apps, filterByModality, targetModality]); const headerText = mode === 'discover' && lastSubmittedRef.current ? `Results for "${lastSubmittedRef.current}"` : 'Available apps'; return ( {/* Search input — debounced 500ms, drives discover() */} setSearchTerm(e.currentTarget.value)} placeholder="Search apps (e.g. 'product image', 'reel video')…" fontSize={1} /> {/* Header line + modality toggle */} {headerText} {hasAppsWithModality ? (