diff --git a/invokeai/app/services/style_preset_records/style_preset_records_common.py b/invokeai/app/services/style_preset_records/style_preset_records_common.py index 34a30d0377..0391b690cc 100644 --- a/invokeai/app/services/style_preset_records/style_preset_records_common.py +++ b/invokeai/app/services/style_preset_records/style_preset_records_common.py @@ -128,9 +128,9 @@ async def parse_presets_from_file(file: UploadFile) -> list[StylePresetWithoutId style_presets.append(style_preset) except pydantic.ValidationError as e: if file.content_type == "text/csv": - msg = "Invalid CSV format: must include columns 'name', 'prompt', and 'negative_prompt'" + msg = "Invalid CSV format: must include columns 'name', 'prompt', and 'negative_prompt' and name cannot be blank" else: # file.content_type == "application/json": - msg = "Invalid JSON format: must be a list of objects with keys 'name', 'prompt', and 'negative_prompt'" + msg = "Invalid JSON format: must be a list of objects with keys 'name', 'prompt', and 'negative_prompt' and name cannot be blank" raise InvalidPresetImportDataError(msg) from e finally: file.file.close() diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index ec0ae472bb..b2959245ea 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1701,10 +1701,14 @@ "deleteImage": "Delete Image", "deleteTemplate": "Delete Template", "deleteTemplate2": "Are you sure you want to delete this template? This cannot be undone.", + "downloadCsv": "Download CSV", "editTemplate": "Edit Template", + "exportDownloaded": "Export Downloaded", + "exportFailed": "Unable to generate and download CSV", "flatten": "Flatten selected template into current prompt", "importTemplates": "Import Prompt Templates", - "importTemplatesDesc": "Format must be either a CSV with columns: 'name', 'prompt' or 'positive_prompt', and 'negative_prompt' included, or a JSON file with keys 'name', 'prompt' or 'positive_prompt', and 'negative_prompt", + "importTemplatesDesc": "Accepted formats: CSV or JSON", + "importTemplatesDesc2": "Accepted columns/keys: 'name', 'prompt' or 'positive_prompt', 'negative_prompt", "insertPlaceholder": "Insert placeholder", "myTemplates": "My Templates", "name": "Name", @@ -1719,6 +1723,7 @@ "searchByName": "Search by name", "shared": "Shared", "sharedTemplates": "Shared Templates", + "templateActions": "Template Actions", "templateDeleted": "Prompt template deleted", "toggleViewMode": "Toggle View Mode", "type": "Type", diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetImport.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetImport.tsx deleted file mode 100644 index 9a248e0e0f..0000000000 --- a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetImport.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { IconButton, spinAnimation, Text } from '@invoke-ai/ui-library'; -import { toast } from 'features/toast/toast'; -import { useCallback } from 'react'; -import { useDropzone } from 'react-dropzone'; -import { useTranslation } from 'react-i18next'; -import { PiSpinner, PiUploadBold } from 'react-icons/pi'; -import { useImportStylePresetsMutation } from 'services/api/endpoints/stylePresets'; - -const loadingStyles: SystemStyleObject = { - svg: { animation: spinAnimation }, -}; - -export const StylePresetImport = () => { - const [importStylePresets, { isLoading }] = useImportStylePresetsMutation(); - const { t } = useTranslation(); - - const onDropAccepted = useCallback( - async (files: File[]) => { - const file = files[0]; - if (!file) { - return; - } - try { - await importStylePresets(file).unwrap(); - toast({ - status: 'success', - title: t('toast.importSuccessful'), - }); - } catch (error) { - toast({ - status: 'error', - title: t('toast.importFailed'), - }); - } - }, - [importStylePresets, t] - ); - - const { getInputProps, getRootProps } = useDropzone({ - accept: { 'text/csv': ['.csv'], 'application/json': ['.json'] }, - onDropAccepted, - noDrag: true, - multiple: false, - }); - - return ( - <> - : } - tooltip={ - <> - {t('stylePresets.importTemplates')} - {t('stylePresets.importTemplatesDesc')} - - } - aria-label={t('stylePresets.importTemplates')} - size="md" - variant="link" - w={8} - h={8} - sx={isLoading ? loadingStyles : undefined} - isDisabled={isLoading} - {...getRootProps()} - /> - - - ); -}; diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx index d8023d63cb..06742053af 100644 --- a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx @@ -1,27 +1,13 @@ -import { Flex, IconButton, Text } from '@invoke-ai/ui-library'; +import { Flex, Text } from '@invoke-ai/ui-library'; import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppSelector } from 'app/store/storeHooks'; -import { $stylePresetModalState } from 'features/stylePresets/store/stylePresetModal'; -import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiDownloadBold, PiPlusBold } from 'react-icons/pi'; import type { StylePresetRecordWithImage } from 'services/api/endpoints/stylePresets'; -import { useLazyExportStylePresetsQuery, useListStylePresetsQuery } from 'services/api/endpoints/stylePresets'; +import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets'; -import { StylePresetImport } from './StylePresetImport'; import { StylePresetList } from './StylePresetList'; +import { StylePresetMenuActions } from './StylePresetMenuActions/StylePresetMenuActions'; import StylePresetSearch from './StylePresetSearch'; -import { toast } from '../../toast/toast'; - -const generateCSV = (data: any[]) => { - const header = ['Column1', 'Column2', 'Column3']; - const csvRows = [ - header.join(','), // add header row - ...data.map((row) => row.join(',')), // add data rows - ]; - - return csvRows.join('\n'); -}; export const StylePresetMenu = () => { const searchTerm = useAppSelector((s) => s.stylePreset.searchTerm); @@ -59,73 +45,12 @@ export const StylePresetMenu = () => { }); const { t } = useTranslation(); - const [exportStylePresets, { isLoading }] = useLazyExportStylePresetsQuery(); - - const handleClickAddNew = useCallback(() => { - $stylePresetModalState.set({ - prefilledFormData: null, - updatingStylePresetId: null, - isModalOpen: true, - }); - }, []); - - const handleClickDownloadCsv = useCallback(async () => { - let blob; - try { - const response = await exportStylePresets().unwrap(); - blob = new Blob([response], { type: 'text/csv' }); - } catch (error) { - toast({ - status: 'error', - title: 'Unable to generate and download export', - }); - } - - if (blob) { - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'data.csv'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); - } - toast({ - status: 'success', - title: 'Export downloaded', - }); - }, [exportStylePresets]); return ( - - - } - tooltip={t('stylePresets.createPromptTemplate')} - aria-label={t('stylePresets.createPromptTemplate')} - onClick={handleClickDownloadCsv} - size="md" - variant="link" - w={8} - h={8} - isDisabled={isLoading} - /> - - } - tooltip={t('stylePresets.createPromptTemplate')} - aria-label={t('stylePresets.createPromptTemplate')} - onClick={handleClickAddNew} - size="md" - variant="link" - w={8} - h={8} - /> - + {data.presets.length === 0 && data.defaultPresets.length === 0 && ( @@ -135,11 +60,9 @@ export const StylePresetMenu = () => { )} - {allowPrivateStylePresets && ( )} - ); diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetExport.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetExport.tsx new file mode 100644 index 0000000000..9e93e78be4 --- /dev/null +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetExport.tsx @@ -0,0 +1,47 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { toast } from 'features/toast/toast'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiDownloadSimpleBold, PiSpinner } from 'react-icons/pi'; +import { useLazyExportStylePresetsQuery } from 'services/api/endpoints/stylePresets'; + +export const StylePresetExport = ({ onClose }: { onClose: () => void }) => { + const [exportStylePresets, { isLoading }] = useLazyExportStylePresetsQuery(); + const { t } = useTranslation(); + + const handleClickDownloadCsv = useCallback(async () => { + let blob; + try { + const response = await exportStylePresets().unwrap(); + blob = new Blob([response], { type: 'text/csv' }); + } catch (error) { + toast({ + status: 'error', + title: t('stylePresets.exportFailed'), + }); + } finally { + onClose(); + } + + if (blob) { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'data.csv'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + toast({ + status: 'success', + title: t('stylePresets.exportDownloaded'), + }); + } + }, [exportStylePresets, onClose, t]); + + return ( + : } onClickCapture={handleClickDownloadCsv}> + {t('stylePresets.downloadCsv')} + + ); +}; diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetImport.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetImport.tsx new file mode 100644 index 0000000000..e6d96da7cb --- /dev/null +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetImport.tsx @@ -0,0 +1,62 @@ +import { Flex, MenuItem, Text } from '@invoke-ai/ui-library'; +import { toast } from 'features/toast/toast'; +import { useCallback } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { useTranslation } from 'react-i18next'; +import { PiSpinner, PiUploadSimpleBold } from 'react-icons/pi'; +import { useImportStylePresetsMutation } from 'services/api/endpoints/stylePresets'; + +export const StylePresetImport = ({ onClose }: { onClose: () => void }) => { + const [importStylePresets, { isLoading }] = useImportStylePresetsMutation(); + const { t } = useTranslation(); + + const onDropAccepted = useCallback( + (files: File[]) => { + const file = files[0]; + if (!file) { + return; + } + importStylePresets(file) + .unwrap() + .then(() => { + toast({ + status: 'success', + title: t('toast.importSuccessful'), + }); + }) + .catch((error) => { + toast({ + status: 'error', + title: t('toast.importFailed'), + description: error ? `${error.data.detail}` : undefined, + }); + }) + .finally(() => { + onClose(); + }); + }, + [importStylePresets, t, onClose] + ); + + const { getInputProps, getRootProps } = useDropzone({ + accept: { 'text/csv': ['.csv'], 'application/json': ['.json'] }, + onDropAccepted, + noDrag: true, + multiple: false, + }); + + return ( + : } alignItems="flex-start" {...getRootProps()}> + + {t('stylePresets.importTemplates')} + + {t('stylePresets.importTemplatesDesc')} + + + {t('stylePresets.importTemplatesDesc2')} + + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetMenuActions.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetMenuActions.tsx new file mode 100644 index 0000000000..1048a009d0 --- /dev/null +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetMenuActions.tsx @@ -0,0 +1,50 @@ +import { Flex, IconButton, Menu, MenuButton, MenuList, useDisclosure } from '@invoke-ai/ui-library'; +import { $stylePresetModalState } from 'features/stylePresets/store/stylePresetModal'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiDotsThreeOutlineFill, PiPlusBold } from 'react-icons/pi'; + +import { StylePresetExport } from './StylePresetExport'; +import { StylePresetImport } from './StylePresetImport'; + +export const StylePresetMenuActions = () => { + const { isOpen, onClose, onToggle } = useDisclosure(); + const handleClickAddNew = useCallback(() => { + $stylePresetModalState.set({ + prefilledFormData: null, + updatingStylePresetId: null, + isModalOpen: true, + }); + }, []); + + const { t } = useTranslation(); + + return ( + + } + tooltip={t('stylePresets.createPromptTemplate')} + aria-label={t('stylePresets.createPromptTemplate')} + onClick={handleClickAddNew} + size="md" + variant="ghost" + w={8} + h={8} + /> + + } + onClick={onToggle} + /> + + + + + + + ); +}; diff --git a/invokeai/frontend/web/src/services/api/endpoints/stylePresets.ts b/invokeai/frontend/web/src/services/api/endpoints/stylePresets.ts index 2626e75eac..2bc945f86e 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/stylePresets.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/stylePresets.ts @@ -92,13 +92,10 @@ export const stylePresetsApi = api.injectEndpoints({ }), providesTags: ['FetchOnReconnect', { type: 'StylePreset', id: LIST_TAG }], }), - exportStylePresets: build.query< - string, - void - >({ + exportStylePresets: build.query({ query: () => ({ - url: buildStylePresetsUrl("/export"), - responseHandler: response => response.text() + url: buildStylePresetsUrl('/export'), + responseHandler: (response) => response.text(), }), providesTags: ['FetchOnReconnect', { type: 'StylePreset', id: LIST_TAG }], }),