708 lines
24 KiB
TypeScript
708 lines
24 KiB
TypeScript
/**
|
|
* Template List Screen - Lists available templates and starting actions.
|
|
*
|
|
* Allows users to:
|
|
* - View imported templates
|
|
* - Select a template and action to start a new transaction
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import { Box, Text } from 'ink';
|
|
import path from 'node:path';
|
|
import { ScrollableList, type ListItemData } from '../components/List.js';
|
|
import { FilePicker } from '../components/FilePicker.js';
|
|
import { useNavigation } from '../hooks/useNavigation.js';
|
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
|
import { useBlockableInput, useInputLayer, useIsInputCaptured, useLayeredInput } from '../hooks/useInputLayer.js';
|
|
import { colors, logoSmall } from '../theme.js';
|
|
|
|
// XO Imports
|
|
import { generateTemplateIdentifier } from '@xo-cash/engine';
|
|
import type { XOTemplate } from '@xo-cash/types';
|
|
|
|
// Import utility functions
|
|
import {
|
|
formatTemplateListItem,
|
|
formatActionListItem,
|
|
getTemplateRoles,
|
|
} from '../../utils/template-utils.js';
|
|
import { buildScriptHashDataMap } from '../../utils/utxo-metadata.js';
|
|
import { loadTemplateFromFile } from '../../utils/load-template-from-file.js';
|
|
import { ConfirmDialog, DialogWrapper } from '../components/Dialog.js';
|
|
|
|
/**
|
|
* Template item with metadata.
|
|
*/
|
|
interface TemplateItem {
|
|
template: XOTemplate;
|
|
templateIdentifier: string;
|
|
availableActions: TemplateActionItem[];
|
|
}
|
|
|
|
/**
|
|
* Template list item with TemplateItem value.
|
|
*/
|
|
type TemplateListItem = ListItemData<TemplateItem>;
|
|
|
|
/**
|
|
* Action list item with available action value.
|
|
*/
|
|
type ActionListItem = ListItemData<TemplateActionItem>;
|
|
|
|
interface TemplateActionItem {
|
|
actionIdentifier: string;
|
|
name: string;
|
|
description?: string;
|
|
roles: string[];
|
|
source: 'starting' | 'next' | 'starting+next';
|
|
}
|
|
|
|
/** List item key for the synthetic import row. */
|
|
const IMPORT_TEMPLATE_KEY = 'import-template';
|
|
|
|
/** Input layer id shared by the import dialog and its file picker. */
|
|
const IMPORT_TEMPLATE_DIALOG_LAYER_ID = 'import-template-dialog';
|
|
|
|
/**
|
|
* Import template dialog overlay.
|
|
* Captures keyboard input and wraps the generic {@link FilePicker}.
|
|
*/
|
|
function ImportTemplateDialogOverlay({
|
|
onClose,
|
|
onSelectFile,
|
|
}: {
|
|
onClose: () => void;
|
|
onSelectFile: (filePath: string) => void;
|
|
}): React.ReactElement {
|
|
useInputLayer(IMPORT_TEMPLATE_DIALOG_LAYER_ID);
|
|
|
|
useLayeredInput(IMPORT_TEMPLATE_DIALOG_LAYER_ID, (_input, key) => {
|
|
if (key.escape) {
|
|
onClose();
|
|
}
|
|
});
|
|
|
|
return (
|
|
<DialogWrapper title="Import Template" borderColor={colors.primary} width={72}>
|
|
<Text color={colors.textMuted}>
|
|
Select a JSON, JavaScript, or TypeScript template file from disk.
|
|
</Text>
|
|
|
|
<Box marginTop={1}>
|
|
<FilePicker
|
|
layerId={IMPORT_TEMPLATE_DIALOG_LAYER_ID}
|
|
extensions={['json', 'js', 'mjs', 'cjs', 'ts', 'mts', 'cts']}
|
|
onSelectFile={onSelectFile}
|
|
maxVisible={8}
|
|
/>
|
|
</Box>
|
|
|
|
<Box marginTop={1}>
|
|
<Text color={colors.textMuted}>
|
|
↑↓ navigate • Enter open/select • Esc cancel
|
|
</Text>
|
|
</Box>
|
|
</DialogWrapper>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Template List Screen Component.
|
|
* Displays templates and their starting actions.
|
|
*/
|
|
export function TemplateListScreen(): React.ReactElement {
|
|
const { navigate } = useNavigation();
|
|
const { appService, showError } = useAppContext();
|
|
const { setStatus } = useStatus();
|
|
|
|
// State
|
|
const [templates, setTemplates] = useState<TemplateItem[]>([]);
|
|
const [selectedTemplateIndex, setSelectedTemplateIndex] = useState(0);
|
|
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
|
const [focusedPanel, setFocusedPanel] = useState<'templates' | 'actions'>('templates');
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
const [templateToDelete, setTemplateToDelete] = useState<TemplateItem | null>(null);
|
|
const isCaptured = useIsInputCaptured();
|
|
|
|
/**
|
|
* Loads templates from the engine.
|
|
*/
|
|
const loadTemplates = useCallback(async () => {
|
|
if (!appService) {
|
|
showError('AppService not initialized');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
setStatus('Loading templates...');
|
|
|
|
const templateList = await appService.engine.listImportedTemplates();
|
|
const allUtxos = await appService.engine.listUnspentOutputsData();
|
|
const scriptHashDataByScriptHash =
|
|
await buildScriptHashDataMap(appService.engine);
|
|
|
|
const ownedOutputsByTemplate = new Map<string, Set<string>>();
|
|
for (const utxo of allUtxos) {
|
|
const scriptRow = scriptHashDataByScriptHash.get(utxo.scriptHash);
|
|
if (scriptRow === undefined) {
|
|
continue;
|
|
}
|
|
|
|
const existing =
|
|
ownedOutputsByTemplate.get(scriptRow.templateIdentifier) ??
|
|
new Set<string>();
|
|
existing.add(scriptRow.outputIdentifier);
|
|
ownedOutputsByTemplate.set(scriptRow.templateIdentifier, existing);
|
|
}
|
|
|
|
const loadedTemplates = await Promise.all(
|
|
templateList.map(async (template) => {
|
|
const templateIdentifier = generateTemplateIdentifier(template);
|
|
const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier);
|
|
const actionMap = new Map<string, TemplateActionItem>();
|
|
|
|
for (const startingAction of rawStartingActions) {
|
|
const existing = actionMap.get(startingAction.action);
|
|
if (existing) {
|
|
if (!existing.roles.includes(startingAction.role ?? '')) {
|
|
existing.roles.push(startingAction.role ?? '');
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const actionDef = template.actions?.[startingAction.action];
|
|
actionMap.set(startingAction.action, {
|
|
actionIdentifier: startingAction.action,
|
|
name: actionDef?.name || startingAction.action,
|
|
description: actionDef?.description,
|
|
roles: [startingAction.role ?? ''],
|
|
source: 'starting',
|
|
});
|
|
}
|
|
|
|
const ownedOutputIdentifiers = ownedOutputsByTemplate.get(templateIdentifier) ?? new Set<string>();
|
|
for (const outputIdentifier of ownedOutputIdentifiers) {
|
|
const outputDef = template.outputs?.[outputIdentifier];
|
|
if (!outputDef || typeof outputDef.lockingScript !== 'string') continue;
|
|
|
|
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDef.lockingScript] as
|
|
| { roles?: Record<string, { actions?: Array<{ action?: string; role?: string } | string> }> }
|
|
| undefined;
|
|
if (!lockingScriptDefinition?.roles) continue;
|
|
|
|
for (const [lockscriptRoleId, lockscriptRoleDef] of Object.entries(lockingScriptDefinition.roles)) {
|
|
for (const actionSpec of lockscriptRoleDef.actions ?? []) {
|
|
const actionIdentifier = typeof actionSpec === 'string'
|
|
? actionSpec
|
|
: actionSpec.action;
|
|
if (!actionIdentifier) continue;
|
|
|
|
const roleIdentifier = typeof actionSpec === 'string'
|
|
? lockscriptRoleId
|
|
: (actionSpec.role ?? lockscriptRoleId);
|
|
|
|
const existing = actionMap.get(actionIdentifier);
|
|
if (existing) {
|
|
if (!existing.roles.includes(roleIdentifier)) {
|
|
existing.roles.push(roleIdentifier);
|
|
}
|
|
if (existing.source === 'starting') {
|
|
existing.source = 'starting+next';
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const actionDef = template.actions?.[actionIdentifier];
|
|
actionMap.set(actionIdentifier, {
|
|
actionIdentifier,
|
|
name: actionDef?.name || actionIdentifier,
|
|
description: actionDef?.description,
|
|
roles: [roleIdentifier],
|
|
source: 'next',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const availableActions = Array.from(actionMap.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
return {
|
|
template,
|
|
templateIdentifier,
|
|
availableActions,
|
|
};
|
|
})
|
|
);
|
|
|
|
setTemplates(loadedTemplates);
|
|
setSelectedTemplateIndex(0);
|
|
setSelectedActionIndex(0);
|
|
setStatus('Ready');
|
|
setIsLoading(false);
|
|
} catch (error) {
|
|
showError(`Failed to load templates: ${error instanceof Error ? error.message : String(error)}`);
|
|
setIsLoading(false);
|
|
}
|
|
}, [appService, setStatus, showError]);
|
|
|
|
// Load templates on mount
|
|
useEffect(() => {
|
|
loadTemplates();
|
|
}, [loadTemplates]);
|
|
|
|
/**
|
|
* Build template list items for ScrollableList.
|
|
*/
|
|
const templateListItems = useMemo((): TemplateListItem[] => {
|
|
const importTemplateItem: TemplateListItem = {
|
|
key: IMPORT_TEMPLATE_KEY,
|
|
label: 'Import Template',
|
|
description: 'Import a template from a file',
|
|
value: {
|
|
templateIdentifier: IMPORT_TEMPLATE_KEY,
|
|
template: {} as XOTemplate,
|
|
availableActions: [],
|
|
},
|
|
hidden: false,
|
|
};
|
|
|
|
return [...templates.map((item, index) => {
|
|
const formatted = formatTemplateListItem(item.template, index);
|
|
return {
|
|
key: item.templateIdentifier,
|
|
label: formatted.label,
|
|
description: formatted.description,
|
|
value: item,
|
|
hidden: !formatted.isValid,
|
|
};
|
|
}), importTemplateItem];
|
|
}, [templates]);
|
|
|
|
const selectedTemplateListItem = templateListItems[selectedTemplateIndex];
|
|
const isImportRowSelected = selectedTemplateListItem?.key === IMPORT_TEMPLATE_KEY;
|
|
const currentTemplate = isImportRowSelected
|
|
? undefined
|
|
: selectedTemplateListItem?.value;
|
|
const currentActions = currentTemplate?.availableActions ?? [];
|
|
|
|
/**
|
|
* Build action list items for ScrollableList.
|
|
*/
|
|
const actionListItems = useMemo((): ActionListItem[] => {
|
|
return currentActions.map((action, index) => {
|
|
const formatted = formatActionListItem(
|
|
action.actionIdentifier,
|
|
currentTemplate?.template?.actions?.[action.actionIdentifier],
|
|
action.roles.length,
|
|
index
|
|
);
|
|
|
|
return {
|
|
key: action.actionIdentifier,
|
|
label: `${formatted.label}`,
|
|
description: formatted.description,
|
|
value: action,
|
|
hidden: !formatted.isValid,
|
|
};
|
|
});
|
|
}, [currentActions, currentTemplate]);
|
|
|
|
/**
|
|
* Handle template selection change.
|
|
*/
|
|
const handleTemplateSelect = useCallback((index: number) => {
|
|
setSelectedTemplateIndex(index);
|
|
setSelectedActionIndex(0); // Reset action selection when template changes
|
|
}, []);
|
|
|
|
/**
|
|
* Opens the import file picker.
|
|
*/
|
|
const openImportDialog = useCallback(() => {
|
|
setIsImportDialogOpen(true);
|
|
}, []);
|
|
|
|
/**
|
|
* Opens the import file picker when the synthetic import row is activated.
|
|
*/
|
|
const handleTemplateActivate = useCallback((item: TemplateListItem) => {
|
|
if (item.key === IMPORT_TEMPLATE_KEY) {
|
|
openImportDialog();
|
|
}
|
|
}, [openImportDialog]);
|
|
|
|
/**
|
|
* Opens delete confirmation for the currently selected template.
|
|
*/
|
|
const openDeleteDialog = useCallback(() => {
|
|
if (!currentTemplate) {
|
|
return;
|
|
}
|
|
|
|
setTemplateToDelete(currentTemplate);
|
|
setIsDeleteDialogOpen(true);
|
|
}, [currentTemplate]);
|
|
|
|
/**
|
|
* Deletes the confirmed template from local storage.
|
|
*/
|
|
const handleConfirmDelete = useCallback(async () => {
|
|
if (!appService || !templateToDelete) {
|
|
return;
|
|
}
|
|
|
|
const deletedName =
|
|
templateToDelete.template.name || templateToDelete.templateIdentifier;
|
|
|
|
try {
|
|
setStatus('Deleting template...');
|
|
await appService.engine.archiveTemplate(
|
|
templateToDelete.templateIdentifier,
|
|
);
|
|
setIsDeleteDialogOpen(false);
|
|
setTemplateToDelete(null);
|
|
await loadTemplates();
|
|
setStatus(`Deleted ${deletedName}`);
|
|
} catch (error) {
|
|
setIsDeleteDialogOpen(false);
|
|
setTemplateToDelete(null);
|
|
showError(
|
|
`Failed to delete template: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}, [appService, loadTemplates, setStatus, showError, templateToDelete]);
|
|
|
|
/**
|
|
* Loads the selected template file and imports it through the engine.
|
|
*/
|
|
const handleImportFile = useCallback(async (filePath: string) => {
|
|
if (!appService) {
|
|
showError('AppService not initialized');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setStatus('Importing template...');
|
|
const content = await loadTemplateFromFile(filePath);
|
|
await appService.engine.importTemplate(content);
|
|
await loadTemplates();
|
|
setIsImportDialogOpen(false);
|
|
setStatus(`Imported ${path.basename(filePath)}`);
|
|
} catch (error) {
|
|
showError(
|
|
`Failed to import template: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}, [appService, loadTemplates, setStatus, showError]);
|
|
|
|
/**
|
|
* Handles action selection.
|
|
* Navigates to the Action Wizard where the user will choose their role.
|
|
*/
|
|
const handleActionActivate = useCallback((item: ActionListItem, index: number) => {
|
|
if (!currentTemplate || !item.value) return;
|
|
|
|
const action = item.value;
|
|
|
|
// Navigate to the Action Wizard — role selection happens there
|
|
navigate('wizard', {
|
|
templateIdentifier: currentTemplate.templateIdentifier,
|
|
actionIdentifier: action.actionIdentifier,
|
|
actionRoles: action.roles,
|
|
template: currentTemplate.template,
|
|
});
|
|
}, [currentTemplate, navigate]);
|
|
|
|
// Handle keyboard navigation and template shortcuts
|
|
useBlockableInput((input, key) => {
|
|
if (key.tab) {
|
|
setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates');
|
|
return;
|
|
}
|
|
|
|
if (isLoading || focusedPanel !== 'templates') {
|
|
return;
|
|
}
|
|
|
|
if (input === 'a' || input === 'A') {
|
|
openImportDialog();
|
|
return;
|
|
}
|
|
|
|
if ((input === 'd' || input === 'D') && currentTemplate) {
|
|
openDeleteDialog();
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Render custom template list item.
|
|
*/
|
|
const renderTemplateItem = useCallback((
|
|
item: TemplateListItem,
|
|
isSelected: boolean,
|
|
isFocused: boolean
|
|
): React.ReactNode => {
|
|
return (
|
|
<Text
|
|
color={isFocused ? colors.focus : colors.text}
|
|
bold={isSelected}
|
|
>
|
|
{isFocused ? '▸ ' : ' '}
|
|
{item.label}
|
|
</Text>
|
|
);
|
|
}, []);
|
|
|
|
/**
|
|
* Render custom action list item.
|
|
*/
|
|
const renderActionItem = useCallback((
|
|
item: ActionListItem,
|
|
isSelected: boolean,
|
|
isFocused: boolean
|
|
): React.ReactNode => {
|
|
return (
|
|
<Text
|
|
color={isFocused ? colors.focus : colors.text}
|
|
bold={isSelected}
|
|
>
|
|
{isFocused ? '▸ ' : ' '}
|
|
{item.label}
|
|
</Text>
|
|
);
|
|
}, []);
|
|
|
|
return (
|
|
<Box flexDirection="column" flexGrow={1}>
|
|
{/* Header */}
|
|
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
|
|
<Text color={colors.primary} bold>{logoSmall} - Select Template & Action</Text>
|
|
</Box>
|
|
|
|
{/* Main content - two columns */}
|
|
<Box flexDirection="row" marginTop={1} flexGrow={1}>
|
|
{/* Left column: Template list */}
|
|
<Box flexDirection="column" width="40%" paddingRight={1}>
|
|
<Box
|
|
borderStyle="single"
|
|
borderColor={focusedPanel === 'templates' ? colors.focus : colors.primary}
|
|
flexDirection="column"
|
|
paddingX={1}
|
|
flexGrow={1}
|
|
>
|
|
<Text color={colors.primary} bold> Templates </Text>
|
|
{isLoading ? (
|
|
<Box marginTop={1}>
|
|
<Text color={colors.textMuted}>Loading...</Text>
|
|
</Box>
|
|
) : (
|
|
<ScrollableList
|
|
items={templateListItems}
|
|
selectedIndex={selectedTemplateIndex}
|
|
onSelect={handleTemplateSelect}
|
|
onActivate={handleTemplateActivate}
|
|
focus={focusedPanel === 'templates' && !isCaptured}
|
|
emptyMessage="No templates imported"
|
|
renderItem={renderTemplateItem}
|
|
/>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Right column: Actions list */}
|
|
<Box flexDirection="column" width="60%" paddingLeft={1}>
|
|
<Box
|
|
borderStyle="single"
|
|
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
|
|
flexDirection="column"
|
|
paddingX={1}
|
|
flexGrow={1}
|
|
>
|
|
<Text color={colors.primary} bold> Available Actions </Text>
|
|
{isLoading ? (
|
|
<Box marginTop={1}>
|
|
<Text color={colors.textMuted}>Loading...</Text>
|
|
</Box>
|
|
) : isImportRowSelected ? (
|
|
<Box marginTop={1}>
|
|
<Text color={colors.textMuted}>
|
|
Import a template to see available actions
|
|
</Text>
|
|
</Box>
|
|
) : !currentTemplate ? (
|
|
<Box marginTop={1}>
|
|
<Text color={colors.textMuted}>Select a template...</Text>
|
|
</Box>
|
|
) : (
|
|
<ScrollableList
|
|
items={actionListItems}
|
|
selectedIndex={selectedActionIndex}
|
|
onSelect={setSelectedActionIndex}
|
|
onActivate={handleActionActivate}
|
|
focus={focusedPanel === 'actions' && !isCaptured}
|
|
emptyMessage="No actions available"
|
|
renderItem={renderActionItem}
|
|
/>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Description box */}
|
|
<Box marginTop={1}>
|
|
<Box
|
|
borderStyle="single"
|
|
borderColor={colors.border}
|
|
flexDirection="column"
|
|
paddingX={1}
|
|
paddingY={1}
|
|
width="100%"
|
|
>
|
|
<Text color={colors.primary} bold> Description </Text>
|
|
|
|
{/* Show template description when templates panel is focused */}
|
|
{focusedPanel === 'templates' && isImportRowSelected ? (
|
|
<Box marginTop={1} flexDirection="column">
|
|
<Text color={colors.text} bold>Import Template</Text>
|
|
<Text color={colors.textMuted}>
|
|
Import a template file (JSON, JavaScript, or TypeScript) from the directory where the TUI was launched.
|
|
Press Enter or a to open the file picker.
|
|
</Text>
|
|
</Box>
|
|
) : focusedPanel === 'templates' && currentTemplate ? (
|
|
<Box marginTop={1} flexDirection="column">
|
|
<Text color={colors.text} bold>
|
|
{currentTemplate.template.name || 'Unnamed Template'}
|
|
</Text>
|
|
<Text color={colors.textMuted}>
|
|
{currentTemplate.template.description || 'No description available'}
|
|
</Text>
|
|
{currentTemplate.template.version !== undefined && (
|
|
<Text color={colors.text}>
|
|
Version: {currentTemplate.template.version}
|
|
</Text>
|
|
)}
|
|
{currentTemplate.template.roles && (
|
|
<Box marginTop={1} flexDirection="column">
|
|
<Text color={colors.text}>Roles:</Text>
|
|
{getTemplateRoles(currentTemplate.template).map((role) => (
|
|
<Text key={role.roleId} color={colors.textMuted}>
|
|
{' '}- {role.name}: {role.description || 'No description'}
|
|
</Text>
|
|
))}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
) : focusedPanel === 'templates' && !currentTemplate ? (
|
|
<Text color={colors.textMuted}>Select a template to see details</Text>
|
|
) : null}
|
|
|
|
{/* Show action description when actions panel is focused */}
|
|
{focusedPanel === 'actions' && currentTemplate && currentActions.length > 0 ? (
|
|
<Box marginTop={1} flexDirection="column">
|
|
{(() => {
|
|
const action = currentActions[selectedActionIndex];
|
|
if (!action) return null;
|
|
|
|
return (
|
|
<>
|
|
<Text color={colors.text} bold>
|
|
{action.name}
|
|
</Text>
|
|
<Text color={colors.textMuted}>
|
|
{action.description || 'No description available'}
|
|
</Text>
|
|
|
|
{/* List roles available for this action in current context */}
|
|
{action.roles.length > 0 && (
|
|
<Box marginTop={1} flexDirection="column">
|
|
<Text color={colors.text}>Available Roles:</Text>
|
|
{action.roles.map((roleId) => {
|
|
const roleDef = currentTemplate.template.roles?.[roleId];
|
|
const roleName = typeof roleDef === 'object' ? roleDef?.name ?? roleId : roleId;
|
|
const roleDescription = typeof roleDef === 'object' ? roleDef?.description : undefined;
|
|
return (
|
|
<Text key={roleId} color={colors.textMuted}>
|
|
{' '}- {roleName}
|
|
{roleDescription ? `: ${roleDescription}` : ''}
|
|
</Text>
|
|
);
|
|
})}
|
|
<Text color={colors.textMuted}>
|
|
{' '}Source: {action.source}
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
</Box>
|
|
) : focusedPanel === 'actions' && !currentTemplate ? (
|
|
<Text color={colors.textMuted}>Select a template first</Text>
|
|
) : focusedPanel === 'actions' && currentActions.length === 0 ? (
|
|
<Text color={colors.textMuted}>No actions available</Text>
|
|
) : null}
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Help text */}
|
|
<Box marginTop={1}>
|
|
<Text color={colors.textMuted} dimColor>
|
|
{focusedPanel === 'templates' && isImportRowSelected
|
|
? 'Tab: Switch list • a/Enter: Import • ↑↓: Navigate • Esc: Back'
|
|
: focusedPanel === 'templates' && currentTemplate
|
|
? 'Tab: Switch list • a: Import • d: Delete • ↑↓: Navigate • Esc: Back'
|
|
: 'Tab: Switch list • Enter: Select action • ↑↓: Navigate • Esc: Back'}
|
|
</Text>
|
|
</Box>
|
|
|
|
{/* Import template dialog overlay */}
|
|
{isImportDialogOpen && (
|
|
<Box
|
|
position="absolute"
|
|
flexDirection="column"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
width="100%"
|
|
height="100%"
|
|
>
|
|
<ImportTemplateDialogOverlay
|
|
onClose={() => setIsImportDialogOpen(false)}
|
|
onSelectFile={handleImportFile}
|
|
/>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Delete template confirmation dialog */}
|
|
{isDeleteDialogOpen && templateToDelete && (
|
|
<Box
|
|
position="absolute"
|
|
flexDirection="column"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
width="100%"
|
|
height="100%"
|
|
>
|
|
<ConfirmDialog
|
|
title="Delete Template"
|
|
message={
|
|
`Delete "${templateToDelete.template.name || templateToDelete.templateIdentifier}"?\n\n` +
|
|
'This removes the template from local storage. Invitations that use it may become unusable.'
|
|
}
|
|
confirmLabel="Delete"
|
|
cancelLabel="Cancel"
|
|
onConfirm={handleConfirmDelete}
|
|
onCancel={() => {
|
|
setIsDeleteDialogOpen(false);
|
|
setTemplateToDelete(null);
|
|
}}
|
|
/>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|