This commit is contained in:
Timothy Jaeryang Baek
2026-07-17 00:54:13 -04:00
parent 27d7eafcd9
commit ce44b90eae
7 changed files with 686 additions and 624 deletions
+131
View File
@@ -0,0 +1,131 @@
<script lang="ts">
import { getContext, onMount, setContext } from 'svelte';
import { writable } from 'svelte/store';
import { goto } from '$app/navigation';
import { config, mobile, showSidebar, user } from '$lib/stores';
import { getAutomationItems } from '$lib/apis/automations';
import type i18nType from '$lib/i18n';
import { formatNumber } from '$lib/utils';
import SidebarIcon from '$lib/components/icons/Sidebar.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n: typeof i18nType = getContext('i18n');
const total = writable<number | null>(null);
let itemName: string | null = null;
let HeaderActions: any = null;
let headerActionProps: Record<string, any> = {};
const setHeader = ({
itemName: nextItemName = null,
actions = null,
actionProps = {}
}: {
itemName?: string | null;
actions?: any;
actionProps?: Record<string, any>;
}) => {
itemName = nextItemName;
HeaderActions = actions;
headerActionProps = actionProps;
};
const clearHeader = () => {
itemName = null;
HeaderActions = null;
headerActionProps = {};
};
const refreshCount = async () => {
const res = await getAutomationItems(localStorage.token, null, 'all', 1).catch(() => null);
if (res) {
total.set(res.total);
}
};
setContext('automationsLayout', {
total,
setTotal: (value: number | null) => total.set(value),
refreshCount,
setHeader,
clearHeader
});
onMount(async () => {
if (
!($config?.features as any)?.enable_automations ||
($user?.role !== 'admin' && !($user?.permissions?.features?.automations ?? false))
) {
goto('/');
return;
}
await refreshCount();
});
</script>
<div
class="flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
? 'md:max-w-[calc(100%-var(--sidebar-width))]'
: ''} max-w-full"
>
<div class="flex h-full min-h-0 flex-col">
<div class="shrink-0 px-2.5 pt-2 pb-1">
<div class="flex items-center gap-0.5 md:gap-1">
{#if $mobile}
<div class="{$showSidebar ? 'md:hidden' : ''} flex flex-none items-center">
<Tooltip content={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}>
<button
id="sidebar-toggle-button"
class="flex size-7 items-center justify-center text-gray-400 transition"
aria-label={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}
on:click={() => {
showSidebar.set(!$showSidebar);
}}
type="button"
>
<SidebarIcon className="size-4" />
</button>
</Tooltip>
</div>
{/if}
<div class="flex w-full min-w-0 items-center">
<div class="flex min-w-0 flex-1 items-center gap-1 py-1">
{#if itemName}
<button
class="min-w-fit px-1 text-sm select-none transition"
aria-label={$i18n.t('Back')}
on:click={() => goto('/automations')}
type="button"
>
{$i18n.t('Automations')}
</button>
{:else}
<span class="min-w-fit px-1 text-sm select-none">{$i18n.t('Automations')}</span>
{/if}
<span class="text-sm text-gray-500 dark:text-gray-500">
{$total === null ? '' : formatNumber($total)}
</span>
{#if itemName}
<span class="text-sm text-gray-300 dark:text-gray-800 px-2">/</span>
<span class="min-w-0 flex-1 truncate text-sm text-gray-900 dark:text-white">
{itemName}
</span>
{/if}
</div>
{#if HeaderActions}
<svelte:component this={HeaderActions} {...headerActionProps} />
{/if}
</div>
</div>
</div>
<div class="min-h-0 flex-1 overflow-hidden">
<slot />
</div>
</div>
</div>
+247 -286
View File
@@ -3,7 +3,7 @@
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { WEBUI_NAME, mobile, showSidebar, user, config } from '$lib/stores';
import { WEBUI_NAME, user, config } from '$lib/stores';
import {
createAutomation,
@@ -14,17 +14,18 @@
type AutomationForm,
type AutomationResponse
} from '$lib/apis/automations';
import type i18nType from '$lib/i18n';
// @ts-ignore file-saver ships without local typings in this repo.
import fileSaver from 'file-saver';
import AutomationModal from '$lib/components/AutomationModal.svelte';
import AutomationListHeaderActions from '$lib/components/automations/AutomationListHeaderActions.svelte';
import AutomationMenu from '$lib/components/automations/AutomationMenu.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Pagination from '$lib/components/common/Pagination.svelte';
import SplitCreateButton from '$lib/components/common/SplitCreateButton.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import SidebarIcon from '$lib/components/icons/Sidebar.svelte';
import Search from '$lib/components/icons/Search.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
@@ -35,11 +36,11 @@
import Check from '$lib/components/icons/Check.svelte';
import CheckCircle from '$lib/components/icons/CheckCircle.svelte';
import Minus from '$lib/components/icons/Minus.svelte';
import { formatNumber } from '$lib/utils';
const { saveAs } = fileSaver;
const i18n = getContext('i18n');
const i18n: typeof i18nType = getContext('i18n');
const automationsLayout: any = getContext('automationsLayout');
let loaded = false;
let automations: AutomationResponse[] | null = null;
@@ -61,6 +62,35 @@
let importFiles: FileList | null = null;
let automationsImportInputElement: HTMLInputElement;
const syncHeader = () => {
automationsLayout?.setHeader({
itemName: null,
actions: AutomationListHeaderActions,
actionProps: {
actions: [
{
id: 'automations-new',
label: $i18n.t('Create'),
onClick: () => {
cloneFrom = null;
showCreateModal = true;
}
},
{
id: 'automations-import',
label: $i18n.t('Import JSON'),
onClick: () => automationsImportInputElement?.click()
},
{
id: 'automations-export',
label: $i18n.t('Export JSON'),
onClick: exportAutomations
}
]
}
});
};
const handleSearchInput = () => {
if (!loaded) return;
@@ -99,6 +129,7 @@
if (res) {
automations = res.items;
total = res.total;
automationsLayout?.setTotal(total);
}
} catch (err) {
console.error(err);
@@ -275,9 +306,9 @@
return 'th';
};
onMount(async () => {
onMount(() => {
if (
!$config?.features?.enable_automations ||
!($config?.features as any)?.enable_automations ||
($user?.role !== 'admin' && !($user?.permissions?.features?.automations ?? false))
) {
goto('/');
@@ -285,6 +316,7 @@
}
loaded = true;
syncHeader();
return () => {
clearTimeout(searchDebounceTimer);
@@ -338,313 +370,242 @@
bind:show={showCreateModal}
automation={null}
{cloneFrom}
on:save={(e) => {
getAutomationList();
on:save={async (e) => {
await getAutomationList();
if (e.detail?.id) {
goto(`/automations/${e.detail.id}`);
}
}}
/>
<div
class="flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
? 'md:max-w-[calc(100%-var(--sidebar-width))]'
: ''} max-w-full"
>
<div class="flex-1 max-h-full overflow-y-auto">
{#if loaded}
<div class="pb-1 px-2.5 pt-2">
<div class="flex items-center gap-0.5 md:gap-1 mb-1">
{#if $mobile}
<div class="{$showSidebar ? 'md:hidden' : ''} flex flex-none items-center">
<Tooltip
content={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}
interactive={true}
<div class="h-full overflow-y-auto px-2.5 pb-1">
{#if loaded}
<div class="space-y-1">
<div class="flex h-8 flex-1 items-center w-full gap-2">
<div class="flex min-w-0 flex-1 items-center">
<div class="self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class="w-full text-sm py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
on:input={handleSearchInput}
aria-label={$i18n.t('Search Automations')}
placeholder={$i18n.t('Search Automations')}
maxlength="500"
/>
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full transition"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';
handleSearchInput();
}}
>
<button
id="sidebar-toggle-button"
class="cursor-pointer flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 transition"
on:click={() => {
showSidebar.set(!$showSidebar);
}}
>
<div class="self-center p-1.5">
<SidebarIcon className="size-4" />
</div>
</button>
</Tooltip>
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div>
<div class="flex w-full items-center">
<div class="flex items-center gap-1 py-1 min-w-0">
<span class="min-w-fit px-1 text-sm select-none">{$i18n.t('Automations')}</span>
<span class="text-sm text-gray-500 dark:text-gray-500">
{total === null ? '' : formatNumber(total)}
</span>
<div
class="flex max-w-[55%] shrink-0 overflow-x-auto scrollbar-none"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div
class="flex w-fit gap-0.5 text-center text-sm rounded-full bg-transparent whitespace-nowrap"
>
<Select
bind:value={statusFilter}
align="end"
items={[
{ value: 'all', label: $i18n.t('All') },
{ value: 'active', label: $i18n.t('Active') },
{ value: 'paused', label: $i18n.t('Paused') }
]}
onChange={() => {
page = 1;
}}
triggerClass="relative h-8 w-full flex items-center gap-0.5 px-1.5 py-1.5 bg-transparent rounded-xl text-[13px] font-normal text-gray-700 transition dark:text-gray-200"
>
<svelte:fragment slot="trigger" let:selectedLabel>
<span
class="inline-flex h-input w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
>
{selectedLabel}
</span>
<ChevronDown className="size-3.5" strokeWidth="2.5" />
</svelte:fragment>
<svelte:fragment slot="item" let:item let:selected>
{item.label}
<div class="ml-auto {selected ? '' : 'invisible'}">
<Check />
</div>
</svelte:fragment>
</Select>
<Dropdown align="end">
<Tooltip content={$i18n.t('Actions')}>
<button
class="flex h-8 items-center gap-1.5 rounded-xl bg-transparent px-1.5 text-[13px] font-normal text-gray-700 transition dark:text-gray-200"
type="button"
>
<span>{$i18n.t('Actions')}</span>
<ChevronDown className="size-3" strokeWidth="2.5" />
</button>
</Tooltip>
<div slot="content">
<DropdownMenu className="w-[170px] shadow-sm">
<button
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[13px]"
type="button"
on:click={() => bulkToggleHandler(true)}
>
<CheckCircle className="size-3.5" />
{$i18n.t('Enable All')}
</button>
<button
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[13px]"
type="button"
on:click={() => bulkToggleHandler(false)}
>
<Minus className="size-3.5" />
{$i18n.t('Disable All')}
</button>
</DropdownMenu>
</div>
</Dropdown>
</div>
</div>
</div>
{#if automations === null || loading}
<div class="flex min-h-[calc(100dvh-13rem)] w-full items-center justify-center">
<Spinner className="size-5" />
</div>
{:else if (automations ?? []).length === 0}
<div class="flex min-h-[calc(100dvh-13rem)] w-full flex-col items-center justify-center">
<div class="max-w-sm text-center text-gray-900 dark:text-gray-100">
<div class="mb-1.5 text-sm">
{query ? $i18n.t('No results found') : $i18n.t('No automations found')}
</div>
<div class="ml-auto flex items-center gap-1">
<SplitCreateButton
actions={[
{
id: 'automations-new',
label: $i18n.t('Create'),
onClick: () => {
cloneFrom = null;
showCreateModal = true;
}
},
{
id: 'automations-import',
label: $i18n.t('Import JSON'),
onClick: () => automationsImportInputElement?.click()
},
{
id: 'automations-export',
label: $i18n.t('Export JSON'),
onClick: exportAutomations
}
]}
/>
<div class="text-center text-xs leading-5 text-gray-500">
{query
? $i18n.t('Try adjusting your search or filter to find what you are looking for.')
: $i18n.t('Create scheduled prompts that run automatically on a recurring basis.')}
</div>
</div>
</div>
<div class="space-y-1">
<div class="flex h-8 flex-1 items-center w-full gap-2">
<div class="flex min-w-0 flex-1 items-center">
<div class="self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class="w-full text-sm py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
on:input={handleSearchInput}
aria-label={$i18n.t('Search Automations')}
placeholder={$i18n.t('Search Automations')}
maxlength="500"
/>
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';
handleSearchInput();
}}
>
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div>
{:else}
<div class="gap-y-0.5 grid my-1">
{#each automations as automation (automation.id)}
<div
class="flex max-w-[55%] shrink-0 overflow-x-auto scrollbar-none"
on:wheel={(e) => {
if (e.deltaY !== 0) {
role="button"
tabindex="0"
aria-label={$i18n.t('Open automation')}
class="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1 text-left transition"
on:click={() => {
goto(`/automations/${automation.id}`);
}}
on:keydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
goto(`/automations/${automation.id}`);
}
}}
>
<div
class="flex w-fit gap-0.5 text-center text-sm rounded-full bg-transparent whitespace-nowrap"
>
<Select
bind:value={statusFilter}
align="end"
items={[
{ value: 'all', label: $i18n.t('All') },
{ value: 'active', label: $i18n.t('Active') },
{ value: 'paused', label: $i18n.t('Paused') }
]}
onChange={() => {
page = 1;
}}
triggerClass="relative h-8 w-full flex items-center gap-0.5 px-1.5 py-1.5 bg-transparent rounded-xl text-[13px] font-normal text-gray-700 transition hover:text-gray-900 dark:text-gray-200 dark:hover:text-gray-100"
>
<svelte:fragment slot="trigger" let:selectedLabel>
<span
class="inline-flex h-input w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
>
{selectedLabel}
</span>
<ChevronDown className="size-3.5" strokeWidth="2.5" />
</svelte:fragment>
<svelte:fragment slot="item" let:item let:selected>
{item.label}
<div class="ml-auto {selected ? '' : 'invisible'}">
<Check />
</div>
</svelte:fragment>
</Select>
<Dropdown align="end">
<Tooltip content={$i18n.t('Actions')}>
<button
class="flex h-8 items-center gap-1.5 rounded-xl bg-transparent px-1.5 text-[13px] font-normal text-gray-700 transition hover:text-gray-900 dark:text-gray-200 dark:hover:text-gray-100"
type="button"
>
<span>{$i18n.t('Actions')}</span>
<ChevronDown className="size-3" strokeWidth="2.5" />
</button>
</Tooltip>
<div slot="content">
<DropdownMenu className="w-[170px] shadow-sm">
<button
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[13px] hover:text-gray-900 dark:hover:text-gray-100"
type="button"
on:click={() => bulkToggleHandler(true)}
>
<CheckCircle className="size-3.5" />
{$i18n.t('Enable All')}
</button>
<button
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[13px] hover:text-gray-900 dark:hover:text-gray-100"
type="button"
on:click={() => bulkToggleHandler(false)}
>
<Minus className="size-3.5" />
{$i18n.t('Disable All')}
</button>
</DropdownMenu>
<div class="min-w-0 flex-1">
<Tooltip content={automation.name} placement="top-start">
<div
class="truncate text-[13px] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
>
{automation.name}
</div>
</Dropdown>
</Tooltip>
<div class="truncate text-[11px] leading-4 text-gray-500 dark:text-gray-500">
{formatRRule(automation.data.rrule)}
</div>
</div>
</div>
</div>
{#if automations === null || loading}
<div class="flex min-h-[calc(100dvh-13rem)] w-full items-center justify-center">
<Spinner className="size-5" />
</div>
{:else if (automations ?? []).length === 0}
<div
class="flex min-h-[calc(100dvh-13rem)] w-full flex-col items-center justify-center"
>
<div class="max-w-sm text-center text-gray-900 dark:text-gray-100">
<div class="mb-1.5 text-sm">
{query ? $i18n.t('No results found') : $i18n.t('No automations found')}
</div>
<div class="text-center text-xs leading-5 text-gray-500">
{query
? $i18n.t(
'Try adjusting your search or filter to find what you are looking for.'
)
: $i18n.t(
'Create scheduled prompts that run automatically on a recurring basis.'
)}
</div>
</div>
</div>
{:else}
<div class="gap-y-0.5 grid my-1">
{#each automations as automation (automation.id)}
<div
role="button"
tabindex="0"
aria-label={$i18n.t('Open automation')}
class="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1 text-left transition hover:bg-gray-50 focus:bg-gray-50 dark:hover:bg-gray-900 dark:focus:bg-gray-900"
on:click={() => {
<div class="flex shrink-0 flex-row items-center gap-1.5 self-center">
<AutomationMenu
show={openAutomationMenuId === automation.id}
editHandler={() => {
goto(`/automations/${automation.id}`);
}}
on:keydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goto(`/automations/${automation.id}`);
}
cloneHandler={() => {
cloneHandler(automation);
}}
runHandler={() => {
runNowHandler(automation);
}}
deleteHandler={() => {
deleteTarget = automation;
showDeleteConfirm = true;
}}
onClose={() => {
openAutomationMenuId = null;
}}
>
<div class="min-w-0 flex-1">
<Tooltip content={automation.name} placement="top-start">
<div
class="truncate text-[13px] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
>
{automation.name}
</div>
</Tooltip>
<div class="truncate text-[11px] leading-4 text-gray-500 dark:text-gray-500">
{formatRRule(automation.data.rrule)}
</div>
</div>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Automation Menu')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
openAutomationMenuId =
openAutomationMenuId === automation.id ? null : automation.id;
}}
>
<EllipsisHorizontal className="size-4" />
</button>
</AutomationMenu>
<div class="flex shrink-0 flex-row items-center gap-1.5 self-center">
<AutomationMenu
show={openAutomationMenuId === automation.id}
editHandler={() => {
goto(`/automations/${automation.id}`);
<button
class="flex h-6 items-center"
type="button"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Tooltip
content={automation.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}
>
<Switch
bind:state={automation.is_active}
on:change={() => {
toggleHandler(automation);
}}
cloneHandler={() => {
cloneHandler(automation);
}}
runHandler={() => {
runNowHandler(automation);
}}
deleteHandler={() => {
deleteTarget = automation;
showDeleteConfirm = true;
}}
onClose={() => {
openAutomationMenuId = null;
}}
>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition hover:bg-gray-100 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
type="button"
aria-label={$i18n.t('Automation Menu')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
openAutomationMenuId =
openAutomationMenuId === automation.id ? null : automation.id;
}}
>
<EllipsisHorizontal className="size-4" />
</button>
</AutomationMenu>
<button
class="flex h-6 items-center"
type="button"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Tooltip
content={automation.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}
>
<Switch
bind:state={automation.is_active}
on:change={() => {
toggleHandler(automation);
}}
/>
</Tooltip>
</button>
</div>
</div>
{/each}
</div>
{#if total > 30}
<div class="flex justify-center mt-4 mb-2">
<Pagination bind:page count={total} perPage={30} />
/>
</Tooltip>
</button>
</div>
{/if}
{/if}
</div>
{/each}
</div>
</div>
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner className="size-5" />
</div>
{/if}
</div>
{#if (total ?? 0) > 30}
<div class="flex justify-center mt-4 mb-2">
<Pagination bind:page count={total ?? 0} perPage={30} />
</div>
{/if}
{/if}
</div>
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner className="size-5" />
</div>
{/if}
</div>
+6 -12
View File
@@ -1,25 +1,23 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { onMount, getContext } from 'svelte';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { user, showSidebar, config } from '$lib/stores';
import { getAutomationById } from '$lib/apis/automations';
import { user, config } from '$lib/stores';
import { getAutomationById, type AutomationResponse } from '$lib/apis/automations';
import AutomationEditor from '$lib/components/automations/AutomationEditor.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
const i18n = getContext('i18n');
let automation = null;
let automation: AutomationResponse | null = null;
let loaded = false;
$: automationId = $page.params.id;
onMount(async () => {
if (
!$config?.features?.enable_automations ||
!($config?.features as any)?.enable_automations ||
($user?.role !== 'admin' && !($user?.permissions?.features?.automations ?? false))
) {
goto('/');
@@ -47,11 +45,7 @@
{#if loaded && automation}
<AutomationEditor {automation} />
{:else}
<div
class="w-full h-screen max-h-[100dvh] flex justify-center items-center transition-width duration-200 ease-in-out {$showSidebar
? 'md:max-w-[calc(100%-var(--sidebar-width))]'
: ''}"
>
<div class="flex h-full w-full items-center justify-center">
<Spinner className="size-5" />
</div>
{/if}