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}
+ />
+
+
+ );
+};
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 }],
}),