feat: automation
This commit is contained in:
@@ -0,0 +1,518 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, getContext } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import { models } from '$lib/stores';
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import Search from '$lib/components/icons/Search.svelte';
|
||||
import Check from '$lib/components/icons/Check.svelte';
|
||||
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
import {
|
||||
createAutomation,
|
||||
updateAutomationById,
|
||||
type AutomationForm,
|
||||
type AutomationResponse
|
||||
} from '$lib/apis/automations';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let show = false;
|
||||
export let automation: AutomationResponse | null = null;
|
||||
|
||||
let name = '';
|
||||
let prompt = '';
|
||||
let model_id = '';
|
||||
let is_active = true;
|
||||
|
||||
let frequency = 'DAILY';
|
||||
let interval = 1;
|
||||
let hour = 9;
|
||||
let minute = 0;
|
||||
let selectedDays: string[] = [];
|
||||
let monthDay = 1;
|
||||
|
||||
let loading = false;
|
||||
let showScheduleDropdown = false;
|
||||
let showModelDropdown = false;
|
||||
let modelSearch = '';
|
||||
let customRrule = '';
|
||||
|
||||
$: modelLabel = model_id
|
||||
? $models.find((m) => m.id === model_id)?.name || model_id
|
||||
: $i18n.t('Select model');
|
||||
|
||||
$: filteredModels = modelSearch
|
||||
? $models.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(modelSearch.toLowerCase()) ||
|
||||
m.id.toLowerCase().includes(modelSearch.toLowerCase())
|
||||
)
|
||||
: $models;
|
||||
|
||||
const FREQUENCIES = [
|
||||
{ key: 'HOURLY', label: 'Hourly' },
|
||||
{ key: 'DAILY', label: 'Daily' },
|
||||
{ key: 'WEEKLY', label: 'Weekly' },
|
||||
{ key: 'MONTHLY', label: 'Monthly' },
|
||||
{ key: 'CUSTOM', label: 'Custom' }
|
||||
];
|
||||
|
||||
const DAYS = [
|
||||
{ key: 'MO', label: 'Mo' },
|
||||
{ key: 'TU', label: 'Tu' },
|
||||
{ key: 'WE', label: 'We' },
|
||||
{ key: 'TH', label: 'Th' },
|
||||
{ key: 'FR', label: 'Fr' },
|
||||
{ key: 'SA', label: 'Sa' },
|
||||
{ key: 'SU', label: 'Su' }
|
||||
];
|
||||
|
||||
const buildVisualRrule = (): string => {
|
||||
let parts = [`FREQ=${lastVisualFrequency}`];
|
||||
if (interval > 1) parts.push(`INTERVAL=${interval}`);
|
||||
if (lastVisualFrequency === 'WEEKLY' && selectedDays.length) {
|
||||
parts.push(`BYDAY=${selectedDays.join(',')}`);
|
||||
}
|
||||
if (lastVisualFrequency === 'MONTHLY') {
|
||||
parts.push(`BYMONTHDAY=${monthDay}`);
|
||||
}
|
||||
if (['DAILY', 'WEEKLY', 'MONTHLY'].includes(lastVisualFrequency)) {
|
||||
parts.push(`BYHOUR=${hour}`);
|
||||
}
|
||||
parts.push(`BYMINUTE=${minute}`);
|
||||
return `RRULE:${parts.join(';')}`;
|
||||
};
|
||||
|
||||
let lastVisualFrequency = 'DAILY';
|
||||
let prevFrequency = 'DAILY';
|
||||
|
||||
$: if (frequency !== 'CUSTOM') {
|
||||
lastVisualFrequency = frequency;
|
||||
}
|
||||
|
||||
$: {
|
||||
if (frequency === 'CUSTOM' && prevFrequency !== 'CUSTOM') {
|
||||
customRrule = buildVisualRrule();
|
||||
}
|
||||
prevFrequency = frequency;
|
||||
}
|
||||
|
||||
const buildRrule = (): string => {
|
||||
if (frequency === 'CUSTOM') return customRrule;
|
||||
let parts = [`FREQ=${frequency}`];
|
||||
if (interval > 1) parts.push(`INTERVAL=${interval}`);
|
||||
if (frequency === 'WEEKLY' && selectedDays.length) {
|
||||
parts.push(`BYDAY=${selectedDays.join(',')}`);
|
||||
}
|
||||
if (frequency === 'MONTHLY') {
|
||||
parts.push(`BYMONTHDAY=${monthDay}`);
|
||||
}
|
||||
if (['DAILY', 'WEEKLY', 'MONTHLY'].includes(frequency)) {
|
||||
parts.push(`BYHOUR=${hour}`);
|
||||
}
|
||||
parts.push(`BYMINUTE=${minute}`);
|
||||
return `RRULE:${parts.join(';')}`;
|
||||
};
|
||||
|
||||
const parseRrule = (s: string) => {
|
||||
const parts: Record<string, string> = {};
|
||||
s.replace('RRULE:', '')
|
||||
.split(';')
|
||||
.forEach((p) => {
|
||||
const [k, v] = p.split('=');
|
||||
if (k && v) parts[k] = v;
|
||||
});
|
||||
const freq = parts.FREQ || 'DAILY';
|
||||
if (!['HOURLY', 'DAILY', 'WEEKLY', 'MONTHLY'].includes(freq)) {
|
||||
frequency = 'CUSTOM';
|
||||
customRrule = s;
|
||||
return;
|
||||
}
|
||||
frequency = freq;
|
||||
interval = parseInt(parts.INTERVAL || '1');
|
||||
hour = parseInt(parts.BYHOUR || '9');
|
||||
minute = parseInt(parts.BYMINUTE || '0');
|
||||
selectedDays = parts.BYDAY ? parts.BYDAY.split(',') : [];
|
||||
monthDay = parseInt(parts.BYMONTHDAY || '1');
|
||||
};
|
||||
|
||||
const scheduleLabel = (freq, intv, h, min, days, mDay) => {
|
||||
const h12 = h > 12 ? h - 12 : h === 0 ? 12 : h;
|
||||
const ampm = h >= 12 ? 'PM' : 'AM';
|
||||
const m = String(min).padStart(2, '0');
|
||||
const time = `${h12}:${m} ${ampm}`;
|
||||
|
||||
if (freq === 'HOURLY') return 'Hourly';
|
||||
if (freq === 'DAILY') return 'Daily';
|
||||
if (freq === 'WEEKLY') return 'Weekly';
|
||||
if (freq === 'MONTHLY') return 'Monthly';
|
||||
if (freq === 'CUSTOM') return 'Custom';
|
||||
return 'Schedule';
|
||||
};
|
||||
|
||||
const submitHandler = async () => {
|
||||
if (!name.trim() || !prompt.trim() || !model_id.trim()) {
|
||||
toast.error($i18n.t('Name, prompt, and model are required'));
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const form: AutomationForm = {
|
||||
name: name.trim(),
|
||||
data: {
|
||||
prompt: prompt.trim(),
|
||||
model_id: model_id.trim(),
|
||||
rrule: buildRrule()
|
||||
},
|
||||
is_active
|
||||
};
|
||||
|
||||
if (automation) {
|
||||
await updateAutomationById(localStorage.token, automation.id, form);
|
||||
toast.success($i18n.t('Automation updated'));
|
||||
} else {
|
||||
await createAutomation(localStorage.token, form);
|
||||
toast.success($i18n.t('Automation created'));
|
||||
}
|
||||
show = false;
|
||||
dispatch('save');
|
||||
} catch (e: any) {
|
||||
toast.error(e?.detail ?? `${e}` ?? 'Failed to save');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const init = () => {
|
||||
if (automation) {
|
||||
name = automation.name;
|
||||
prompt = automation.data.prompt;
|
||||
model_id = automation.data.model_id;
|
||||
is_active = automation.is_active;
|
||||
parseRrule(automation.data.rrule);
|
||||
} else {
|
||||
name = '';
|
||||
prompt = '';
|
||||
model_id = '';
|
||||
is_active = true;
|
||||
frequency = 'DAILY';
|
||||
interval = 1;
|
||||
hour = 9;
|
||||
minute = 0;
|
||||
selectedDays = [];
|
||||
monthDay = 1;
|
||||
}
|
||||
showScheduleDropdown = false;
|
||||
};
|
||||
|
||||
$: if (show) {
|
||||
init();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal size="md" bind:show>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
|
||||
<input
|
||||
class="w-full text-lg font-medium bg-transparent outline-hidden font-primary placeholder:text-gray-300 dark:placeholder:text-gray-700"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder={$i18n.t('Automation title')}
|
||||
/>
|
||||
<button
|
||||
class="self-center shrink-0 ml-2"
|
||||
aria-label={$i18n.t('Close')}
|
||||
on:click={() => (show = false)}
|
||||
>
|
||||
<XMark className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Prompt -->
|
||||
<div class="px-5 pb-2">
|
||||
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Instructions')}</div>
|
||||
<textarea
|
||||
class="w-full text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 resize-none min-h-[12rem]"
|
||||
bind:value={prompt}
|
||||
rows={8}
|
||||
placeholder={$i18n.t('Enter prompt here.')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bottom toolbar -->
|
||||
<div class="flex items-center justify-between px-4 pb-3.5 pt-1 gap-2">
|
||||
<div class="flex items-center gap-0.5 flex-wrap flex-1 min-w-0">
|
||||
<!-- Schedule dropdown -->
|
||||
<Dropdown bind:show={showScheduleDropdown} side="top" align="start">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-2xl text-xs transition
|
||||
text-gray-600 dark:text-gray-400 hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-3.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="whitespace-nowrap"
|
||||
>{scheduleLabel(frequency, interval, hour, minute, selectedDays, monthDay)}</span
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="size-2.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
slot="content"
|
||||
class="rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-72 p-1"
|
||||
>
|
||||
<div class="px-2 text-xs text-gray-500 py-1">
|
||||
{$i18n.t('Schedule')}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 px-2 mb-2 overflow-x-auto scrollbar-none">
|
||||
{#each FREQUENCIES as f}
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-1 rounded-xl text-xs transition shrink-0 {frequency === f.key
|
||||
? 'bg-gray-50 dark:bg-gray-800 text-black dark:text-gray-100'
|
||||
: 'text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-200'}"
|
||||
on:click={() => (frequency = f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if frequency === 'CUSTOM'}
|
||||
<div class="px-2 pb-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={customRrule}
|
||||
placeholder="RRULE:FREQ=DAILY;BYHOUR=9;BYMINUTE=0"
|
||||
class="w-full bg-transparent outline-hidden text-xs placeholder:text-gray-400 dark:placeholder:text-gray-600"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex gap-2 flex-wrap items-center px-3 pb-2 text-xs">
|
||||
{#if frequency === 'HOURLY'}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs text-gray-500">{$i18n.t('Every')}</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={interval}
|
||||
min={1}
|
||||
max={60}
|
||||
class="w-12 bg-gray-50 dark:bg-gray-800 rounded-lg text-center outline-hidden text-xs py-1 border border-gray-200 dark:border-gray-700"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span class="text-xs text-gray-500">hr</span>
|
||||
<span class="text-xs text-gray-500">{$i18n.t('at')}</span>
|
||||
<span class="text-gray-400">:</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={minute}
|
||||
min={0}
|
||||
max={59}
|
||||
class="w-12 bg-gray-50 dark:bg-gray-800 rounded-lg text-center outline-hidden text-xs py-1 border border-gray-200 dark:border-gray-700"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span class="text-xs text-gray-500">min</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs text-gray-500 mr-0.5">{$i18n.t('Time')}</span>
|
||||
<input
|
||||
type="time"
|
||||
value={`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`}
|
||||
on:input={(e) => {
|
||||
const [h, m] = e.currentTarget.value.split(':').map(Number);
|
||||
hour = h;
|
||||
minute = m;
|
||||
}}
|
||||
class="bg-gray-50 dark:bg-gray-800 rounded-lg text-center outline-hidden text-xs py-1 px-2 border border-gray-200 dark:border-gray-700 dark:color-scheme-dark"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if frequency === 'MONTHLY'}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs text-gray-500">{$i18n.t('Day')}</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={monthDay}
|
||||
min={1}
|
||||
max={31}
|
||||
class="w-12 bg-gray-50 dark:bg-gray-800 rounded-lg text-center outline-hidden text-xs py-1 border border-gray-200 dark:border-gray-700"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if frequency === 'WEEKLY'}
|
||||
<div class="flex gap-1 px-2 pb-2">
|
||||
{#each DAYS as d}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 py-1 text-xs rounded-xl transition {selectedDays.includes(d.key)
|
||||
? 'bg-gray-50 dark:bg-gray-800 text-black dark:text-gray-100'
|
||||
: 'text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-200'}"
|
||||
on:click={() => {
|
||||
if (selectedDays.includes(d.key)) {
|
||||
selectedDays = selectedDays.filter((x) => x !== d.key);
|
||||
} else {
|
||||
selectedDays = [...selectedDays, d.key];
|
||||
}
|
||||
}}
|
||||
>
|
||||
{d.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
<!-- Model dropdown -->
|
||||
<Dropdown bind:show={showModelDropdown} side="top" align="start">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-2xl text-xs transition
|
||||
text-gray-600 dark:text-gray-400 hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-3.5 shrink-0"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 0 0-2.455 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="whitespace-nowrap max-w-32 truncate">{modelLabel}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="size-2.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
slot="content"
|
||||
class="rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-72 p-1"
|
||||
>
|
||||
<div class="flex items-center gap-2 px-2.5 py-1.5">
|
||||
<Search className="size-3.5" strokeWidth="2.5" />
|
||||
<input
|
||||
bind:value={modelSearch}
|
||||
class="w-full text-sm bg-transparent outline-hidden"
|
||||
placeholder={$i18n.t('Search a model')}
|
||||
autocomplete="off"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto scrollbar-thin max-h-60">
|
||||
<div class="px-2 text-xs text-gray-500 py-1">
|
||||
{$i18n.t('Models')}
|
||||
</div>
|
||||
|
||||
{#each filteredModels as model (model.id)}
|
||||
<button
|
||||
class="px-2.5 py-1.5 rounded-xl w-full text-left text-sm {model_id === model.id
|
||||
? 'bg-gray-50 dark:bg-gray-800'
|
||||
: ''}"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
model_id = model.id;
|
||||
showModelDropdown = false;
|
||||
modelSearch = '';
|
||||
}}
|
||||
>
|
||||
<div class="flex text-black dark:text-gray-100 line-clamp-1">
|
||||
<img
|
||||
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${encodeURIComponent(model.id)}`}
|
||||
alt={model?.name ?? model.id}
|
||||
class="rounded-full size-5 items-center mr-2"
|
||||
loading="lazy"
|
||||
on:error={(e) => {
|
||||
e.currentTarget.src = '/favicon.png';
|
||||
}}
|
||||
/>
|
||||
<div class="truncate">
|
||||
{model.name}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="block px-3 py-2 text-sm text-gray-700 dark:text-gray-100">
|
||||
{$i18n.t('No results found')}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
class="px-3 py-1 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-200 transition"
|
||||
type="button"
|
||||
on:click={() => (show = false)}
|
||||
>
|
||||
{$i18n.t('Cancel')}
|
||||
</button>
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 {loading
|
||||
? 'cursor-not-allowed'
|
||||
: ''}"
|
||||
on:click={submitHandler}
|
||||
type="button"
|
||||
disabled={loading}
|
||||
>
|
||||
{automation ? $i18n.t('Save') : $i18n.t('Create')}
|
||||
{#if loading}
|
||||
<span class="shrink-0"><Spinner /></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
|
||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let editHandler: Function;
|
||||
export let runHandler: Function = () => {};
|
||||
export let deleteHandler: Function;
|
||||
export let onClose: Function = () => {};
|
||||
|
||||
let show = false;
|
||||
</script>
|
||||
|
||||
<Dropdown
|
||||
bind:show
|
||||
onOpenChange={(state) => {
|
||||
if (state === false) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip content={$i18n.t('More')}>
|
||||
<slot />
|
||||
</Tooltip>
|
||||
|
||||
<div slot="content">
|
||||
<div
|
||||
class="min-w-[170px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
|
||||
>
|
||||
<button
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
|
||||
draggable="false"
|
||||
on:click={() => {
|
||||
editHandler();
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class="flex items-center">{$i18n.t('Edit')}</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
|
||||
draggable="false"
|
||||
on:click={() => {
|
||||
runHandler();
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class="flex items-center">{$i18n.t('Run Now')}</div>
|
||||
</button>
|
||||
|
||||
<hr class="border-gray-50 dark:border-gray-850/30 my-1" />
|
||||
|
||||
<button
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
|
||||
draggable="false"
|
||||
on:click={() => {
|
||||
deleteHandler();
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<GarbageBin />
|
||||
<div class="flex items-center">{$i18n.t('Delete')}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
@@ -175,7 +175,8 @@
|
||||
bind:this={contentEl}
|
||||
class={contentClass}
|
||||
transition:flyAndScale
|
||||
on:click|stopPropagation
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
on:pointerdown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<slot name="content" />
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
let handleOutsidePointerDown;
|
||||
let handleModalFocusIn;
|
||||
|
||||
$: if (show && modalElement) {
|
||||
document.body.appendChild(modalElement);
|
||||
focusTrap = FocusTrap.createFocusTrap(modalElement, {
|
||||
@@ -66,10 +69,34 @@
|
||||
}
|
||||
});
|
||||
focusTrap.activate();
|
||||
|
||||
// Auto-pause focus trap when interacting with portaled content (e.g. Dropdown)
|
||||
handleOutsidePointerDown = (e) => {
|
||||
if (focusTrap && modalElement && !modalElement.contains(e.target)) {
|
||||
focusTrap.pause();
|
||||
}
|
||||
};
|
||||
handleModalFocusIn = () => {
|
||||
if (focusTrap) {
|
||||
focusTrap.unpause();
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', handleOutsidePointerDown, true);
|
||||
modalElement.addEventListener('focusin', handleModalFocusIn);
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else if (modalElement) {
|
||||
focusTrap.deactivate();
|
||||
if (focusTrap) {
|
||||
focusTrap.deactivate();
|
||||
focusTrap = null;
|
||||
}
|
||||
if (handleOutsidePointerDown) {
|
||||
document.removeEventListener('pointerdown', handleOutsidePointerDown, true);
|
||||
}
|
||||
if (handleModalFocusIn) {
|
||||
modalElement.removeEventListener('focusin', handleModalFocusIn);
|
||||
}
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.removeChild(modalElement);
|
||||
document.body.style.overflow = 'unset';
|
||||
|
||||
@@ -536,7 +536,7 @@
|
||||
};
|
||||
});
|
||||
|
||||
// Handler for chat:active events (defined outside onMount for proper cleanup)
|
||||
// Handler for chat events (defined outside onMount for proper cleanup)
|
||||
const chatActiveEventHandler = (event: {
|
||||
chat_id: string;
|
||||
message_id: string;
|
||||
@@ -553,6 +553,8 @@
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
} else if (event.data?.type === 'chat:list') {
|
||||
initChatList();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -214,6 +214,40 @@
|
||||
<div class=" self-center truncate">{$i18n.t('Settings')}</div>
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="/automations"
|
||||
draggable="false"
|
||||
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
|
||||
on:click={async (e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
e.preventDefault();
|
||||
show = false;
|
||||
goto('/automations');
|
||||
if ($mobile) {
|
||||
await tick();
|
||||
showSidebar.set(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="self-center mr-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="self-center truncate">{$i18n.t('Automations')}</div>
|
||||
</a>
|
||||
|
||||
<button
|
||||
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user