Images.svelte 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
  1. <script lang="ts">
  2. import { toast } from 'svelte-sonner';
  3. import { createEventDispatcher, onMount, getContext } from 'svelte';
  4. import { config as backendConfig, user } from '$lib/stores';
  5. import { getBackendConfig } from '$lib/apis';
  6. import {
  7. getImageGenerationModels,
  8. getImageGenerationConfig,
  9. updateImageGenerationConfig,
  10. getConfig,
  11. updateConfig,
  12. verifyConfigUrl
  13. } from '$lib/apis/images';
  14. import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
  15. import Switch from '$lib/components/common/Switch.svelte';
  16. import Tooltip from '$lib/components/common/Tooltip.svelte';
  17. const dispatch = createEventDispatcher();
  18. const i18n = getContext('i18n');
  19. let loading = false;
  20. let config = null;
  21. let imageGenerationConfig = null;
  22. let models = null;
  23. let samplers = ["DPM++ 2M", "DPM++ SDE", "DPM++ 2M SDE", "DPM++ 2M SDE Heun", "DPM++ 2S a", "DPM++ 3M SDE", "Euler a", "Euler", "LMS", "Heun", "DPM2", "DPM2 a", "DPM fast", "DPM adaptive", "Restart", "DDIM", "DDIM CFG++", "PLMS", "UniPC"];
  24. let schedulers = ["Automatic", "Uniform", "Karras", "Exponential", "Polyexponential", "SGM Uniform", "KL Optimal", "Align Your Steps", "Simple", "Normal", "DDIM", "Beta"];
  25. let requiredWorkflowNodes = [
  26. {
  27. type: 'prompt',
  28. key: 'text',
  29. node_ids: ''
  30. },
  31. {
  32. type: 'model',
  33. key: 'ckpt_name',
  34. node_ids: ''
  35. },
  36. {
  37. type: 'width',
  38. key: 'width',
  39. node_ids: ''
  40. },
  41. {
  42. type: 'height',
  43. key: 'height',
  44. node_ids: ''
  45. },
  46. {
  47. type: 'steps',
  48. key: 'steps',
  49. node_ids: ''
  50. },
  51. {
  52. type: 'seed',
  53. key: 'seed',
  54. node_ids: ''
  55. }
  56. ];
  57. const getModels = async () => {
  58. models = await getImageGenerationModels(localStorage.token).catch((error) => {
  59. toast.error(error);
  60. return null;
  61. });
  62. };
  63. const updateConfigHandler = async () => {
  64. const res = await updateConfig(localStorage.token, config).catch((error) => {
  65. toast.error(error);
  66. return null;
  67. });
  68. if (res) {
  69. config = res;
  70. }
  71. if (config.enabled) {
  72. backendConfig.set(await getBackendConfig());
  73. getModels();
  74. }
  75. };
  76. const validateJSON = (json) => {
  77. try {
  78. const obj = JSON.parse(json);
  79. if (obj && typeof obj === 'object') {
  80. return true;
  81. }
  82. } catch (e) {}
  83. return false;
  84. };
  85. const saveHandler = async () => {
  86. loading = true;
  87. if (config?.comfyui?.COMFYUI_WORKFLOW) {
  88. if (!validateJSON(config.comfyui.COMFYUI_WORKFLOW)) {
  89. toast.error('Invalid JSON format for ComfyUI Workflow.');
  90. loading = false;
  91. return;
  92. }
  93. }
  94. if (config?.comfyui?.COMFYUI_WORKFLOW) {
  95. config.comfyui.COMFYUI_WORKFLOW_NODES = requiredWorkflowNodes.map((node) => {
  96. return {
  97. type: node.type,
  98. key: node.key,
  99. node_ids:
  100. node.node_ids.trim() === '' ? [] : node.node_ids.split(',').map((id) => id.trim())
  101. };
  102. });
  103. }
  104. await updateConfig(localStorage.token, config).catch((error) => {
  105. toast.error(error);
  106. loading = false;
  107. return null;
  108. });
  109. await updateImageGenerationConfig(localStorage.token, imageGenerationConfig).catch((error) => {
  110. toast.error(error);
  111. loading = false;
  112. return null;
  113. });
  114. getModels();
  115. dispatch('save');
  116. loading = false;
  117. };
  118. onMount(async () => {
  119. if ($user.role === 'admin') {
  120. const res = await getConfig(localStorage.token).catch((error) => {
  121. toast.error(error);
  122. return null;
  123. });
  124. if (res) {
  125. config = res;
  126. }
  127. if (config.enabled) {
  128. getModels();
  129. }
  130. if (config.comfyui.COMFYUI_WORKFLOW) {
  131. config.comfyui.COMFYUI_WORKFLOW = JSON.stringify(
  132. JSON.parse(config.comfyui.COMFYUI_WORKFLOW),
  133. null,
  134. 2
  135. );
  136. }
  137. requiredWorkflowNodes = requiredWorkflowNodes.map((node) => {
  138. const n = config.comfyui.COMFYUI_WORKFLOW_NODES.find((n) => n.type === node.type) ?? node;
  139. console.log(n);
  140. return {
  141. type: n.type,
  142. key: n.key,
  143. node_ids: typeof n.node_ids === 'string' ? n.node_ids : n.node_ids.join(',')
  144. };
  145. });
  146. const imageConfigRes = await getImageGenerationConfig(localStorage.token).catch((error) => {
  147. toast.error(error);
  148. return null;
  149. });
  150. if (imageConfigRes) {
  151. imageGenerationConfig = imageConfigRes;
  152. }
  153. }
  154. });
  155. </script>
  156. <form
  157. class="flex flex-col h-full justify-between space-y-3 text-sm"
  158. on:submit|preventDefault={async () => {
  159. saveHandler();
  160. }}
  161. >
  162. <div class=" space-y-3 overflow-y-scroll scrollbar-hidden pr-2">
  163. {#if config && imageGenerationConfig}
  164. <div>
  165. <div class=" mb-1 text-sm font-medium">{$i18n.t('Image Settings')}</div>
  166. <div>
  167. <div class=" py-0.5 flex w-full justify-between">
  168. <div class=" self-center text-xs font-medium">
  169. {$i18n.t('Image Generation (Experimental)')}
  170. </div>
  171. <div class="px-1">
  172. <Switch
  173. bind:state={config.enabled}
  174. on:change={(e) => {
  175. const enabled = e.detail;
  176. if (enabled) {
  177. if (
  178. config.engine === 'automatic1111' &&
  179. config.automatic1111.AUTOMATIC1111_BASE_URL === ''
  180. ) {
  181. toast.error($i18n.t('AUTOMATIC1111 Base URL is required.'));
  182. config.enabled = false;
  183. } else if (
  184. config.engine === 'comfyui' &&
  185. config.comfyui.COMFYUI_BASE_URL === ''
  186. ) {
  187. toast.error($i18n.t('ComfyUI Base URL is required.'));
  188. config.enabled = false;
  189. } else if (config.engine === 'openai' && config.openai.OPENAI_API_KEY === '') {
  190. toast.error($i18n.t('OpenAI API Key is required.'));
  191. config.enabled = false;
  192. }
  193. }
  194. updateConfigHandler();
  195. }}
  196. />
  197. </div>
  198. </div>
  199. </div>
  200. <div class=" py-0.5 flex w-full justify-between">
  201. <div class=" self-center text-xs font-medium">{$i18n.t('Image Generation Engine')}</div>
  202. <div class="flex items-center relative">
  203. <select
  204. class="w-fit pr-8 rounded px-2 p-1 text-xs bg-transparent outline-none text-right"
  205. bind:value={config.engine}
  206. placeholder={$i18n.t('Select Engine')}
  207. on:change={async () => {
  208. updateConfigHandler();
  209. }}
  210. >
  211. <option value="openai">{$i18n.t('Default (Open AI)')}</option>
  212. <option value="comfyui">{$i18n.t('ComfyUI')}</option>
  213. <option value="automatic1111">{$i18n.t('Automatic1111')}</option>
  214. </select>
  215. </div>
  216. </div>
  217. </div>
  218. <hr class=" dark:border-gray-850" />
  219. <div class="flex flex-col gap-2">
  220. {#if (config?.engine ?? 'automatic1111') === 'automatic1111'}
  221. <div>
  222. <div class=" mb-2 text-sm font-medium">{$i18n.t('AUTOMATIC1111 Base URL')}</div>
  223. <div class="flex w-full">
  224. <div class="flex-1 mr-2">
  225. <input
  226. class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
  227. placeholder={$i18n.t('Enter URL (e.g. http://127.0.0.1:7860/)')}
  228. bind:value={config.automatic1111.AUTOMATIC1111_BASE_URL}
  229. />
  230. </div>
  231. <button
  232. class="px-2.5 bg-gray-50 hover:bg-gray-100 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
  233. type="button"
  234. on:click={async () => {
  235. await updateConfigHandler();
  236. const res = await verifyConfigUrl(localStorage.token).catch((error) => {
  237. toast.error(error);
  238. return null;
  239. });
  240. if (res) {
  241. toast.success($i18n.t('Server connection verified'));
  242. }
  243. }}
  244. >
  245. <svg
  246. xmlns="http://www.w3.org/2000/svg"
  247. viewBox="0 0 20 20"
  248. fill="currentColor"
  249. class="w-4 h-4"
  250. >
  251. <path
  252. fill-rule="evenodd"
  253. d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
  254. clip-rule="evenodd"
  255. />
  256. </svg>
  257. </button>
  258. </div>
  259. <div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
  260. {$i18n.t('Include `--api` flag when running stable-diffusion-webui')}
  261. <a
  262. class=" text-gray-300 font-medium"
  263. href="https://github.com/AUTOMATIC1111/stable-diffusion-webui/discussions/3734"
  264. target="_blank"
  265. >
  266. {$i18n.t('(e.g. `sh webui.sh --api`)')}
  267. </a>
  268. </div>
  269. </div>
  270. <div>
  271. <div class=" mb-2 text-sm font-medium">
  272. {$i18n.t('AUTOMATIC1111 Api Auth String')}
  273. </div>
  274. <SensitiveInput
  275. placeholder={$i18n.t('Enter api auth string (e.g. username:password)')}
  276. bind:value={config.automatic1111.AUTOMATIC1111_API_AUTH}
  277. required={false}
  278. />
  279. <div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
  280. {$i18n.t('Include `--api-auth` flag when running stable-diffusion-webui')}
  281. <a
  282. class=" text-gray-300 font-medium"
  283. href="https://github.com/AUTOMATIC1111/stable-diffusion-webui/discussions/13993"
  284. target="_blank"
  285. >
  286. {$i18n
  287. .t('(e.g. `sh webui.sh --api --api-auth username_password`)')
  288. .replace('_', ':')}
  289. </a>
  290. </div>
  291. </div>
  292. <!---Sampler-->
  293. <div>
  294. <div class=" mb-2.5 text-sm font-medium">{$i18n.t('Set Sampler')}</div>
  295. <div class="flex w-full">
  296. <div class="flex-1 mr-2">
  297. <Tooltip content={$i18n.t('Enter Sampler (e.g. Euler a)')} placement="top-start">
  298. <input
  299. list="sampler-list"
  300. class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
  301. placeholder={$i18n.t('Enter Sampler (e.g. Euler a)')}
  302. bind:value={config.automatic1111.AUTOMATIC1111_SAMPLER}
  303. />
  304. <datalist id="sampler-list">
  305. {#each samplers ?? [] as sampler}
  306. <option value={sampler}>{sampler}</option>
  307. {/each}
  308. </datalist>
  309. </Tooltip>
  310. </div>
  311. </div>
  312. </div>
  313. <!---Scheduler-->
  314. <div>
  315. <div class=" mb-2.5 text-sm font-medium">{$i18n.t('Set Scheduler')}</div>
  316. <div class="flex w-full">
  317. <div class="flex-1 mr-2">
  318. <Tooltip content={$i18n.t('Enter Scheduler (e.g. Karras)')} placement="top-start">
  319. <input
  320. list="scheduler-list"
  321. class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
  322. placeholder={$i18n.t('Enter Scheduler (e.g. Karras)')}
  323. bind:value={config.automatic1111.AUTOMATIC1111_SCHEDULER}
  324. />
  325. <datalist id="scheduler-list">
  326. {#each schedulers ?? [] as scheduler}
  327. <option value={scheduler}>{scheduler}</option>
  328. {/each}
  329. </datalist>
  330. </Tooltip>
  331. </div>
  332. </div>
  333. </div>
  334. <!---CFG scale-->
  335. <div>
  336. <div class=" mb-2.5 text-sm font-medium">{$i18n.t('Set CFG Scale')}</div>
  337. <div class="flex w-full">
  338. <div class="flex-1 mr-2">
  339. <Tooltip content={$i18n.t('Enter CFG Scale (e.g. 7.0)')} placement="top-start">
  340. <input
  341. class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
  342. placeholder={$i18n.t('Enter CFG Scale (e.g. 7.0)')}
  343. bind:value={config.automatic1111.AUTOMATIC1111_CFG_SCALE}
  344. />
  345. </Tooltip>
  346. </div>
  347. </div>
  348. </div>
  349. {:else if config?.engine === 'comfyui'}
  350. <div class="">
  351. <div class=" mb-2 text-sm font-medium">{$i18n.t('ComfyUI Base URL')}</div>
  352. <div class="flex w-full">
  353. <div class="flex-1 mr-2">
  354. <input
  355. class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
  356. placeholder={$i18n.t('Enter URL (e.g. http://127.0.0.1:7860/)')}
  357. bind:value={config.comfyui.COMFYUI_BASE_URL}
  358. />
  359. </div>
  360. <button
  361. class="px-2.5 bg-gray-50 hover:bg-gray-100 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
  362. type="button"
  363. on:click={async () => {
  364. await updateConfigHandler();
  365. const res = await verifyConfigUrl(localStorage.token).catch((error) => {
  366. toast.error(error);
  367. return null;
  368. });
  369. if (res) {
  370. toast.success($i18n.t('Server connection verified'));
  371. }
  372. }}
  373. >
  374. <svg
  375. xmlns="http://www.w3.org/2000/svg"
  376. viewBox="0 0 20 20"
  377. fill="currentColor"
  378. class="w-4 h-4"
  379. >
  380. <path
  381. fill-rule="evenodd"
  382. d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
  383. clip-rule="evenodd"
  384. />
  385. </svg>
  386. </button>
  387. </div>
  388. </div>
  389. <div class="">
  390. <div class=" mb-2 text-sm font-medium">{$i18n.t('ComfyUI Workflow')}</div>
  391. {#if config.comfyui.COMFYUI_WORKFLOW}
  392. <textarea
  393. class="w-full rounded-lg mb-1 py-2 px-4 text-xs bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none disabled:text-gray-600 resize-none"
  394. rows="10"
  395. bind:value={config.comfyui.COMFYUI_WORKFLOW}
  396. required
  397. />
  398. {/if}
  399. <div class="flex w-full">
  400. <div class="flex-1">
  401. <input
  402. id="upload-comfyui-workflow-input"
  403. hidden
  404. type="file"
  405. accept=".json"
  406. on:change={(e) => {
  407. const file = e.target.files[0];
  408. const reader = new FileReader();
  409. reader.onload = (e) => {
  410. config.comfyui.COMFYUI_WORKFLOW = e.target.result;
  411. e.target.value = null;
  412. };
  413. reader.readAsText(file);
  414. }}
  415. />
  416. <button
  417. class="w-full text-sm font-medium py-2 bg-transparent hover:bg-gray-100 border border-dashed dark:border-gray-800 dark:hover:bg-gray-850 text-center rounded-xl"
  418. type="button"
  419. on:click={() => {
  420. document.getElementById('upload-comfyui-workflow-input')?.click();
  421. }}
  422. >
  423. {$i18n.t('Click here to upload a workflow.json file.')}
  424. </button>
  425. </div>
  426. </div>
  427. <div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
  428. {$i18n.t('Make sure to export a workflow.json file as API format from ComfyUI.')}
  429. </div>
  430. </div>
  431. {#if config.comfyui.COMFYUI_WORKFLOW}
  432. <div class="">
  433. <div class=" mb-2 text-sm font-medium">{$i18n.t('ComfyUI Workflow Nodes')}</div>
  434. <div class="text-xs flex flex-col gap-1.5">
  435. {#each requiredWorkflowNodes as node}
  436. <div class="flex w-full items-center border dark:border-gray-850 rounded-lg">
  437. <div class="flex-shrink-0">
  438. <div
  439. class=" capitalize line-clamp-1 font-medium px-3 py-1 w-20 text-center rounded-l-lg bg-green-500/10 text-green-700 dark:text-green-200"
  440. >
  441. {node.type}{node.type === 'prompt' ? '*' : ''}
  442. </div>
  443. </div>
  444. <div class="">
  445. <Tooltip content="Input Key (e.g. text, unet_name, steps)">
  446. <input
  447. class="py-1 px-3 w-24 text-xs text-center bg-transparent outline-none border-r dark:border-gray-850"
  448. placeholder="Key"
  449. bind:value={node.key}
  450. required
  451. />
  452. </Tooltip>
  453. </div>
  454. <div class="w-full">
  455. <Tooltip
  456. content="Comma separated Node Ids (e.g. 1 or 1,2)"
  457. placement="top-start"
  458. >
  459. <input
  460. class="w-full py-1 px-4 rounded-r-lg text-xs bg-transparent outline-none"
  461. placeholder="Node Ids"
  462. bind:value={node.node_ids}
  463. />
  464. </Tooltip>
  465. </div>
  466. </div>
  467. {/each}
  468. </div>
  469. <div class="mt-2 text-xs text-right text-gray-400 dark:text-gray-500">
  470. {$i18n.t('*Prompt node ID(s) are required for image generation')}
  471. </div>
  472. </div>
  473. {/if}
  474. {:else if config?.engine === 'openai'}
  475. <div>
  476. <div class=" mb-1.5 text-sm font-medium">{$i18n.t('OpenAI API Config')}</div>
  477. <div class="flex gap-2 mb-1">
  478. <input
  479. class="flex-1 w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
  480. placeholder={$i18n.t('API Base URL')}
  481. bind:value={config.openai.OPENAI_API_BASE_URL}
  482. required
  483. />
  484. <SensitiveInput
  485. placeholder={$i18n.t('API Key')}
  486. bind:value={config.openai.OPENAI_API_KEY}
  487. />
  488. </div>
  489. </div>
  490. {/if}
  491. </div>
  492. {#if config?.enabled}
  493. <hr class=" dark:border-gray-850" />
  494. <div>
  495. <div class=" mb-2.5 text-sm font-medium">{$i18n.t('Set Default Model')}</div>
  496. <div class="flex w-full">
  497. <div class="flex-1 mr-2">
  498. <div class="flex w-full">
  499. <div class="flex-1">
  500. <Tooltip content={$i18n.t('Enter Model ID')} placement="top-start">
  501. <input
  502. list="model-list"
  503. class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
  504. bind:value={imageGenerationConfig.MODEL}
  505. placeholder="Select a model"
  506. required
  507. />
  508. <datalist id="model-list">
  509. {#each models ?? [] as model}
  510. <option value={model.id}>{model.name}</option>
  511. {/each}
  512. </datalist>
  513. </Tooltip>
  514. </div>
  515. </div>
  516. </div>
  517. </div>
  518. </div>
  519. <div>
  520. <div class=" mb-2.5 text-sm font-medium">{$i18n.t('Set Image Size')}</div>
  521. <div class="flex w-full">
  522. <div class="flex-1 mr-2">
  523. <Tooltip content={$i18n.t('Enter Image Size (e.g. 512x512)')} placement="top-start">
  524. <input
  525. class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
  526. placeholder={$i18n.t('Enter Image Size (e.g. 512x512)')}
  527. bind:value={imageGenerationConfig.IMAGE_SIZE}
  528. required
  529. />
  530. </Tooltip>
  531. </div>
  532. </div>
  533. </div>
  534. <div>
  535. <div class=" mb-2.5 text-sm font-medium">{$i18n.t('Set Steps')}</div>
  536. <div class="flex w-full">
  537. <div class="flex-1 mr-2">
  538. <Tooltip content={$i18n.t('Enter Number of Steps (e.g. 50)')} placement="top-start">
  539. <input
  540. class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
  541. placeholder={$i18n.t('Enter Number of Steps (e.g. 50)')}
  542. bind:value={imageGenerationConfig.IMAGE_STEPS}
  543. required
  544. />
  545. </Tooltip>
  546. </div>
  547. </div>
  548. </div>
  549. {/if}
  550. {/if}
  551. </div>
  552. <div class="flex justify-end pt-3 text-sm font-medium">
  553. <button
  554. class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg flex flex-row space-x-1 items-center {loading
  555. ? ' cursor-not-allowed'
  556. : ''}"
  557. type="submit"
  558. disabled={loading}
  559. >
  560. {$i18n.t('Save')}
  561. {#if loading}
  562. <div class="ml-2 self-center">
  563. <svg
  564. class=" w-4 h-4"
  565. viewBox="0 0 24 24"
  566. fill="currentColor"
  567. xmlns="http://www.w3.org/2000/svg"
  568. ><style>
  569. .spinner_ajPY {
  570. transform-origin: center;
  571. animation: spinner_AtaB 0.75s infinite linear;
  572. }
  573. @keyframes spinner_AtaB {
  574. 100% {
  575. transform: rotate(360deg);
  576. }
  577. }
  578. </style><path
  579. d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
  580. opacity=".25"
  581. /><path
  582. d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
  583. class="spinner_ajPY"
  584. /></svg
  585. >
  586. </div>
  587. {/if}
  588. </button>
  589. </div>
  590. </form>