mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-25 23:14:48 -06:00
refac
This commit is contained in:
@@ -42,10 +42,15 @@ def _normalize_target(target: dict[str, Any], existing: dict[str, Any] | None =
|
||||
validate_url(url)
|
||||
config['url'] = url
|
||||
|
||||
events = target.get('events', existing.get('events') or sorted(VALID_EVENTS))
|
||||
events = [str(event) for event in events if str(event) in VALID_EVENTS]
|
||||
if not events:
|
||||
raise ValueError('At least one notification event is required')
|
||||
events = target['events'] if 'events' in target else existing.get('events', [])
|
||||
if events is None:
|
||||
events = []
|
||||
if not isinstance(events, list):
|
||||
raise ValueError('events must be a list')
|
||||
events = [str(event) for event in events]
|
||||
unsupported = [event for event in events if event not in VALID_EVENTS]
|
||||
if unsupported:
|
||||
raise ValueError(f'unsupported notification event: {unsupported[0]}')
|
||||
|
||||
delivery = str(target.get('delivery') or existing.get('delivery') or 'away')
|
||||
if delivery not in VALID_DELIVERY:
|
||||
@@ -83,7 +88,11 @@ async def _load_notifications(user_id: str) -> dict[str, Any]:
|
||||
targets = notifications.get('targets')
|
||||
|
||||
if not isinstance(targets, list) or not targets:
|
||||
legacy_url = str(settings.get('ui', {}).get('notifications', {}).get('webhook_url') or '').strip()
|
||||
legacy_url = str(
|
||||
notifications.get('webhook_url')
|
||||
or settings.get('ui', {}).get('notifications', {}).get('webhook_url')
|
||||
or ''
|
||||
).strip()
|
||||
if legacy_url:
|
||||
target = _normalize_target(
|
||||
{
|
||||
@@ -96,7 +105,7 @@ async def _load_notifications(user_id: str) -> dict[str, Any]:
|
||||
'config': {'url': legacy_url},
|
||||
}
|
||||
)
|
||||
notifications = {'targets': [target], 'default_target_id': DEFAULT_TARGET_ID}
|
||||
notifications = {**notifications, 'targets': [target], 'default_target_id': DEFAULT_TARGET_ID}
|
||||
await Users.update_user_settings_by_id(user_id, {'notifications': notifications})
|
||||
else:
|
||||
notifications['targets'] = [target for target in targets if isinstance(target, dict)]
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import { config, settings, user } from '$lib/stores';
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import Switch from '$lib/components/common/Switch.svelte';
|
||||
import SettingsSelect from '$lib/components/common/SettingsSelect.svelte';
|
||||
import UserSettingField from './UserSettingField.svelte';
|
||||
import UserSettingRow from './UserSettingRow.svelte';
|
||||
import UserSettingSection from './UserSettingSection.svelte';
|
||||
import Plus from '$lib/components/icons/Plus.svelte';
|
||||
import {
|
||||
createNotificationTarget,
|
||||
deleteNotificationTarget,
|
||||
@@ -26,40 +24,54 @@
|
||||
|
||||
let notificationEnabled = false;
|
||||
let notificationSound = true;
|
||||
let notificationSoundAlways = false;
|
||||
let targets: NotificationTarget[] = [];
|
||||
let defaultTargetId: string | null = null;
|
||||
let events: { event: string; label: string }[] = [];
|
||||
let loading = false;
|
||||
let editingTarget: NotificationTarget | null = null;
|
||||
let targetName = 'Webhook';
|
||||
let targetUrl = '';
|
||||
let targetEnabled = true;
|
||||
let targetDelivery: 'away' | 'always' = 'away';
|
||||
let targetEvents = ['chat.finished', 'chat.failed'];
|
||||
|
||||
const inputClass =
|
||||
'h-7 w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
|
||||
const actionButtonClass =
|
||||
'text-xs text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white disabled:cursor-not-allowed disabled:opacity-50';
|
||||
let events: { event: string; label: string; description?: string }[] = [
|
||||
{
|
||||
event: 'chat.finished',
|
||||
label: 'Chat finished',
|
||||
description: 'A chat run finished successfully.'
|
||||
},
|
||||
{ event: 'chat.failed', label: 'Chat failed', description: 'A chat run failed.' }
|
||||
];
|
||||
let loadingTargets = false;
|
||||
let savingTarget = false;
|
||||
let editingId: string | null = null;
|
||||
let formOpen = false;
|
||||
let form = {
|
||||
id: '',
|
||||
url: '',
|
||||
enabled: true,
|
||||
events: [] as string[],
|
||||
delivery: 'away' as 'away' | 'always'
|
||||
};
|
||||
let loadedTargets = false;
|
||||
|
||||
$: canUseWebhooks =
|
||||
($config?.features as any)?.enable_user_webhooks &&
|
||||
($user?.role === 'admin' || ($user?.permissions?.features?.webhooks ?? false));
|
||||
|
||||
const setNotificationEnabled = async (enabled: boolean) => {
|
||||
const permission = enabled ? await Notification.requestPermission() : 'granted';
|
||||
$: if (canUseWebhooks && !loadedTargets) {
|
||||
void loadTargets();
|
||||
}
|
||||
|
||||
if (permission === 'granted') {
|
||||
notificationEnabled = enabled;
|
||||
saveSettings({ notificationEnabled });
|
||||
const toggleNotifications = async () => {
|
||||
if (!notificationEnabled) {
|
||||
const permission =
|
||||
'Notification' in window ? await Notification.requestPermission() : 'denied';
|
||||
if (permission === 'granted') {
|
||||
notificationEnabled = true;
|
||||
saveSettings({ notificationEnabled });
|
||||
} else {
|
||||
toast.error(
|
||||
$i18n.t(
|
||||
'Response notifications cannot be activated as the website permissions have been denied. Please visit your browser settings to grant the necessary access.'
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
notificationEnabled = false;
|
||||
toast.error(
|
||||
$i18n.t(
|
||||
'Response notifications cannot be activated as the website permissions have been denied. Please visit your browser settings to grant the necessary access.'
|
||||
)
|
||||
);
|
||||
saveSettings({ notificationEnabled });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -68,75 +80,110 @@
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
loadingTargets = true;
|
||||
loadedTargets = true;
|
||||
try {
|
||||
events = await getNotificationEvents(localStorage.token);
|
||||
const response = await getNotificationTargets(localStorage.token);
|
||||
targets = response.targets ?? [];
|
||||
defaultTargetId = response.default_target_id ?? null;
|
||||
const [eventResult, targetResult] = await Promise.allSettled([
|
||||
getNotificationEvents(localStorage.token),
|
||||
getNotificationTargets(localStorage.token)
|
||||
]);
|
||||
|
||||
if (eventResult.status === 'fulfilled' && eventResult.value?.length) {
|
||||
events = eventResult.value;
|
||||
}
|
||||
if (targetResult.status === 'fulfilled') {
|
||||
targets = targetResult.value.targets ?? [];
|
||||
defaultTargetId = targetResult.value.default_target_id ?? null;
|
||||
} else {
|
||||
toast.error(`${targetResult.reason}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`${error}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
loadingTargets = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
editingTarget = null;
|
||||
targetName = 'Webhook';
|
||||
targetUrl = '';
|
||||
targetEnabled = true;
|
||||
targetDelivery = 'away';
|
||||
targetEvents = ['chat.finished', 'chat.failed'];
|
||||
const openNewTarget = () => {
|
||||
editingId = null;
|
||||
formOpen = true;
|
||||
form = {
|
||||
id: '',
|
||||
url: '',
|
||||
enabled: true,
|
||||
events: [],
|
||||
delivery: 'away'
|
||||
};
|
||||
};
|
||||
|
||||
const editTarget = (target: NotificationTarget) => {
|
||||
editingTarget = target;
|
||||
targetName = target.name;
|
||||
targetUrl = '';
|
||||
targetEnabled = target.enabled;
|
||||
targetDelivery = target.delivery;
|
||||
targetEvents = [...target.events];
|
||||
const openEditTarget = (target: NotificationTarget) => {
|
||||
editingId = target.id;
|
||||
formOpen = true;
|
||||
form = {
|
||||
id: target.id,
|
||||
url: '',
|
||||
enabled: target.enabled,
|
||||
events: [...target.events],
|
||||
delivery: target.delivery
|
||||
};
|
||||
};
|
||||
|
||||
const toggleFormEvent = (event: string) => {
|
||||
form.events = form.events.includes(event)
|
||||
? form.events.filter((item) => item !== event)
|
||||
: [...form.events, event];
|
||||
};
|
||||
|
||||
const saveTarget = async () => {
|
||||
savingTarget = true;
|
||||
try {
|
||||
const id = form.id.trim();
|
||||
const payload: Partial<NotificationTarget> = {
|
||||
...(id ? { id, name: id } : {}),
|
||||
type: 'webhook',
|
||||
name: targetName,
|
||||
enabled: targetEnabled,
|
||||
events: targetEvents,
|
||||
delivery: targetDelivery,
|
||||
config: targetUrl ? { url: targetUrl } : {}
|
||||
enabled: form.enabled,
|
||||
events: form.events,
|
||||
delivery: form.delivery,
|
||||
...(form.url.trim() ? { config: { url: form.url.trim() } } : {})
|
||||
};
|
||||
|
||||
if (editingTarget) {
|
||||
await updateNotificationTarget(localStorage.token, editingTarget.id, payload);
|
||||
if (editingId) {
|
||||
await updateNotificationTarget(localStorage.token, editingId, payload);
|
||||
} else {
|
||||
await createNotificationTarget(localStorage.token, payload);
|
||||
}
|
||||
|
||||
await loadTargets();
|
||||
formOpen = false;
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
toast.error(`${error}`);
|
||||
} finally {
|
||||
savingTarget = false;
|
||||
}
|
||||
};
|
||||
|
||||
const patchTarget = async (target: NotificationTarget, patch: Partial<NotificationTarget>) => {
|
||||
try {
|
||||
await updateNotificationTarget(localStorage.token, target.id, patch);
|
||||
await loadTargets();
|
||||
} catch (error) {
|
||||
toast.error(`${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEvent = (event: string) => {
|
||||
if (targetEvents.includes(event)) {
|
||||
targetEvents = targetEvents.filter((item) => item !== event);
|
||||
} else {
|
||||
targetEvents = [...targetEvents, event];
|
||||
const sendTest = async (target: NotificationTarget) => {
|
||||
try {
|
||||
await testNotificationTarget(localStorage.token, target.id);
|
||||
toast.success($i18n.t('Test notification sent.'));
|
||||
} catch (error) {
|
||||
toast.error(`${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
notificationEnabled = $settings.notificationEnabled ?? false;
|
||||
notificationSound = $settings?.notificationSound ?? true;
|
||||
notificationSoundAlways = $settings?.notificationSoundAlways ?? false;
|
||||
await loadTargets();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -146,24 +193,25 @@
|
||||
{$i18n.t('Notifications')}
|
||||
</h2>
|
||||
|
||||
<UserSettingSection title={$i18n.t('Local Notifications')} first>
|
||||
<UserSettingRow
|
||||
label={$i18n.t('Browser Notifications')}
|
||||
description={$i18n.t('Allow browser notifications for completed responses.')}
|
||||
>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label class="flex cursor-pointer items-center justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{$i18n.t('Browser Notifications')}
|
||||
</span>
|
||||
<Switch
|
||||
state={notificationEnabled}
|
||||
ariaLabel={$i18n.t('Browser Notifications')}
|
||||
on:change={(event) => {
|
||||
setNotificationEnabled(event.detail);
|
||||
}}
|
||||
on:change={toggleNotifications}
|
||||
/>
|
||||
</UserSettingRow>
|
||||
</label>
|
||||
<p class="-mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('Allow browser notifications for completed responses.')}
|
||||
</p>
|
||||
|
||||
<UserSettingRow
|
||||
label={$i18n.t('Notification Sound')}
|
||||
description={$i18n.t('Play a sound when new chat activity arrives.')}
|
||||
>
|
||||
<label class="flex cursor-pointer items-center justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{$i18n.t('Notification Sound')}
|
||||
</span>
|
||||
<Switch
|
||||
bind:state={notificationSound}
|
||||
ariaLabel={$i18n.t('Notification Sound')}
|
||||
@@ -171,175 +219,242 @@
|
||||
saveSettings({ notificationSound });
|
||||
}}
|
||||
/>
|
||||
</UserSettingRow>
|
||||
</label>
|
||||
|
||||
{#if notificationSound}
|
||||
<UserSettingRow
|
||||
label={$i18n.t('Always Play Notification Sound')}
|
||||
description={$i18n.t('Play notification sounds even when the app is focused.')}
|
||||
>
|
||||
<Switch
|
||||
bind:state={notificationSoundAlways}
|
||||
ariaLabel={$i18n.t('Always Play Notification Sound')}
|
||||
on:change={() => {
|
||||
saveSettings({ notificationSoundAlways });
|
||||
}}
|
||||
/>
|
||||
</UserSettingRow>
|
||||
{/if}
|
||||
</UserSettingSection>
|
||||
{#if canUseWebhooks}
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
{$i18n.t('Notification Targets')}
|
||||
</span>
|
||||
<button
|
||||
class="flex h-6 w-6 items-center justify-center rounded-lg text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
|
||||
type="button"
|
||||
on:click={openNewTarget}
|
||||
title={$i18n.t('Add Notification Target')}
|
||||
aria-label={$i18n.t('Add Notification Target')}
|
||||
>
|
||||
<Plus className="size-3.5" strokeWidth="2" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if canUseWebhooks}
|
||||
<UserSettingSection title={$i18n.t('Webhook Targets')}>
|
||||
{#if loading}
|
||||
<div class="text-xs text-gray-400 dark:text-gray-600">{$i18n.t('Loading...')}</div>
|
||||
{:else if targets.length === 0}
|
||||
<div class="text-xs text-gray-400 dark:text-gray-600">
|
||||
{#if loadingTargets}
|
||||
<p class="text-[0.6875rem] text-gray-400 dark:text-gray-600">{$i18n.t('Loading...')}</p>
|
||||
{:else if !targets.length}
|
||||
<p class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('No notification targets configured.')}
|
||||
</div>
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col">
|
||||
{#each targets as target}
|
||||
<div
|
||||
class="rounded-lg border border-gray-100/70 p-2.5 text-xs dark:border-white/[0.06]"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-medium text-gray-700 dark:text-gray-300">
|
||||
{target.name}
|
||||
{#if target.id === defaultTargetId}
|
||||
<span class="ml-1 text-gray-400 dark:text-gray-600"
|
||||
>{$i18n.t('Default')}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="truncate text-gray-400 dark:text-gray-600">
|
||||
{target.config?.url}
|
||||
</div>
|
||||
{@const alertLabels = events
|
||||
.filter((event) => target.events.includes(event.event))
|
||||
.map((event) => event.label)
|
||||
.join(', ')}
|
||||
<div class="notification-target-row">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="truncate text-[0.71875rem] text-gray-700 dark:text-gray-300">
|
||||
{target.id}
|
||||
</span>
|
||||
<span class="shrink-0 text-[0.625rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('Webhook')}
|
||||
</span>
|
||||
{#if target.id === defaultTargetId}
|
||||
<span class="shrink-0 text-[0.625rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('Default')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="truncate text-[0.625rem] leading-tight text-gray-400 dark:text-gray-600"
|
||||
>
|
||||
{target.config?.url}
|
||||
</div>
|
||||
<div
|
||||
class="truncate text-[0.625rem] leading-tight text-gray-400 dark:text-gray-600"
|
||||
>
|
||||
{alertLabels || $i18n.t('No chat alerts')}
|
||||
{#if target.events.length}
|
||||
· {target.delivery === 'away'
|
||||
? $i18n.t('Only when away')
|
||||
: $i18n.t('Always')}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
state={target.enabled}
|
||||
ariaLabel={$i18n.t('Enabled')}
|
||||
on:change={async (event) => {
|
||||
await updateNotificationTarget(localStorage.token, target.id, {
|
||||
enabled: event.detail
|
||||
});
|
||||
await loadTargets();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2 text-gray-400 dark:text-gray-600">
|
||||
<span
|
||||
>{target.delivery === 'always'
|
||||
? $i18n.t('Always')
|
||||
: $i18n.t('Only when away')}</span
|
||||
>
|
||||
<span>{target.events.join(', ')}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-3">
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
class={actionButtonClass}
|
||||
class="text-[0.625rem] text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
type="button"
|
||||
on:click={async () => {
|
||||
await testNotificationTarget(localStorage.token, target.id);
|
||||
toast.success($i18n.t('Test notification sent.'));
|
||||
}}>{$i18n.t('Test')}</button
|
||||
on:click={() => sendTest(target)}
|
||||
>
|
||||
{$i18n.t('Send Test')}
|
||||
</button>
|
||||
{#if target.id !== defaultTargetId}
|
||||
<button
|
||||
class="text-[0.625rem] text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
type="button"
|
||||
on:click={async () => {
|
||||
await setDefaultNotificationTarget(localStorage.token, target.id);
|
||||
await loadTargets();
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Make Default')}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class={actionButtonClass}
|
||||
class="text-[0.625rem] text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
type="button"
|
||||
on:click={() => editTarget(target)}>{$i18n.t('Edit')}</button
|
||||
on:click={() => openEditTarget(target)}
|
||||
>
|
||||
{$i18n.t('Edit')}
|
||||
</button>
|
||||
<button
|
||||
class={actionButtonClass}
|
||||
type="button"
|
||||
disabled={target.id === defaultTargetId}
|
||||
on:click={async () => {
|
||||
await setDefaultNotificationTarget(localStorage.token, target.id);
|
||||
await loadTargets();
|
||||
}}>{$i18n.t('Make Default')}</button
|
||||
>
|
||||
<button
|
||||
class={actionButtonClass}
|
||||
class="text-[0.625rem] text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
type="button"
|
||||
on:click={async () => {
|
||||
await deleteNotificationTarget(localStorage.token, target.id);
|
||||
await loadTargets();
|
||||
}}>{$i18n.t('Remove')}</button
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Remove')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex w-9 shrink-0 justify-end">
|
||||
<Switch
|
||||
state={target.enabled}
|
||||
ariaLabel={$i18n.t('Enabled')}
|
||||
on:change={(event) => patchTarget(target, { enabled: event.detail })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</UserSettingSection>
|
||||
|
||||
<UserSettingSection title={editingTarget ? $i18n.t('Edit Target') : $i18n.t('Add Target')}>
|
||||
<UserSettingField label={$i18n.t('Name')}>
|
||||
<input class={inputClass} type="text" bind:value={targetName} />
|
||||
</UserSettingField>
|
||||
|
||||
<UserSettingField
|
||||
label={$i18n.t('Webhook URL')}
|
||||
description={editingTarget
|
||||
? $i18n.t('Leave blank to keep the current webhook URL.')
|
||||
: $i18n.t('Send chat notifications to this webhook URL.')}
|
||||
>
|
||||
<input
|
||||
class={inputClass}
|
||||
type="url"
|
||||
bind:value={targetUrl}
|
||||
placeholder={editingTarget
|
||||
? editingTarget.config?.url
|
||||
: $i18n.t('Enter your webhook URL')}
|
||||
/>
|
||||
</UserSettingField>
|
||||
|
||||
<UserSettingRow label={$i18n.t('Enabled')}>
|
||||
<Switch bind:state={targetEnabled} ariaLabel={$i18n.t('Enabled')} />
|
||||
</UserSettingRow>
|
||||
|
||||
<UserSettingField label={$i18n.t('Delivery')}>
|
||||
<SettingsSelect bind:value={targetDelivery} ariaLabel={$i18n.t('Delivery')}>
|
||||
<option value="away">{$i18n.t('Only when away')}</option>
|
||||
<option value="always">{$i18n.t('Always')}</option>
|
||||
</SettingsSelect>
|
||||
</UserSettingField>
|
||||
|
||||
<UserSettingField label={$i18n.t('Events')}>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each events as item}
|
||||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={targetEvents.includes(item.event)}
|
||||
on:change={() => toggleEvent(item.event)}
|
||||
/>
|
||||
<span>{$i18n.t(item.label)}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</UserSettingField>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
{#if editingTarget}
|
||||
<button class={actionButtonClass} type="button" on:click={resetForm}
|
||||
>{$i18n.t('Cancel')}</button
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
class="rounded-full bg-black px-3.5 py-1.5 text-sm font-normal text-white transition hover:bg-gray-900 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-black dark:hover:bg-gray-100"
|
||||
type="button"
|
||||
disabled={!editingTarget && !targetUrl}
|
||||
on:click={saveTarget}
|
||||
>
|
||||
{editingTarget ? $i18n.t('Save') : $i18n.t('Add')}
|
||||
</button>
|
||||
</div>
|
||||
</UserSettingSection>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal size="sm" bind:show={formOpen}>
|
||||
<div class="p-4">
|
||||
<h2 class="mb-3 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{editingId ? $i18n.t('Edit') : $i18n.t('Add Notification Target')}
|
||||
</h2>
|
||||
|
||||
<div class="mb-2 flex gap-1">
|
||||
<button
|
||||
class="h-7 rounded-md bg-gray-200/60 px-2 text-xs text-gray-900 transition-colors dark:bg-white/10 dark:text-white"
|
||||
type="button"
|
||||
>
|
||||
{$i18n.t('Webhook')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-[0.625rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('Target ID for notify')}
|
||||
</div>
|
||||
<input
|
||||
bind:value={form.id}
|
||||
placeholder={$i18n.t('Target ID')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
class="block w-full bg-transparent py-0.5 font-mono text-[0.8125rem] text-gray-700 outline-none placeholder:text-gray-300 dark:text-gray-300 dark:placeholder:text-gray-700"
|
||||
/>
|
||||
|
||||
<div class="mt-2 text-[0.625rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('Webhook')}
|
||||
</div>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={form.url}
|
||||
placeholder={editingId
|
||||
? $i18n.t('Keep current webhook URL')
|
||||
: 'https://hooks.slack.com/services/...'}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
class="block w-full bg-transparent py-0.5 font-mono text-[0.8125rem] text-gray-700 outline-none placeholder:text-gray-300 dark:text-gray-300 dark:placeholder:text-gray-700"
|
||||
/>
|
||||
|
||||
<div class="mt-3 mb-1 text-[0.625rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('Automatic Events')}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each events as event}
|
||||
<button
|
||||
class="h-6 rounded-md px-2 text-[0.6875rem] transition-colors {form.events.includes(
|
||||
event.event
|
||||
)
|
||||
? 'bg-gray-200/60 text-gray-900 dark:bg-white/10 dark:text-white'
|
||||
: 'text-gray-500 hover:text-gray-800 dark:hover:text-gray-300'}"
|
||||
type="button"
|
||||
on:click={() => toggleFormEvent(event.event)}
|
||||
>
|
||||
{event.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if form.events.length}
|
||||
<div class="mt-3 mb-1 text-[0.625rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('Automatic Delivery')}
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
{#each ['away', 'always'] as mode}
|
||||
<button
|
||||
class="h-6 rounded-md px-2 text-[0.6875rem] transition-colors {form.delivery === mode
|
||||
? 'bg-gray-200/60 text-gray-900 dark:bg-white/10 dark:text-white'
|
||||
: 'text-gray-500 hover:text-gray-800 dark:hover:text-gray-300'}"
|
||||
type="button"
|
||||
on:click={() => (form.delivery = mode as 'away' | 'always')}
|
||||
>
|
||||
{mode === 'away' ? $i18n.t('Only when away') : $i18n.t('Always')}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="mt-3 text-[0.625rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t(
|
||||
'The notify tool always sends to an enabled target, regardless of automatic event settings.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 flex justify-end gap-3">
|
||||
<button
|
||||
class="text-[0.8125rem] text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
type="button"
|
||||
on:click={() => (formOpen = false)}
|
||||
>
|
||||
{$i18n.t('Cancel')}
|
||||
</button>
|
||||
<button
|
||||
class="text-[0.8125rem] text-gray-700 transition-colors duration-100 hover:text-gray-900 disabled:opacity-30 dark:text-gray-300 dark:hover:text-white"
|
||||
type="button"
|
||||
on:click={saveTarget}
|
||||
disabled={savingTarget || (!editingId && !form.url.trim())}
|
||||
>
|
||||
{savingTarget ? $i18n.t('Saving...') : $i18n.t('Save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.notification-target-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.375rem 0.25rem;
|
||||
border-bottom: 1px solid rgb(229 231 235 / 0.7);
|
||||
}
|
||||
|
||||
.notification-target-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:global(.dark) .notification-target-row {
|
||||
border-bottom-color: rgb(255 255 255 / 0.06);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
import WrenchAlt from '../icons/WrenchAlt.svelte';
|
||||
import Face from '../icons/Face.svelte';
|
||||
import AppNotification from '../icons/AppNotification.svelte';
|
||||
import AdjustmentsHorizontal from '../icons/AdjustmentsHorizontal.svelte';
|
||||
import ArchiveBox from '../icons/ArchiveBox.svelte';
|
||||
import ChevronLeft from '../icons/ChevronLeft.svelte';
|
||||
import Keyboard from '../icons/Keyboard.svelte';
|
||||
@@ -914,7 +915,7 @@
|
||||
selectedTab = 'interface';
|
||||
}}
|
||||
>
|
||||
<AppNotification className="size-3.5" strokeWidth="2" />
|
||||
<AdjustmentsHorizontal className="size-3.5" strokeWidth="2" />
|
||||
<span>{$i18n.t('Interface')}</span>
|
||||
</button>
|
||||
{:else if tabId === 'notifications'}
|
||||
|
||||
Reference in New Issue
Block a user