Refactor: facelift the WebUI

This commit is contained in:
arabcoders 2026-01-14 18:56:07 +03:00
parent 48e653b70a
commit e0a47e2fe3
19 changed files with 475 additions and 454 deletions

View file

@ -132,13 +132,9 @@
</div> </div>
</div> </div>
<div class="column is-12"> <div class="column is-12">
<Message message_class="has-background-warning-90 has-text-dark" v-if="items.length === 0"> <Message class="is-warning" v-if="items.length === 0">
<span class="icon"> <span class="icon"><i class="fas fa-exclamation-circle" /></span>
<i class="fas fa-exclamation-circle" /> <span>No custom fields found, you can add new fields using the button above.</span>
</span>
<span>
No custom fields found, you can add new fields using the button above.
</span>
</Message> </Message>
</div> </div>
</div> </div>
@ -250,7 +246,6 @@ const addNewField = () => items.value.push({
const deleteField = (index: number) => items.value.splice(index, 1) const deleteField = (index: number) => items.value.splice(index, 1)
const validateItem = (item: DLField, index: number): boolean => { const validateItem = (item: DLField, index: number): boolean => {
const requiredFields = ['name', 'field', 'kind', 'description'] const requiredFields = ['name', 'field', 'kind', 'description']
for (const field of requiredFields) { for (const field of requiredFields) {
@ -282,9 +277,10 @@ const validateItem = (item: DLField, index: number): boolean => {
const sortedDLFields = computed(() => items.value.sort((a, b) => (a.order || 0) - (b.order || 0))) const sortedDLFields = computed(() => items.value.sort((a, b) => (a.order || 0) - (b.order || 0)))
watch(() => config.ytdlp_options, newOptions => ytDlpOptions.value = newOptions watch(() => config.ytdlp_options, newOptions => ytDlpOptions.value = newOptions
.filter(opt => !opt.ignored).flatMap(opt => opt.flags .filter(opt => !opt.ignored)
.filter(flag => flag.startsWith('--')) .flatMap(
.map(flag => ({ value: flag, description: opt.description || '' }))), opt => opt.flags.filter(flag => flag.startsWith('--')
).map(flag => ({ value: flag, description: opt.description || '' }))),
{ immediate: true } { immediate: true }
) )
onMounted(async () => { onMounted(async () => {

View file

@ -1,67 +0,0 @@
<template>
<Message :message_class="messageClass" :title="title" :icon="icon" :useClose="true" @close="() => dismissed = true"
v-if="!isDismissed">
<slot />
</Message>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useStorage } from '@vueuse/core'
import Message from "~/components/Message.vue";
const props = withDefaults(defineProps<{
version: string
storageKey?: string
title?: string
icon?: string
tone?: 'warning' | 'danger' | 'info' | 'success'
}>(), {
storageKey: 'deprecated-notice',
title: 'Deprecated Feature',
icon: 'fas fa-exclamation-triangle',
tone: 'warning',
})
const config = useConfigStore()
const isDev = computed(() => 'development' === config.app?.app_env)
const storageKeyComputed = computed<string>(() => `${props.storageKey}:${props.version}`)
const dismissed = useStorage<boolean>(storageKeyComputed, false)
const isDismissed = computed(() => dismissed.value)
const messageClass = computed(() => {
switch (props.tone) {
case 'danger':
return 'is-danger has-background-danger-90 has-text-dark'
case 'info':
return 'is-info has-background-info-90 has-text-dark'
case 'success':
return 'is-success has-background-success-90 has-text-dark'
case 'warning':
default:
return 'is-warning has-background-warning-90 has-text-dark'
}
})
onMounted(() => {
if (!isDev.value) {
return
}
document.addEventListener('keydown', handle_event)
})
onBeforeUnmount(() => {
if (!isDev.value) {
return
}
document.removeEventListener('keydown', handle_event)
})
const handle_event = (e: KeyboardEvent) => {
if (e.ctrlKey && e.altKey && 'd' === e.key.toLowerCase()) {
e.preventDefault()
dismissed.value = !dismissed.value
}
}
</script>

View file

@ -434,9 +434,9 @@
<div class="columns is-multiline" v-if="!hasItems && !paginationInfo.isLoading"> <div class="columns is-multiline" v-if="!hasItems && !paginationInfo.isLoading">
<div class="column is-12"> <div class="column is-12">
<Message message_class="is-warning" title="Filter results" icon="fas fa-search" :useClose="true" <Message class="is-warning" title="Filter results" icon="fas fa-search" :useClose="true"
@close="() => emitter('clear_search')" v-if="query" :newStyle="true"> @close="() => emitter('clear_search')" v-if="query">
<span class="is-block">No results found for '<span class="is-underlined is-bold">{{ query }}</span>'.</span> <span class="is-block">No results found for: <code>{{ query }}</code>.</span>
<hr> <hr>
<p> <p>
You can search using any value shown in the items <code><span class="icon"><i You can search using any value shown in the items <code><span class="icon"><i
@ -452,9 +452,9 @@
<li><code>source_name:task_name</code> - items added by the specified task.</li> <li><code>source_name:task_name</code> - items added by the specified task.</li>
</ul> </ul>
</Message> </Message>
<Message message_class="is-primary" title="No items" icon="fas fa-exclamation-triangle" <Message v-else-if="socket.isConnected" class="is-primary" title="No items" icon="fas fa-exclamation-triangle"
v-else-if="socket.isConnected" :new-style="true"> :new-style="true">
<p>Your download history is empty. Once queued downloads are completed, they will appear here.</p> <p>Download history is empty.</p>
</Message> </Message>
</div> </div>
</div> </div>

View file

@ -71,8 +71,9 @@ code {
</div> </div>
<div style="position: relative" v-if="!isLoading"> <div style="position: relative" v-if="!isLoading">
<Message v-if="error" message_class="has-background-warning-90 has-text-dark" title="Error" <Message v-if="error" class="is-warning" title="Error" icon="fas fa-exclamation">
icon="fas fa-exclamation" :message="error" /> {{ error }}
</Message>
<div class="card" v-else> <div class="card" v-else>
<div class="card-body p-4"> <div class="card-body p-4">
<div class="content" v-html="content" /> <div class="content" v-html="content" />

View file

@ -1,16 +1,13 @@
<template> <template>
<div :class="[newStyle ? 'message' : 'notification', message_class]"> <div class="message">
<button class="delete" @click="$emit('close')" v-if="!useToggle && useClose && !newStyle"></button>
<div @click="$emit('toggle')" class="is-clickable is-pulled-right is-unselectable" v-if="useToggle"> <div @click="$emit('toggle')" class="is-clickable is-pulled-right is-unselectable" v-if="useToggle">
<span class="icon"> <span class="icon">
<i class="fas" :class="{'fa-arrow-up':toggle,'fa-arrow-down':!toggle}"></i> <i class="fas" :class="{ 'fa-arrow-up': toggle, 'fa-arrow-down': !toggle }"></i>
</span> </span>
<span>{{ toggle ? 'Close' : 'Open' }}</span> <span>{{ toggle ? 'Close' : 'Open' }}</span>
</div> </div>
<div class="is-unselectable" <div class="is-unselectable message-header" :class="{ 'is-clickable': useToggle, }" v-if="title || icon"
:class="{'is-clickable':useToggle, 'notification-title': !newStyle, 'message-header': newStyle}" @click="true === useToggle ? $emit('toggle', toggle) : null">
v-if="title || icon"
@click="true === useToggle ? $emit('toggle', toggle): null">
<template v-if="icon"> <template v-if="icon">
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i :class="icon"></i></span> <span class="icon"><i :class="icon"></i></span>
@ -18,12 +15,11 @@
</span> </span>
</template> </template>
<template v-else>{{ title }}</template> <template v-else>{{ title }}</template>
<button class="delete" @click="$emit('close')" v-if="!useToggle && useClose && newStyle"/> <button class="delete" @click="$emit('close')" v-if="!useToggle && useClose" />
</div> </div>
<div class="content is-text-break" v-if="false === useToggle || toggle" <div class="content message-body is-text-break" v-if="false === useToggle || toggle" :class="body_class">
:class="{'notification-body': !newStyle, 'message-body': newStyle}">
<template v-if="message">{{ message }}</template> <template v-if="message">{{ message }}</template>
<slot/> <slot />
</div> </div>
</div> </div>
</template> </template>
@ -36,24 +32,21 @@ withDefaults(defineProps<{
icon?: string | null icon?: string | null
/** Main message content */ /** Main message content */
message?: string | null message?: string | null
/** CSS class for the notification */
message_class?: string
/** If true, show toggle button */ /** If true, show toggle button */
useToggle?: boolean useToggle?: boolean
/** Current toggle state */ /** Current toggle state */
toggle?: boolean toggle?: boolean
/** If true, show close button */ /** If true, show close button */
useClose?: boolean, useClose?: boolean,
newStyle?: boolean body_class?: string | null,
}>(), { }>(), {
title: null, title: null,
icon: null, icon: null,
message: null, message: null,
message_class: 'is-info',
useToggle: false, useToggle: false,
toggle: false, toggle: false,
useClose: false, useClose: false,
newStyle: false body_class: null,
}) })
defineEmits<{ defineEmits<{

View file

@ -1,5 +1,5 @@
<template> <template>
<Message message_class="is-success has-background-success-90 has-text-dark"> <Message class="is-success">
<span class="icon"><i class="fas fa-info-circle" /></span> <span class="icon"><i class="fas fa-info-circle" /></span>
<span> <span>
A New WebUI Version installed, please A New WebUI Version installed, please

View file

@ -302,9 +302,9 @@
<div class="columns is-multiline" v-if="filteredItems.length < 1"> <div class="columns is-multiline" v-if="filteredItems.length < 1">
<div class="column is-12"> <div class="column is-12">
<Message message_class="is-warning" title="Filter results" :newStyle="true" icon="fas fa-search" :useClose="true" <Message class="is-warning" title="Filter results" icon="fas fa-search" :useClose="true"
@close="() => emitter('clear_search')" v-if="query"> @close="() => emitter('clear_search')" v-if="query">
<span class="is-block">No results found for '<span class="is-underlined is-bold">{{ query }}</span>'.</span> <span class="is-block">No results found for: <code>{{ query }}</code>.</span>
<hr> <hr>
<p> <p>
You can search using any value shown in the items <code><span class="icon"><i You can search using any value shown in the items <code><span class="icon"><i
@ -320,9 +320,9 @@
<li><code>source_name:task_name</code> - items added by the specified task.</li> <li><code>source_name:task_name</code> - items added by the specified task.</li>
</ul> </ul>
</Message> </Message>
<Message message_class="is-info" title="No items" icon="fas fa-exclamation-triangle" :useClose="false" <Message v-else class="is-info" title="No items" icon="fas fa-exclamation-triangle" :useClose="false"
:newStyle="true" v-else> >
<p>The download queue is empty.</p> <p>Download queue is empty.</p>
</Message> </Message>
</div> </div>
</div> </div>

View file

@ -85,15 +85,10 @@
</div> </div>
</div> </div>
<Message v-if="!guiSupported" message_class="is-warning"> <Message v-if="!guiSupported" class="is-warning">
<p> <span class="icon"><i class="fa-solid fa-triangle-exclamation" /></span>
<span> <span>This task definition uses features that cannot be represented with the visual editor. You can still
<span class="icon"><i class="fa-solid fa-triangle-exclamation" /></span> update it via the advanced view.</span>
<span>This task definition uses features that cannot be represented with the visual editor. You can still
update it
via the advanced view.</span>
</span>
</p>
</Message> </Message>
<div v-if="'gui' === mode"> <div v-if="'gui' === mode">

View file

@ -1,16 +1,15 @@
<template> <template>
<main class="columns mt-2 is-multiline"> <main class="columns mt-2 is-multiline">
<div class="column is-12" v-if="form?.url && is_yt_handle(form.url)"> <div class="column is-12" v-if="form?.url && is_yt_handle(form.url)">
<Message title="Information" class="is-info is-background-info-80 has-text-dark" icon="fas fa-info-circle"> <Message title="Information" class="is-info" icon="fas fa-info-circle">
<span>You are using a YouTube link with <b>@handle</b> instead of <b>channel_id</b>. To activate RSS feed You are using a YouTube link with <code>@handle</code> instead of <code>channel_id</code>. To activate RSS
support for URL click on the <NuxtLink @click="async () => form.url = await convert_url(form.url)"><b>Convert feed support for URL click on the <b>Convert URL</b> link.
URL</b></NuxtLink> link.</span>
</Message> </Message>
</div> </div>
<div class="column is-12" v-if="form?.url && is_generic_rss(form.url)"> <div class="column is-12" v-if="form?.url && is_generic_rss(form.url)">
<Message title="Information" class="is-info is-background-info-80 has-text-dark" icon="fas fa-info-circle"> <Message title="Information" class="is-warning" icon="fas fa-info-circle">
<span>You are using a generic RSS/Atom feed URL. The task handler will automatically download new items found You are using a generic RSS/Atom feed URL. The task handler will automatically download new items found
in this feed.</span> in this feed.
</Message> </Message>
</div> </div>
<div class="column is-12"> <div class="column is-12">
@ -330,7 +329,7 @@
</div> </div>
<div class="column is-12"> <div class="column is-12">
<Message class="is-info" :newStyle="true"> <Message class="is-info">
<span> <span>
<ul> <ul>
<li><strong>Tasks:</strong> requires <code>--download-archive</code> in <li><strong>Tasks:</strong> requires <code>--download-archive</code> in

View file

@ -86,7 +86,7 @@ code {
</div> </div>
</form> </form>
<Message v-if="loading" class="has-background-info-90 has-text-dark mt-5"> <Message v-if="loading" class="is-info">
<p> <p>
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fas fa-spinner fa-spin" /></span> <span class="icon"><i class="fas fa-spinner fa-spin" /></span>
@ -96,7 +96,7 @@ code {
</Message> </Message>
<div v-if="response" class="mt-4"> <div v-if="response" class="mt-4">
<Message v-if="response.error" message_class="has-background-danger-90 has-text-dark" title="Error" <Message v-if="response.error" class="is-danger" title="Error"
icon="fas fa-exclamation-triangle"> icon="fas fa-exclamation-triangle">
<p>{{ response.error }}</p> <p>{{ response.error }}</p>
<p v-if="response.message">{{ response.message }}</p> <p v-if="response.message">{{ response.message }}</p>
@ -109,7 +109,6 @@ code {
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { request } from '~/utils' import { request } from '~/utils'

View file

@ -132,7 +132,7 @@
<div> <div>
<NuxtLoadingIndicator /> <NuxtLoadingIndicator />
<NuxtPage v-if="config.is_loaded" :isLoading="loadingImage" @reload_bg="() => loadImage(true)" /> <NuxtPage v-if="config.is_loaded" :isLoading="loadingImage" @reload_bg="() => loadImage(true)" />
<Message v-if="!config.is_loaded" class="mt-5" :newStyle="true" title="Loading Configuration" <Message v-if="!config.is_loaded" class="is-info mt-5" title="Loading Configuration"
icon="fas fa-spinner fa-spin"> icon="fas fa-spinner fa-spin">
<p>This usually takes less than a second. <p>This usually takes less than a second.
<span v-if="!socket.isConnected" class="mt-2"> <span v-if="!socket.isConnected" class="mt-2">

View file

@ -105,7 +105,8 @@
</div> </div>
</div> </div>
<div class="columns is-mobile is-multiline is-justify-content-flex-end" v-if="config.app.browser_control_enabled && items && items.length > 0"> <div class="columns is-mobile is-multiline is-justify-content-flex-end"
v-if="config.app.browser_control_enabled && items && items.length > 0">
<div class="column is-narrow"> <div class="column is-narrow">
<button type="button" class="button" @click="masterSelectAll = !masterSelectAll" <button type="button" class="button" @click="masterSelectAll = !masterSelectAll"
:class="{ 'has-text-primary': !masterSelectAll, 'has-text-danger': masterSelectAll }" :class="{ 'has-text-primary': !masterSelectAll, 'has-text-danger': masterSelectAll }"
@ -341,8 +342,8 @@
</template> </template>
<div class="column is-12" v-if="search && filteredItems.length < 1"> <div class="column is-12" v-if="search && filteredItems.length < 1">
<Message message_class="has-background-warning-90 has-text-dark" title="No results" icon="fas fa-filter" <Message class="is-warning" title="No results" icon="fas fa-filter" :useClose="true"
:useClose="true" @close="() => search = ''" v-if="search"> @close="() => search = ''" v-if="search">
<p class="is-block"> <p class="is-block">
No results found for '<span class="is-underlined is-bold">{{ search }}</span>'. No results found for '<span class="is-underlined is-bold">{{ search }}</span>'.
</p> </p>
@ -350,13 +351,12 @@
</div> </div>
<div class="column is-12" v-if="!items || items.length < 1"> <div class="column is-12" v-if="!items || items.length < 1">
<Message title="Loading content" class="has-background-info-90 has-text-dark" icon="fas fa-refresh fa-spin" <Message title="Loading content" class="is-info" icon="fas fa-refresh fa-spin"
v-if="isLoading"> v-if="isLoading">
Loading file browser contents... Loading file browser contents...
</Message> </Message>
<Message v-else title="No Content" class="is-background-warning-80 has-text-dark" <Message v-else title="No Content" class="is-warning" icon="fas fa-exclamation-circle">
icon="fas fa-exclamation-circle"> Directory is empty.
No content found in this directory.
</Message> </Message>
</div> </div>
<div class="column is-12" v-if="!config.app.browser_control_enabled"> <div class="column is-12" v-if="!config.app.browser_control_enabled">
@ -446,13 +446,10 @@ const filteredItems = computed<FileItem[]>(() => {
} }
const searchLower = search.value.toLowerCase() const searchLower = search.value.toLowerCase()
return sortedItems(items.value.filter((item: FileItem) => { return sortedItems(items.value.filter((item: FileItem) => deepIncludes(item, searchLower, new WeakSet())))
return item.name.toLowerCase().includes(searchLower)
}))
}) })
const sortedItems = (items: FileItem[]): FileItem[] => { const sortedItems = (items: FileItem[]): FileItem[] => {
if (!items || items.length < 1) { if (!items || items.length < 1) {
return [] return []
} }

View file

@ -47,8 +47,9 @@ hr {
<div class="columns is-multiline" v-if="isLoading"> <div class="columns is-multiline" v-if="isLoading">
<div class="column is-12"> <div class="column is-12">
<Message message_class="has-background-info-90 has-text-dark" title="Loading" icon="fas fa-spinner fa-spin" <Message class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
message="Loading data. Please wait..." /> Loading data. Please wait...
</Message>
</div> </div>
</div> </div>
@ -92,8 +93,8 @@ hr {
<div class="columns is-multiline" v-if="!filteredLogs || filteredLogs.length < 1"> <div class="columns is-multiline" v-if="!filteredLogs || filteredLogs.length < 1">
<div class="column is-12"> <div class="column is-12">
<Message title="No Results" class="is-background-warning-80 has-text-dark" icon="fas fa-search" <Message title="No Results" class="is-warning" icon="fas fa-search" v-if="query"
v-if="query" :useClose="true" @close="query = ''"> :useClose="true" @close="query = ''">
<p>No changelog entries found for the query: <strong>{{ query }}</strong>.</p> <p>No changelog entries found for the query: <strong>{{ query }}</strong>.</p>
<p>Please try a different search term.</p> <p>Please try a different search term.</p>
</Message> </Message>
@ -135,29 +136,21 @@ const filteredLogs = computed<changelogs>(() => {
const q = query.value?.toLowerCase() const q = query.value?.toLowerCase()
if (!q) return logs.value if (!q) return logs.value
return logs.value return logs.value.map(log => {
.map(log => { const tagMatches = log.tag.toLowerCase().includes(q)
// Check if tag matches
const tagMatches = log.tag.toLowerCase().includes(q)
// Filter commits that match the query const filteredCommits = log.commits?.filter(commit =>
const filteredCommits = log.commits?.filter(commit => commit.message.toLowerCase().includes(q) ||
commit.message.toLowerCase().includes(q) || commit.author.toLowerCase().includes(q) ||
commit.author.toLowerCase().includes(q) || commit.full_sha.toLowerCase().includes(q)
commit.full_sha.toLowerCase().includes(q) ) ?? []
) ?? []
// Return log only if tag matches OR if there are matching commits if (tagMatches || filteredCommits.length > 0) {
if (tagMatches || filteredCommits.length > 0) { return { ...log, commits: tagMatches ? log.commits : filteredCommits }
return { }
...log,
commits: tagMatches ? log.commits : filteredCommits
}
}
return null return null
}) }).filter((log): log is changeset => log !== null)
.filter((log): log is changeset => log !== null)
}) })
const loadContent = async () => { const loadContent = async () => {

View file

@ -16,6 +16,19 @@
</span> </span>
<div class="is-pulled-right" v-if="!toggleForm"> <div class="is-pulled-right" v-if="!toggleForm">
<div class="field is-grouped"> <div class="field is-grouped">
<p class="control has-icons-left" v-if="toggleFilter && items && items.length > 0">
<input type="search" v-model.lazy="query" class="input" id="filter"
placeholder="Filter displayed content">
<span class="icon is-left"><i class="fas fa-filter" /></span>
</p>
<p class="control" v-if="items && items.length > 0">
<button class="button is-danger is-light" @click="toggleFilter = !toggleFilter">
<span class="icon"><i class="fas fa-filter" /></span>
<span v-if="!isMobile">Filter</span>
</button>
</p>
<p class="control"> <p class="control">
<button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm;"> <button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm;">
<span class="icon"><i class="fas fa-add" /></span> <span class="icon"><i class="fas fa-add" /></span>
@ -44,7 +57,7 @@
</div> </div>
<div class="is-hidden-mobile" v-if="!toggleForm"> <div class="is-hidden-mobile" v-if="!toggleForm">
<span class="subtitle"> <span class="subtitle">
Run yt-dlp custom match filter on returned info. and apply cli arguments if matched. Run yt-dlp custom match filter on returned info. and apply options.
</span> </span>
</div> </div>
</div> </div>
@ -53,192 +66,202 @@
<ConditionForm :addInProgress="addInProgress" :reference="itemRef" :item="item as ConditionItem" <ConditionForm :addInProgress="addInProgress" :reference="itemRef" :item="item as ConditionItem"
@cancel="resetForm(true)" @submit="updateItem" /> @cancel="resetForm(true)" @submit="updateItem" />
</div> </div>
</div>
<div class="column is-12" v-if="!toggleForm"> <div class="columns is-multiline" v-if="!isLoading && !toggleForm && (filteredItems && filteredItems.length > 0)">
<div class="columns is-multiline" v-if="items.length > 0"> <div class="column is-12" v-if="'list' === display_style">
<template v-if="'list' === display_style"> <div class="table-container">
<div class="column is-12"> <table class="table is-striped is-hoverable is-fullwidth is-bordered"
<div class="table-container"> style="min-width: 850px; table-layout: fixed;">
<table class="table is-striped is-hoverable is-fullwidth is-bordered" <thead>
style="min-width: 850px; table-layout: fixed;"> <tr class="has-text-centered is-unselectable">
<thead> <th width="80%">
<tr class="has-text-centered is-unselectable"> <span class="icon"><i class="fa-solid fa-filter" /></span>
<th width="80%"> <span>Condition</span>
</th>
<th width="20%">
<span class="icon"><i class="fa-solid fa-gear" /></span>
<span>Actions</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="cond in filteredItems" :key="cond.id">
<td class="is-vcentered">
<div class="is-text-overflow is-bold">
{{ cond.name }}
</div>
<div class="is-unselectable">
<span class="icon-text is-clickable" @click="toggleEnabled(cond)"
v-tooltip="'Click to ' + (cond.enabled !== false ? 'disable' : 'enable') + ' condition'">
<span class="icon">
<i class="fa-solid fa-power-off"
:class="{ 'has-text-success': cond.enabled !== false, 'has-text-danger': cond.enabled === false }" />
</span>
<span>{{ cond.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
</span>
&nbsp;
<Popover :maxWidth="450">
<template #trigger>
<span class="is-clickable">
<span class="icon"> <i class="fa-solid fa-info-circle" /></span>
<span>Show Details</span>
</span>
</template>
<template #title><strong>Condition Details</strong></template>
<div v-if="cond.filter">
<span class="icon"><i class="fa-solid fa-filter" /></span> <span class="icon"><i class="fa-solid fa-filter" /></span>
<span>Condition</span> <code>{{ cond.filter }}</code>
</th>
<th width="20%">
<span class="icon"><i class="fa-solid fa-gear" /></span>
<span>Actions</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="cond in items" :key="cond.id">
<td class="is-vcentered">
<div class="is-text-overflow is-bold">
{{ cond.name }}
</div>
<div class="is-unselectable">
<span class="icon-text is-clickable" @click="toggleEnabled(cond)"
v-tooltip="'Click to ' + (cond.enabled !== false ? 'disable' : 'enable') + ' condition'">
<span class="icon">
<i class="fa-solid fa-power-off"
:class="{ 'has-text-success': cond.enabled !== false, 'has-text-danger': cond.enabled === false }" />
</span>
<span>{{ cond.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
</span>
&nbsp;
<Popover :maxWidth="450">
<template #trigger>
<span class="is-clickable">
<span class="icon"> <i class="fa-solid fa-info-circle" /></span>
<span>Show Details</span>
</span>
</template>
<template #title><strong>Condition Details</strong></template>
<div v-if="cond.filter">
<span class="icon"><i class="fa-solid fa-filter" /></span>
<code>{{ cond.filter }}</code>
</div>
<div v-if="cond.cli">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<code>{{ cond.cli }}</code>
</div>
<span v-if="cond.extras && Object.keys(cond.extras).length > 0">
<template v-for="(value, key) in cond.extras" :key="key">
<div>
<span class="icon"><i class="fa-solid fa-list" /></span>
<code>{{ key }}: {{ value }}</code>
</div>
</template>
</span>
</Popover>
<template v-if="cond.priority > 0">
&nbsp;
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
<span>Priority: {{ cond.priority }}</span>
</span>
</template>
</div>
</td>
<td class="is-vcentered is-items-center">
<div class="field is-grouped is-grouped-centered">
<div class="control">
<button class="button is-info is-small is-fullwidth" @click="exportItem(cond)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
<span v-if="!isMobile">Export</span>
</button>
</div>
<div class="control">
<button class="button is-warning is-small is-fullwidth" @click="editItem(cond)">
<span class="icon"><i class="fa-solid fa-edit" /></span>
<span v-if="!isMobile">Edit</span>
</button>
</div>
<div class="control">
<button class="button is-danger is-small is-fullwidth" @click="deleteItem(cond)">
<span class="icon"><i class="fa-solid fa-trash" /></span>
<span v-if="!isMobile">Delete</span>
</button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<template v-else>
<div class="column is-6" v-for="cond in items" :key="cond.id">
<div class="card is-flex is-full-height is-flex-direction-column">
<header class="card-header">
<div class="card-header-title is-text-overflow is-block" v-text="cond.name" />
<div class="card-header-icon">
<div class="field is-grouped">
<div class="control" v-if="cond.priority > 0">
<span class="tag is-dark">
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
<span v-text="cond.priority" />
</span>
</div> </div>
<div class="control" @click="toggleEnabled(cond)">
<span class="icon" :class="cond.enabled ? 'has-text-success' : 'has-text-danger'" <div v-if="cond.cli">
v-tooltip="`Condition is ${cond.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`"> <span class="icon"><i class="fa-solid fa-terminal" /></span>
<i class="fa-solid fa-power-off" /> <code>{{ cond.cli }}</code>
</span>
</div>
<div class="control">
<a class="has-text-info" v-tooltip="'Export item'" @click.prevent="exportItem(cond)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</a>
</div> </div>
<span v-if="cond.extras && Object.keys(cond.extras).length > 0">
<template v-for="(value, key) in cond.extras" :key="key">
<div>
<span class="icon"><i class="fa-solid fa-list" /></span>
<code>{{ key }}: {{ value }}</code>
</div>
</template>
</span>
</Popover>
<template v-if="cond.priority > 0">
&nbsp;
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
<span>Priority: {{ cond.priority }}</span>
</span>
</template>
</div>
</td>
<td class="is-vcentered is-items-center">
<div class="field is-grouped is-grouped-centered">
<div class="control">
<button class="button is-info is-small is-fullwidth" @click="exportItem(cond)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
<span v-if="!isMobile">Export</span>
</button>
</div>
<div class="control">
<button class="button is-warning is-small is-fullwidth" @click="editItem(cond)">
<span class="icon"><i class="fa-solid fa-edit" /></span>
<span v-if="!isMobile">Edit</span>
</button>
</div>
<div class="control">
<button class="button is-danger is-small is-fullwidth" @click="deleteItem(cond)">
<span class="icon"><i class="fa-solid fa-trash" /></span>
<span v-if="!isMobile">Delete</span>
</button>
</div> </div>
</div> </div>
</header> </td>
<div class="card-content is-flex-grow-1"> </tr>
<div class="content"> </tbody>
<p class="is-text-overflow"> </table>
<span class="icon"><i class="fa-solid fa-filter" /></span> </div>
<span v-text="cond.filter" /> </div>
</p> <template v-else>
<p class="is-text-overflow" v-if="cond.cli"> <div class="column is-6" v-for="cond in filteredItems" :key="cond.id">
<span class="icon"><i class="fa-solid fa-terminal" /></span> <div class="card is-flex is-full-height is-flex-direction-column">
<span>{{ cond.cli }}</span> <header class="card-header">
</p> <div class="card-header-title is-text-overflow is-block" v-text="cond.name" />
<p class="is-text-overflow" v-if="cond.extras && Object.keys(cond.extras).length > 0"> <div class="card-header-icon">
<span class="icon"><i class="fa-solid fa-list" /></span> <div class="field is-grouped">
<span>Extras: <div class="control" v-if="cond.priority > 0">
<span v-for="(value, key) in cond.extras" :key="key" class="tag is-info mr-2"> <span class="tag is-dark">
<b>{{ key }}</b>: {{ value }} <span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
</span> <span v-text="cond.priority" />
</span> </span>
</p>
<p class="is-clickable" :class="{ 'is-text-overflow': !isExpanded(cond.id, 'description') }"
v-if="cond.description" @click="toggleExpand(cond.id, 'description')">
<span class="icon"><i class="fa-solid fa-comment" /></span>
<span>{{ cond.description }}</span>
</p>
</div> </div>
</div> <div class="control" @click="toggleEnabled(cond)">
<div class="card-footer mt-auto"> <span class="icon" :class="cond.enabled ? 'has-text-success' : 'has-text-danger'"
<div class="card-footer-item"> v-tooltip="`Condition is ${cond.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`">
<button class="button is-warning is-fullwidth" @click="editItem(cond)"> <i class="fa-solid fa-power-off" />
<span class="icon"><i class="fa-solid fa-edit" /></span> </span>
<span>Edit</span>
</button>
</div> </div>
<div class="card-footer-item"> <div class="control">
<button class="button is-danger is-fullwidth" @click="deleteItem(cond)"> <a class="has-text-info" v-tooltip="'Export item'" @click.prevent="exportItem(cond)">
<span class="icon"><i class="fa-solid fa-trash" /></span> <span class="icon"><i class="fa-solid fa-file-export" /></span>
<span>Delete</span> </a>
</button>
</div> </div>
</div> </div>
</div> </div>
</header>
<div class="card-content is-flex-grow-1">
<div class="content">
<p class="is-text-overflow">
<span class="icon"><i class="fa-solid fa-filter" /></span>
<span v-text="cond.filter" />
</p>
<p class="is-text-overflow" v-if="cond.cli">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>{{ cond.cli }}</span>
</p>
<p class="is-text-overflow" v-if="cond.extras && Object.keys(cond.extras).length > 0">
<span class="icon"><i class="fa-solid fa-list" /></span>
<span>Extras:
<span v-for="(value, key) in cond.extras" :key="key" class="tag is-info mr-2">
<b>{{ key }}</b>: {{ value }}
</span>
</span>
</p>
<p class="is-clickable" :class="{ 'is-text-overflow': !isExpanded(cond.id, 'description') }"
v-if="cond.description" @click="toggleExpand(cond.id, 'description')">
<span class="icon"><i class="fa-solid fa-comment" /></span>
<span>{{ cond.description }}</span>
</p>
</div>
</div> </div>
</template> <div class="card-footer mt-auto">
<div class="card-footer-item">
<button class="button is-warning is-fullwidth" @click="editItem(cond)">
<span class="icon"><i class="fa-solid fa-edit" /></span>
<span>Edit</span>
</button>
</div>
<div class="card-footer-item">
<button class="button is-danger is-fullwidth" @click="deleteItem(cond)">
<span class="icon"><i class="fa-solid fa-trash" /></span>
<span>Delete</span>
</button>
</div>
</div>
</div>
</div> </div>
<Message title="No items" message="There are no custom conditions defined." </template>
class="is-background-warning-80 has-text-dark" icon="fas fa-exclamation-circle" </div>
v-if="!items || items.length < 1" />
<div class="columns is-multiline" v-if="!toggleForm && (isLoading || !filteredItems || filteredItems.length < 1)">
<div class="column is-12">
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
Loading data. Please wait...
</Message>
<Message title="No Results" class="is-warning" icon="fas fa-search" v-else-if="query" :useClose="true"
@close="query = ''">
<p>No results found for the query: <code>{{ query }}</code>.</p>
<p>Please try a different search term.</p>
</Message>
<Message v-else title="No items" class="is-warning" icon="fas fa-exclamation-circle">
There are no custom defined conditions yet. Click the <span class="icon"><i class="fas fa-add" /></span>
<strong>New Condition</strong> button to add your first condition.
</Message>
</div> </div>
</div> </div>
<div class="column is-12" v-if="items && items.length > 0 && !toggleForm">
<div class="message is-info"> <div class="columns is-multiline" v-if="filteredItems && filteredItems.length > 0 && !toggleForm">
<div class="message-body content pl-0"> <div class="column is-12">
<Message class="is-info" :body_class="'pl-0'">
<ul> <ul>
<li>Filtering is based on yt-dlps <code>--match-filter</code> logic. Any expression that works with yt-dlp <li>Filtering is based on yt-dlps <code>--match-filter</code> logic. Any expression that works with
will also work here, including the same boolean operators. We added extended support for the yt-dlp will also work here, including the same boolean operators. We added extended support for the
<code>OR</code> ( <code>||</code> ) operator, which yt-dlp does not natively support. This allows you to combine <code>OR</code> ( <code>||</code> ) operator, which yt-dlp does not natively support. This allows you to
multiple conditions more flexibly. combine multiple conditions more flexibly.
</li> </li>
<li> <li>
The primary use case for this feature is to apply custom cli arguments to specific returned info. The primary use case for this feature is to apply custom cli arguments to specific returned info.
@ -254,7 +277,7 @@
filter matches or not. filter matches or not.
</li> </li>
</ul> </ul>
</div> </Message>
</div> </div>
</div> </div>
</div> </div>
@ -280,9 +303,18 @@ const toggleForm = ref(false)
const isLoading = ref(false) const isLoading = ref(false)
const initialLoad = ref(true) const initialLoad = ref(true)
const addInProgress = ref(false) const addInProgress = ref(false)
const query = ref<string>('')
const toggleFilter = ref(false)
const remove_keys = ['raw', 'toggle_description'] const remove_keys = ['raw', 'toggle_description']
const expandedItems = ref<Record<string, Set<string>>>({}) const expandedItems = ref<Record<string, Set<string>>>({})
const filteredItems = computed<ConditionItemWithUI[]>(() => {
const q = query.value?.toLowerCase();
if (!q) return items.value;
return items.value.filter((item: ConditionItemWithUI) => deepIncludes(item, q, new WeakSet()));
});
const toggleExpand = (itemId: string | undefined, field: string) => { const toggleExpand = (itemId: string | undefined, field: string) => {
if (!itemId) return if (!itemId) return
@ -309,6 +341,12 @@ watch(() => socket.isConnected, async () => {
} }
}) })
watch(toggleFilter, (val) => {
if (!val) {
query.value = ''
}
})
const reloadContent = async (fromMounted = false): Promise<void> => { const reloadContent = async (fromMounted = false): Promise<void> => {
try { try {

View file

@ -85,7 +85,7 @@
</p> </p>
</header> </header>
<div v-show="!isHistoryCollapsed" class="card-content p-2"> <div v-show="!isHistoryCollapsed" class="card-content p-2">
<Message :newStyle="true" class="is-info" v-if="commandHistory.length < 1"> <Message class="is-info" v-if="commandHistory.length < 1">
Commands history is empty. Commands history is empty.
</Message> </Message>
<div class="table-container" v-if="commandHistory.length > 0"> <div class="table-container" v-if="commandHistory.length > 0">

View file

@ -16,6 +16,19 @@
</span> </span>
<div class="is-pulled-right" v-if="!toggleForm"> <div class="is-pulled-right" v-if="!toggleForm">
<div class="field is-grouped"> <div class="field is-grouped">
<p class="control has-icons-left" v-if="toggleFilter && notifications && notifications.length > 0">
<input type="search" v-model.lazy="query" class="input" id="filter"
placeholder="Filter displayed content">
<span class="icon is-left"><i class="fas fa-filter" /></span>
</p>
<p class="control" v-if="notifications && notifications.length > 0">
<button class="button is-danger is-light" @click="toggleFilter = !toggleFilter">
<span class="icon"><i class="fas fa-filter" /></span>
<span v-if="!isMobile">Filter</span>
</button>
</p>
<p class="control"> <p class="control">
<button class="button is-primary" @click="resetForm(false); toggleForm = true" <button class="button is-primary" @click="resetForm(false); toggleForm = true"
v-tooltip="'Add new notification target.'"> v-tooltip="'Add new notification target.'">
@ -25,7 +38,7 @@
</p> </p>
<p class="control" v-if="notifications.length > 0"> <p class="control" v-if="notifications.length > 0">
<button class="button is-warning" @click="sendTest" v-tooltip="'Send test notification.'" <button class="button is-warning" @click="sendTest" v-tooltip="'Send test notification.'"
:class="{ 'is-loading': isLoading }" :disabled="isLoading"> :class="{ 'is-loading': sendingTest }" :disabled="!notifications.length || sendingTest">
<span class="icon"><i class="fas fa-paper-plane" /></span> <span class="icon"><i class="fas fa-paper-plane" /></span>
<span v-if="!isMobile">Send Test</span> <span v-if="!isMobile">Send Test</span>
</button> </button>
@ -62,9 +75,11 @@
<NotificationForm :addInProgress="addInProgress" :reference="targetRef as string" :item="target" <NotificationForm :addInProgress="addInProgress" :reference="targetRef as string" :item="target"
@cancel="resetForm(true);" @submit="updateItem" :allowedEvents="allowedEvents" /> @cancel="resetForm(true);" @submit="updateItem" :allowedEvents="allowedEvents" />
</div> </div>
</div>
<div class="column is-12" v-if="!toggleForm && notifications && notifications.length > 0"> <div class="columns is-multiline" v-if="!isLoading && !toggleForm && filteredTargets && filteredTargets.length > 0">
<template v-if="'list' === display_style"> <template v-if="'list' === display_style">
<div class="column is-12">
<div class="table-container"> <div class="table-container">
<table class="table is-striped is-hoverable is-fullwidth is-bordered" <table class="table is-striped is-hoverable is-fullwidth is-bordered"
style="min-width: 850px; table-layout: fixed;"> style="min-width: 850px; table-layout: fixed;">
@ -81,7 +96,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="item in notifications" :key="item.id"> <tr v-for="item in filteredTargets" :key="item.id">
<td class="is-text-overflow is-vcentered"> <td class="is-text-overflow is-vcentered">
<div class="is-bold"> <div class="is-bold">
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @ {{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
@ -133,89 +148,99 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</template> </div>
<template v-else> </template>
<div class="columns is-multiline" v-if="notifications && notifications.length > 0"> <template v-else>
<div class="column is-6" v-for="item in notifications" :key="item.id"> <div class="column is-6 is-12-mobile" v-for="item in filteredTargets" :key="item.id">
<div class="card is-flex is-full-height is-flex-direction-column"> <div class="card is-flex is-full-height is-flex-direction-column">
<header class="card-header"> <header class="card-header">
<div class="card-header-title is-text-overflow is-block"> <div class="card-header-title is-text-overflow is-block">
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @ {{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink> <NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink>
</div>
<div class="card-header-icon">
<div class="field is-grouped">
<div class="control" @click="toggleEnabled(item)">
<span class="icon" :class="item.enabled ? 'has-text-success' : 'has-text-danger'"
v-tooltip="`Notification is ${item.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`">
<i class="fa-solid fa-power-off" />
</span>
</div> </div>
<div class="card-header-icon"> <div class="control">
<div class="field is-grouped"> <a class="has-text-info" v-tooltip="'Export target.'" @click.prevent="exportItem(item)">
<div class="control" @click="toggleEnabled(item)"> <span class="icon"><i class="fa-solid fa-file-export" /></span>
<span class="icon" :class="item.enabled ? 'has-text-success' : 'has-text-danger'" </a>
v-tooltip="`Notification is ${item.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`">
<i class="fa-solid fa-power-off" />
</span>
</div>
<div class="control">
<a class="has-text-info" v-tooltip="'Export target.'" @click.prevent="exportItem(item)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</a>
</div>
<div class="control">
<button @click="item.raw = !item.raw">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-arrow-down': !item?.raw, 'fa-arrow-up': item?.raw }" /></span>
</button>
</div>
</div>
</div> </div>
</header> <div class="control">
<div class="card-content is-flex-grow-1"> <button @click="item.raw = !item.raw">
<div class="content"> <span class="icon"><i class="fa-solid"
<p> :class="{ 'fa-arrow-down': !item?.raw, 'fa-arrow-up': item?.raw }" /></span>
<span class="icon"><i class="fa-solid fa-list-ul" /></span>
<span>On: {{ join_events(item.on) }}</span>
</p>
<p>
<span class="icon"><i class="fa-solid fa-sliders" /></span>
<span>Presets: {{ join_presets(item.presets) }}</span>
</p>
<p v-if="item.request?.headers && item.request.headers.length > 0">
<span class="icon"><i class="fa-solid fa-heading" /></span>
<span>Headers: {{item.request.headers.map(h => h.key).join(', ')}}</span>
</p>
</div>
</div>
<div class="card-content" v-if="item?.raw">
<div class="content">
<pre><code>{{ JSON.stringify(cleanObject(item, ['raw']), null, 2) }}</code></pre>
</div>
</div>
<div class="card-footer mt-auto">
<div class="card-footer-item">
<button class="button is-warning is-fullwidth" @click="editItem(item);">
<span class="icon"><i class="fa-solid fa-edit" /></span>
<span>Edit</span>
</button>
</div>
<div class="card-footer-item">
<button class="button is-danger is-fullwidth" @click="deleteItem(item)">
<span class="icon"><i class="fa-solid fa-trash" /></span>
<span>Delete</span>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</header>
<div class="card-content is-flex-grow-1">
<div class="content">
<p>
<span class="icon"><i class="fa-solid fa-list-ul" /></span>
<span>On: {{ join_events(item.on) }}</span>
</p>
<p>
<span class="icon"><i class="fa-solid fa-sliders" /></span>
<span>Presets: {{ join_presets(item.presets) }}</span>
</p>
<p v-if="item.request?.headers && item.request.headers.length > 0">
<span class="icon"><i class="fa-solid fa-heading" /></span>
<span>Headers: {{item.request.headers.map(h => h.key).join(', ')}}</span>
</p>
</div>
</div>
<div class="card-content" v-if="item?.raw">
<div class="content">
<pre><code>{{ JSON.stringify(cleanObject(item, ['raw']), null, 2) }}</code></pre>
</div>
</div>
<div class="card-footer mt-auto">
<div class="card-footer-item">
<button class="button is-warning is-fullwidth" @click="editItem(item);">
<span class="icon"><i class="fa-solid fa-edit" /></span>
<span>Edit</span>
</button>
</div>
<div class="card-footer-item">
<button class="button is-danger is-fullwidth" @click="deleteItem(item)">
<span class="icon"><i class="fa-solid fa-trash" /></span>
<span>Delete</span>
</button>
</div>
</div> </div>
</div> </div>
</template> </div>
</div> </template>
</div>
<div class="column is-12" v-if="!toggleForm && (!notifications || notifications.length < 1)"> <div class="columns is-multiline"
<Message title="No Endpoints" class="is-background-warning-80 has-text-dark" icon="fas fa-exclamation-circle"> v-if="!toggleForm && (isLoading || !filteredTargets || filteredTargets.length < 1)">
There are no notifications endpoints configured to receive web notifications. <div class="column is-12">
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
Loading data. Please wait...
</Message>
<Message title="No Results" class="is-warning" icon="fas fa-search" v-else-if="query" :useClose="true"
@close="query = ''">
<p>No results found for the query: <code>{{ query }}</code>.</p>
<p>Please try a different search term.</p>
</Message>
<Message v-else title="No targets" class="is-warning" icon="fas fa-exclamation-circle">
No notification targets found. Click on the <span class="icon"><i class="fas fa-add" /></span> <strong>New
Notification</strong> button to add your first notification target.
</Message> </Message>
</div> </div>
</div> </div>
<div class="column is-12" v-if="notifications && notifications.length > 0 && !toggleForm"> <div class="columns is-multiline" v-if="!toggleForm && filteredTargets && filteredTargets.length > 0">
<div class="message is-info"> <div class="column is-12">
<div class="message-body content pl-0"> <Message class="is-info" :body_class="'pl-0'">
<ul> <ul>
<li> <li>
When you export notification target, We remove <code>Authorization</code> header key by default, When you export notification target, We remove <code>Authorization</code> header key by default,
@ -227,16 +252,19 @@
<code>...&data_key=json_string</code>, only the <code>data</code> field will be JSON encoded. <code>...&data_key=json_string</code>, only the <code>data</code> field will be JSON encoded.
The other keys <code>id</code>, <code>event</code> and <code>created_at</code> will be sent as they are. The other keys <code>id</code>, <code>event</code> and <code>created_at</code> will be sent as they are.
</li> </li>
<li>We also send two special headers <code>X-Event-ID</code> and <code>X-Event</code> with the request.</li> <li>We also send two special headers <code>X-Event-ID</code> and <code>X-Event</code> with the request.
</li>
<li> <li>
If you have selected specific presets or events, this will take priority, For example, if you limited the If you have selected specific presets or events, this will take priority, For example, if you limited
target to <code>default</code> preset and selected <code>ALL</code> events, only events that reference the the
target to <code>default</code> preset and selected <code>ALL</code> events, only events that reference
the
<code>default</code> preset will be sent to that target. Like wise, if you have limited both events and <code>default</code> preset will be sent to that target. Like wise, if you have limited both events and
presets, then ONLY events that satisfy both conditions will be sent to that target. Only the presets, then ONLY events that satisfy both conditions will be sent to that target. Only the
<code>test</code> events can bypass these conditions. <code>test</code> events can bypass these conditions.
</li> </li>
</ul> </ul>
</div> </Message>
</div> </div>
</div> </div>
</div> </div>
@ -272,6 +300,22 @@ const toggleForm = ref(false)
const isLoading = ref(false) const isLoading = ref(false)
const initialLoad = ref(true) const initialLoad = ref(true)
const addInProgress = ref(false) const addInProgress = ref(false)
const sendingTest = ref(false)
const query = ref<string>('')
const toggleFilter = ref(false)
const filteredTargets = computed<notificationItemWithUI[]>(() => {
const q = query.value?.toLowerCase();
if (!q) return notifications.value;
return notifications.value.filter((item: notificationItemWithUI) => deepIncludes(item, q, new WeakSet()));
});
watch(toggleFilter, (val) => {
if (!val) {
query.value = ''
}
})
watch(() => socket.isConnected, async () => { watch(() => socket.isConnected, async () => {
if (socket.isConnected && initialLoad.value) { if (socket.isConnected && initialLoad.value) {
@ -413,7 +457,7 @@ const sendTest = async () => {
} }
try { try {
isLoading.value = true sendingTest.value = true
const response = await request('/api/notifications/test', { method: 'POST' }) const response = await request('/api/notifications/test', { method: 'POST' })
if (200 !== response.status) { if (200 !== response.status) {
@ -427,7 +471,7 @@ const sendTest = async () => {
console.error(e) console.error(e)
toast.error(`Failed to send test notification. ${e.message}`) toast.error(`Failed to send test notification. ${e.message}`)
} finally { } finally {
isLoading.value = false sendingTest.value = false
} }
} }

View file

@ -16,6 +16,19 @@
</span> </span>
<div class="is-pulled-right" v-if="!toggleForm"> <div class="is-pulled-right" v-if="!toggleForm">
<div class="field is-grouped"> <div class="field is-grouped">
<p class="control has-icons-left" v-if="toggleFilter && presets && presets.length > 0">
<input type="search" v-model.lazy="query" class="input" id="filter"
placeholder="Filter displayed content">
<span class="icon is-left"><i class="fas fa-filter" /></span>
</p>
<p class="control" v-if="presets && presets.length > 0">
<button class="button is-danger is-light" @click="toggleFilter = !toggleFilter">
<span class="icon"><i class="fas fa-filter" /></span>
<span v-if="!isMobile">Filter</span>
</button>
</p>
<p class="control"> <p class="control">
<button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm;" <button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm;"
v-tooltip.bottom="'Toggle add form'"> v-tooltip.bottom="'Toggle add form'">
@ -35,6 +48,7 @@
</span> </span>
</button> </button>
</p> </p>
<p class="control"> <p class="control">
<button class="button is-info" @click="reloadContent()" :class="{ 'is-loading': isLoading }" <button class="button is-info" @click="reloadContent()" :class="{ 'is-loading': isLoading }"
:disabled="isLoading" v-if="presets && presets.length > 0"> :disabled="isLoading" v-if="presets && presets.length > 0">
@ -72,7 +86,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="item in presetsNoDefault" :key="item.id"> <tr v-for="item in filteredPresets" :key="item.id">
<td class="is-text-overflow is-vcentered"> <td class="is-text-overflow is-vcentered">
<div class="is-text-overflow is-bold"> <div class="is-text-overflow is-bold">
{{ item.name }} {{ item.name }}
@ -122,7 +136,7 @@
</template> </template>
<template v-else> <template v-else>
<div class="column is-6" v-for="item in presetsNoDefault" :key="item.id"> <div class="column is-6" v-for="item in filteredPresets" :key="item.id">
<div class="card is-flex is-full-height is-flex-direction-column"> <div class="card is-flex is-full-height is-flex-direction-column">
<header class="card-header"> <header class="card-header">
<div class="card-header-title is-block is-clickable" <div class="card-header-title is-block is-clickable"
@ -204,24 +218,29 @@
</template> </template>
</div> </div>
<div class="columns is-multiline" v-if="!presets || presets.length < 1"> <div class="columns is-multiline" v-if="!toggleForm && (!filteredPresets || filteredPresets.length < 1)">
<div class="column is-12"> <div class="column is-12">
<Message message_class="has-background-info-90 has-text-dark" title="Loading" icon="fas fa-spinner fa-spin" <Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
message="Loading data. Please wait..." v-if="isLoading" /> Loading data. Please wait...
<Message title="No presets" message="There are no custom defined presets." </Message>
class="is-background-warning-80 has-text-dark" icon="fas fa-exclamation-circle" v-else /> <Message title="No Results" class="is-warning" icon="fas fa-search" v-else-if="query"
:useClose="true" @close="query = ''">
<p>No results found for the query: <code>{{ query }}</code>.</p>
<p>Please try a different search term.</p>
</Message>
<Message v-else title="No presets" class="is-warning" icon="fas fa-exclamation-circle">
There are no custom defined presets.
</Message>
</div> </div>
</div> </div>
<div class="columns is-multiline" v-if="presets && presets.length > 0"> <div class="columns is-multiline" v-if="!query && presets && presets.length > 0">
<div class="column is-12"> <div class="column is-12">
<div class="message is-info"> <Message class="is-info">
<p class="message-body"> <span class="icon"><i class="fas fa-info-circle" /></span>
<span class="icon"> <i class="fas fa-info-circle" /></span> When you <b>export</b> preset, it doesn't include the <code>cookies</code> field contents for security
When you export preset, it doesn't include the <strong>cookies</strong> field contents for security reasons.
reasons. </Message>
</p>
</div>
</div> </div>
</div> </div>
</template> </template>
@ -243,6 +262,9 @@ const box = useConfirm()
const display_style = useStorage<string>('preset_display_style', 'cards') const display_style = useStorage<string>('preset_display_style', 'cards')
const isMobile = useMediaQuery({ maxWidth: 1024 }) const isMobile = useMediaQuery({ maxWidth: 1024 })
const query = ref<string>('')
const toggleFilter = ref(false)
const presets = ref<PresetWithUI[]>([]) const presets = ref<PresetWithUI[]>([])
const preset = ref<Partial<Preset>>({}) const preset = ref<Partial<Preset>>({})
const presetRef = ref<string | null>('') const presetRef = ref<string | null>('')
@ -255,6 +277,12 @@ const expandedItems = ref<Record<string, Set<string>>>({})
const presetsNoDefault = computed(() => presets.value.filter((t) => !t.default)) const presetsNoDefault = computed(() => presets.value.filter((t) => !t.default))
const filteredPresets = computed<PresetWithUI[]>(() => {
const q = query.value?.toLowerCase();
if (!q) return presetsNoDefault.value;
return presetsNoDefault.value.filter((item: PresetWithUI) => deepIncludes(item, q, new WeakSet()));
});
const toggleExpand = (itemId: string | undefined, field: string) => { const toggleExpand = (itemId: string | undefined, field: string) => {
if (!itemId) return if (!itemId) return
@ -281,6 +309,12 @@ watch(() => socket.isConnected, async () => {
} }
}) })
watch(toggleFilter, (val) => {
if (!val) {
query.value = ''
}
})
const reloadContent = async (fromMounted = false) => { const reloadContent = async (fromMounted = false) => {
try { try {
isLoading.value = true isLoading.value = true

View file

@ -80,7 +80,8 @@
</td> </td>
<td class="is-vcentered has-text-centered">{{ definition.priority }}</td> <td class="is-vcentered has-text-centered">{{ definition.priority }}</td>
<td class="is-vcentered has-text-centered"> <td class="is-vcentered has-text-centered">
<span class="has-tooltip" :date-datetime="moment.unix(definition.updated_at).format('YYYY-M-DD H:mm Z')" <span class="has-tooltip"
:date-datetime="moment.unix(definition.updated_at).format('YYYY-M-DD H:mm Z')"
v-tooltip="moment.unix(definition.updated_at).format('YYYY-M-DD H:mm Z')" v-tooltip="moment.unix(definition.updated_at).format('YYYY-M-DD H:mm Z')"
v-rtime="definition.updated_at" /> v-rtime="definition.updated_at" />
</td> </td>
@ -170,11 +171,14 @@
<div class="columns is-multiline" v-if="!definitions.length"> <div class="columns is-multiline" v-if="!definitions.length">
<div class="column is-12"> <div class="column is-12">
<Message message_class="has-background-info-90 has-text-dark" title="Loading" icon="fas fa-spinner fa-spin" <Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
message="Loading data. Please wait..." v-if="isLoading" /> Loading data. Please wait...
<Message title="No definitions" message="No task definitions are configured yet. Create one to get started." </Message>
class="is-background-warning-80 has-text-dark" icon="fas fa-exclamation-circle" <Message v-if="!isLoading && !isEditorOpen" title="No definitions" class="is-warning"
v-if="!isLoading && !isEditorOpen" /> icon="fas fa-exclamation-circle">
There are no task definitions. Click the <span class="icon"><i class="fas fa-add" /></span> <strong>New
Definition</strong> button to create your first task definition.
</Message>
</div> </div>
</div> </div>

View file

@ -407,29 +407,26 @@
</div> </div>
</div> </div>
<div class="columns is-multiline" v-if="!filteredTasks || filteredTasks.length < 1"> <div class="columns is-multiline" v-if="!toggleForm && (!filteredTasks || filteredTasks.length < 1)">
<div class="column is-12"> <div class="column is-12">
<Message class="is-info" title="Loading" icon="fas fa-spinner fa-spin" v-if="isLoading">
<Message :newStyle="true" message_class="is-info" v-if="isLoading"> Loading data. Please wait...
<p><span class="icon"><i class="fas fa-spinner fa-spin" /></span> Loading data. Please wait...</p>
</Message> </Message>
<Message title="No Results" message_class="is-warning" icon="fas fa-search" v-else-if="query" :useClose="true" <Message title="No Results" class="is-warning" icon="fas fa-search" v-else-if="query"
@close="query = ''" :newStyle="true"> :useClose="true" @close="query = ''">
<p>No results found for the query: <strong>{{ query }}</strong>.</p> <p>No results found for the query: <code>{{ query }}</code>.</p>
<p>Please try a different search term.</p> <p>Please try a different search term.</p>
</Message> </Message>
<Message message_class="is-warning" :newStyle="true" v-else> <Message class="is-warning" icon="fa-solid fa-info-circle" title="No tasks." v-else>
<p> There are no tasks defined yet. Click the <span class="icon"><i class="fas fa-add" /></span> <strong>New
<span class="icon"><i class="fa-solid fa-info-circle" /></span> Task</strong> button to create your first automated download task.
<span>No tasks are defined.</span>
</p>
</Message> </Message>
</div> </div>
</div> </div>
<div class="columns is-multiline" v-if="!toggleForm && tasks && tasks.length > 0"> <div class="columns is-multiline" v-if="!toggleForm && tasks && tasks.length > 0">
<div class="column is-12"> <div class="column is-12">
<Message :newStyle="true" message_class="is-info"> <Message class="is-info">
<ul> <ul>
<li class="has-text-danger"> <li class="has-text-danger">
<span class="icon"> <span class="icon">
@ -611,9 +608,7 @@ const filteredTasks = computed<task_item[]>(() => {
const q = query.value?.toLowerCase(); const q = query.value?.toLowerCase();
if (!q) return tasks.value; if (!q) return tasks.value;
return tasks.value.filter( return tasks.value.filter(task => deepIncludes(task, q, new WeakSet()));
task => Object.values(task).some(value => typeof value === 'string' && value.toLowerCase().includes(q))
);
}); });
const reloadContent = async (fromMounted: boolean = false) => { const reloadContent = async (fromMounted: boolean = false) => {