ytptube/ui/app/components/NotificationForm.vue
2026-04-14 23:43:48 +03:00

637 lines
19 KiB
Vue

<template>
<form id="notificationForm" autocomplete="off" class="space-y-6" @submit.prevent="checkInfo">
<div class="grid gap-4 md:grid-cols-2">
<div v-if="reference" class="md:col-span-2 flex justify-end">
<UButton
type="button"
color="neutral"
variant="ghost"
size="sm"
:icon="showImport ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
@click="showImport = !showImport"
>
{{ showImport ? 'Hide' : 'Show' }} import
</UButton>
</div>
<template v-if="showImport || !reference">
<UFormField class="w-full md:col-span-2" :ui="fieldUi">
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-import" class="size-4 text-toned" />
<span class="font-semibold text-default">Import string</span>
</div>
</template>
<template #description>
<span>You can use this field to populate the data, using shared string.</span>
</template>
<div class="flex flex-col gap-2 sm:flex-row">
<UInput
id="import_string"
v-model="importString"
type="text"
autocomplete="off"
size="lg"
class="w-full"
:ui="inputUi"
/>
<UButton
type="button"
color="neutral"
variant="outline"
icon="i-lucide-import"
size="lg"
class="justify-center sm:min-w-28"
:disabled="!importString"
@click="() => void importItem()"
>
Import
</UButton>
</div>
</UFormField>
</template>
<UFormField class="w-full" :ui="fieldUi">
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-type" class="size-4 text-toned" />
<span class="font-semibold text-default">Target name</span>
</div>
</template>
<template #description>
<span>
The notification target name, this is used to identify the target in the logs and
notifications.
</span>
</template>
<UInput
id="name"
v-model="form.name"
type="text"
size="lg"
:disabled="addInProgress"
class="w-full"
:ui="inputUi"
/>
</UFormField>
<UFormField class="w-full" :ui="fieldUi">
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-link" class="size-4 text-toned" />
<span class="font-semibold text-default">Target URL</span>
</div>
</template>
<template #description>
<span>
The URL to send the notification to. It can be regular http/https endpoint. or
<a
target="_blank"
rel="noreferrer"
href="https://github.com/caronc/apprise?tab=readme-ov-file#readme"
class="text-primary hover:underline"
>
Apprise
</a>
URL.
</span>
</template>
<UInput
id="url"
v-model="form.request.url"
type="url"
size="lg"
:disabled="addInProgress"
class="w-full"
:ui="inputUi"
/>
</UFormField>
</div>
<UAlert
v-if="isAppriseTarget"
color="info"
variant="soft"
icon="i-lucide-bell-ring"
title="Apprise target detected"
description="Apprise URLs only require the target name and URL. Request method, request type, headers, and data field are not used."
/>
<div v-if="!isAppriseTarget" class="grid gap-4 border-t border-default pt-5 md:grid-cols-2">
<UFormField class="w-full" :ui="fieldUi">
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-arrow-right-left" class="size-4 text-toned" />
<span class="font-semibold text-default">Request method</span>
</div>
</template>
<template #description>
<span>
The request method to use when sending the notification. This can be any of the standard
HTTP methods.
</span>
</template>
<USelect
id="method"
v-model="form.request.method"
:items="requestMethods"
size="lg"
class="w-full"
:disabled="addInProgress"
:ui="selectUi"
/>
</UFormField>
<UFormField class="w-full" :ui="fieldUi">
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-braces" class="size-4 text-toned" />
<span class="font-semibold text-default">Request Type</span>
</div>
</template>
<template #description>
<span>
The request type to use when sending the notification. This can be JSON or FORM request.
</span>
</template>
<USelect
id="type"
v-model="form.request.type"
:items="requestTypeItems"
value-key="value"
label-key="label"
size="lg"
class="w-full"
:disabled="addInProgress"
:ui="selectUi"
/>
</UFormField>
</div>
<div class="grid gap-5 border-t border-default pt-5 md:grid-cols-2">
<UFormField class="w-full" :ui="fieldUi">
<template #label>
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-2 font-semibold text-default">
<UIcon name="i-lucide-bell-ring" class="size-4 text-toned" />
<span>Select Events</span>
</span>
<button
v-if="form.on.length > 0"
type="button"
class="text-primary hover:underline"
@click="form.on = []"
>
Clear selection
</button>
</div>
</template>
<template #description>
<span>
Subscribe to the events you want to listen for. When the event is triggered, the
notification will be sent to the target URL. If no events are selected, the notification
will be sent for all events.
</span>
</template>
<select
id="on"
v-model="form.on"
multiple
:disabled="addInProgress"
class="min-h-40 w-full rounded-md border border-default bg-elevated/60 px-3 py-2 text-sm text-default outline-none transition focus:border-primary"
>
<option v-for="aEvent in allowedEvents" :key="aEvent" :value="aEvent">
{{ aEvent }}
</option>
</select>
</UFormField>
<UFormField class="w-full" :ui="fieldUi">
<template #label>
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-2 font-semibold text-default">
<UIcon name="i-lucide-sliders-horizontal" class="size-4 text-toned" />
<span>Select Presets</span>
</span>
<button
v-if="form.presets.length > 0"
type="button"
class="text-primary hover:underline"
@click="form.presets = []"
>
Clear selection
</button>
</div>
</template>
<template #description>
<span>
Select the presets you want to listen for. If you select presets, only events that
reference those presets will trigger the notification. If no presets are selected, the
notification will be sent for all presets.
</span>
</template>
<select
id="presets"
v-model="form.presets"
multiple
:disabled="addInProgress"
class="min-h-40 w-full rounded-md border border-default bg-elevated/60 px-3 py-2 text-sm text-default outline-none transition focus:border-primary"
>
<optgroup v-if="filterPresets(false).length > 0" label="Custom presets">
<option v-for="preset in filterPresets(false)" :key="preset.id" :value="preset.name">
{{ preset.name }}
</option>
</optgroup>
<optgroup label="Default presets">
<option v-for="preset in filterPresets(true)" :key="preset.id" :value="preset.name">
{{ preset.name }}
</option>
</optgroup>
</select>
</UFormField>
</div>
<div class="grid gap-4 border-t border-default pt-5 md:grid-cols-2">
<UFormField class="w-full" :ui="fieldUi">
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-power" class="size-4 text-toned" />
<span class="font-semibold text-default">Enabled</span>
</div>
</template>
<template #description>
<span>Whether the notification target is enabled.</span>
</template>
<div
class="flex min-h-11 items-center justify-between rounded-md border border-default bg-elevated/40 px-3"
>
<span class="text-sm text-default">{{ form.enabled ? 'Yes' : 'No' }}</span>
<USwitch v-model="form.enabled" :disabled="addInProgress" />
</div>
</UFormField>
<UFormField v-if="!isAppriseTarget" class="w-full" :ui="fieldUi">
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-braces" class="size-4 text-toned" />
<span class="font-semibold text-default">Data field</span>
</div>
</template>
<template #description>
<span>
The field name to use when sending the notification. This is used to identify the data
in the request. The default is data.
</span>
</template>
<UInput
id="data_key"
v-model="form.request.data_key"
type="text"
size="lg"
:disabled="addInProgress"
class="w-full"
:ui="inputUi"
/>
</UFormField>
</div>
<div v-if="!isAppriseTarget" class="space-y-4 border-t border-default pt-5">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="space-y-1">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon name="i-lucide-key" class="size-4 text-toned" />
<span>Optional Headers</span>
</div>
<p class="text-sm text-toned">The header key/value to send with the notification.</p>
</div>
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-plus"
:disabled="addInProgress"
@click="addHeader"
>
Add Header
</UButton>
</div>
<div v-if="form.request.headers.length > 0" class="space-y-3">
<div
v-for="(header, index) in form.request.headers"
:key="`header-${index}`"
class="grid gap-3 rounded-lg border border-default bg-muted/20 p-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]"
>
<UFormField :ui="fieldUi">
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-key" class="size-4 text-toned" />
<span class="font-semibold text-default">Header key</span>
</div>
</template>
<UInput
v-model="header.key"
type="text"
size="lg"
:disabled="addInProgress"
class="w-full"
:ui="inputUi"
/>
</UFormField>
<UFormField :ui="fieldUi">
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-pen-line" class="size-4 text-toned" />
<span class="font-semibold text-default">Header value</span>
</div>
</template>
<UInput
v-model="header.value"
type="text"
size="lg"
:disabled="addInProgress"
class="w-full"
:ui="inputUi"
/>
</UFormField>
<div class="flex items-end">
<UButton
type="button"
color="neutral"
variant="outline"
icon="i-lucide-trash"
:disabled="addInProgress"
@click="form.request.headers.splice(index, 1)"
>
Remove
</UButton>
</div>
</div>
</div>
<UAlert
color="warning"
variant="soft"
icon="i-lucide-triangle-alert"
description="If header key or value is empty, the header will not be sent."
/>
</div>
<div
class="flex flex-col-reverse gap-2 border-t border-default pt-5 sm:flex-row sm:justify-end"
>
<UButton
type="button"
color="neutral"
variant="outline"
size="lg"
icon="i-lucide-x"
:disabled="addInProgress"
class="justify-center"
@click="emitter('cancel')"
>
Cancel
</UButton>
<UButton
type="submit"
color="primary"
size="lg"
icon="i-lucide-save"
:disabled="addInProgress"
:loading="addInProgress"
class="justify-center"
>
Save
</UButton>
</div>
</form>
</template>
<script setup lang="ts">
import { useStorage } from '@vueuse/core';
import { useConfirm } from '~/composables/useConfirm';
import { useNotifications } from '~/composables/useNotifications';
import type { ImportedItem } from '~/types';
import type { notification, notificationRequestHeaderItem } from '~/types/notification';
const emitter = defineEmits<{
(event: 'cancel'): void;
(event: 'submit', payload: { reference: number | undefined; item: notification }): void;
}>();
const props = defineProps<{
reference?: number | null;
allowedEvents: readonly string[];
item: notification;
addInProgress?: boolean;
}>();
const toast = useNotification();
const box = useConfirm();
const { isApprise } = useNotifications();
const { filterPresets, hasPreset } = usePresetOptions();
const requestMethods = ['POST', 'PUT'];
const requestTypeItems = [
{ label: 'Json', value: 'json' },
{ label: 'Form', value: 'form' },
];
const showImport = useStorage('showImport', false);
const importString = ref('');
const requestType = computed(() => form.request.type);
const form = reactive<notification>(normalizeNotification(props.item));
const fieldUi = {
label: 'font-semibold text-default',
container: 'space-y-2',
description: 'text-sm text-toned',
hint: 'text-sm text-toned',
};
const inputUi = {
root: 'w-full',
base: 'w-full bg-elevated/60 ring-default focus-visible:ring-primary',
};
const selectUi = {
base: 'w-full bg-elevated/60 ring-default focus-visible:ring-primary',
};
const isAppriseTarget = computed(() => Boolean(form.request.url) && isApprise(form.request.url));
watch(
() => props.item,
(value) => {
Object.assign(form, normalizeNotification(value));
},
{ deep: true },
);
function createDefaultNotification(): notification {
return {
name: '',
on: [],
presets: [],
enabled: true,
request: {
method: 'POST',
url: '',
type: 'json',
headers: [],
data_key: 'data',
},
};
}
function normalizeNotification(value?: Partial<notification> | null): notification {
const base = createDefaultNotification();
const item = JSON.parse(JSON.stringify(value || {})) as Partial<notification>;
return {
...base,
...item,
on: Array.isArray(item.on) ? [...item.on] : [],
presets: Array.isArray(item.presets) ? [...item.presets] : [],
enabled: item.enabled ?? true,
request: {
...base.request,
...(item.request || {}),
headers: Array.isArray(item.request?.headers)
? item.request.headers.map((header) => ({ ...header }))
: [],
},
};
}
const hasFormContent = computed(() => {
return Boolean(
form.name ||
form.request.url ||
(requestType.value === 'form' ? form.request.data_key : '') ||
form.on.length > 0 ||
form.presets.length > 0 ||
form.request.headers.some((header) => header.key || header.value),
);
});
const addHeader = (): void => {
form.request.headers.push({ key: '', value: '' });
};
const checkInfo = async (): Promise<void> => {
const required = !isAppriseTarget.value
? [
'name',
'request.url',
'request.method',
'request.type',
...(requestType.value === 'form' ? ['request.data_key'] : []),
]
: ['name', 'request.url'];
for (const key of required) {
if (key.includes('.')) {
const [parent, child] = key.split('.') as ['request', keyof notification['request'] & string];
const parentObject = form[parent] as Record<string, unknown> | undefined;
if (!parentObject || !parentObject[child]) {
toast.error(`The field ${parent}.${child} is required.`);
return;
}
} else if (!(form as Record<string, unknown>)[key]) {
toast.error(`The field ${key} is required.`);
return;
}
}
if (!isAppriseTarget.value) {
try {
new URL(form.request.url);
} catch {
toast.error('Invalid URL');
return;
}
}
const copy = normalizeNotification(form);
copy.name = copy.name.trim();
copy.request.url = copy.request.url.trim();
copy.request.method = copy.request.method.trim();
copy.request.type = copy.request.type.trim();
copy.request.data_key = copy.request.data_key.trim();
copy.on = copy.on.map((entry) => entry.trim()).filter(Boolean);
copy.presets = copy.presets.map((entry) => entry.trim()).filter(Boolean);
copy.request.headers = copy.request.headers
.map((header) => ({
key: String(header.key || '').trim(),
value: String(header.value || '').trim(),
}))
.filter((header) => header.key && header.value) as notificationRequestHeaderItem[];
emitter('submit', { reference: toRaw(props.reference ?? undefined), item: toRaw(copy) });
};
const importItem = async (): Promise<void> => {
const value = importString.value.trim();
if (!value) {
toast.error('The import string is required.');
return;
}
try {
const item = decode(value) as notification & ImportedItem;
if ('notification' !== item._type) {
toast.error(`Invalid import string. Expected type 'notification', got '${item._type}'.`);
importString.value = '';
return;
}
if (
hasFormContent.value &&
false === (await box.confirm('Overwrite the current form fields?'))
) {
return;
}
const nextValue = normalizeNotification(item);
nextValue.presets = nextValue.presets.filter((preset) => hasPreset(preset));
Object.assign(form, nextValue);
importString.value = '';
showImport.value = false;
} catch (error: any) {
toast.error(`Failed to import task. ${error.message}`);
}
};
</script>