refactor: minor fixes to overall design.

This commit is contained in:
arabcoders 2026-04-14 21:22:24 +03:00
parent dbff4c6798
commit 360d2f3df8
14 changed files with 1776 additions and 801 deletions

View file

@ -40,7 +40,8 @@
<UButton
type="button"
color="primary"
color="neutral"
variant="outline"
icon="i-lucide-import"
size="lg"
class="justify-center sm:min-w-28"
@ -248,7 +249,7 @@
<div class="flex items-end">
<UButton
type="button"
color="error"
color="neutral"
variant="outline"
icon="i-lucide-trash"
:disabled="addInProgress"
@ -306,7 +307,8 @@
<div class="flex items-end">
<UButton
type="button"
color="primary"
color="neutral"
variant="outline"
icon="i-lucide-plus"
class="justify-center"
:disabled="addInProgress || !newExtraKey || !newExtraValue"

View file

@ -40,7 +40,8 @@
<UButton
type="button"
color="primary"
color="neutral"
variant="outline"
icon="i-lucide-import"
size="lg"
class="justify-center sm:min-w-28"

View file

@ -40,7 +40,8 @@
<UButton
type="button"
color="primary"
color="neutral"
variant="outline"
icon="i-lucide-import"
size="lg"
class="justify-center sm:min-w-28"
@ -382,7 +383,7 @@
<div class="flex items-end">
<UButton
type="button"
color="error"
color="neutral"
variant="outline"
icon="i-lucide-trash"
:disabled="addInProgress"

View file

@ -71,7 +71,8 @@
<UButton
type="button"
color="primary"
color="neutral"
variant="outline"
icon="i-lucide-import"
size="lg"
:disabled="!importString"

View file

@ -1,83 +1,17 @@
<template>
<div class="space-y-6">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-wrap items-center gap-2">
<UButton
type="button"
color="neutral"
variant="ghost"
size="sm"
:icon="showImport ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
:disabled="isBusy"
@click="showImport = !showImport"
>
{{ showImport ? 'Hide import' : 'Show import' }}
</UButton>
<div class="inline-flex rounded-md border border-default bg-muted/20 p-1">
<UButton
type="button"
size="sm"
icon="i-lucide-sliders-horizontal"
:color="mode === 'gui' ? 'primary' : 'neutral'"
:variant="mode === 'gui' ? 'solid' : 'ghost'"
:disabled="!guiSupported || isBusy"
@click="switchMode('gui')"
>
GUI
</UButton>
<UButton
type="button"
size="sm"
icon="i-lucide-code"
:color="mode === 'advanced' ? 'primary' : 'neutral'"
:variant="mode === 'advanced' ? 'solid' : 'ghost'"
:disabled="isBusy"
@click="switchMode('advanced')"
>
Advanced
</UButton>
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<UButton
v-if="mode === 'advanced'"
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-wand-sparkles"
:disabled="isBusy"
@click="beautify"
>
Format
</UButton>
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-x"
:disabled="submitting"
@click="cancel"
>
Cancel
</UButton>
<UButton
type="button"
color="primary"
size="sm"
icon="i-lucide-save"
:loading="submitting"
:disabled="isBusy"
@click="submit"
>
Save
</UButton>
</div>
<div class="flex flex-wrap items-center gap-2">
<UButton
type="button"
color="neutral"
variant="ghost"
size="sm"
:icon="showImport ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
:disabled="isBusy"
@click="showImport = !showImport"
>
{{ showImport ? 'Hide import' : 'Show import' }}
</UButton>
</div>
<div
@ -132,7 +66,8 @@
<UButton
type="button"
color="primary"
color="neutral"
variant="outline"
icon="i-lucide-import"
class="justify-center sm:min-w-28"
:disabled="isBusy || !importString.trim()"
@ -219,7 +154,7 @@
<UFormField
class="md:col-span-3"
:ui="fieldUi"
description="Disabled definitions won't match tasks."
description="Whether this definition is active or not."
>
<template #label>
<div class="flex flex-wrap items-center gap-2">
@ -364,7 +299,7 @@
</div>
<div class="grid gap-4 md:grid-cols-12">
<UFormField class="md:col-span-4" :ui="fieldUi">
<UFormField class="md:col-span-4" :ui="fieldUi" description="Selector type">
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-shapes" class="size-4 text-toned" />
@ -383,7 +318,7 @@
/>
</UFormField>
<UFormField class="md:col-span-8" :ui="fieldUi">
<UFormField class="md:col-span-8" :ui="fieldUi" description="Match expression">
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-crosshair" class="size-4 text-toned" />
@ -415,7 +350,8 @@
<UButton
type="button"
color="primary"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-plus"
:disabled="isBusy"
@ -517,7 +453,7 @@
<td class="px-3 py-3 text-right">
<UButton
type="button"
color="error"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-trash"
@ -571,6 +507,77 @@
:description="errorMessage"
/>
</template>
<div
class="flex flex-col gap-3 border-t border-default pt-5 sm:flex-row sm:items-center sm:justify-between"
>
<div class="inline-flex self-start rounded-md border border-default bg-muted/20 p-1">
<UButton
type="button"
size="sm"
icon="i-lucide-sliders-horizontal"
color="neutral"
:variant="mode === 'gui' ? 'soft' : 'ghost'"
:disabled="!guiSupported || isBusy"
@click="switchMode('gui')"
>
GUI
</UButton>
<UButton
type="button"
size="sm"
icon="i-lucide-code"
color="neutral"
:variant="mode === 'advanced' ? 'soft' : 'ghost'"
:disabled="isBusy"
@click="switchMode('advanced')"
>
Advanced
</UButton>
</div>
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
v-if="mode === 'advanced'"
type="button"
color="neutral"
variant="outline"
size="lg"
icon="i-lucide-wand-sparkles"
:disabled="isBusy"
class="justify-center"
@click="beautify"
>
Format
</UButton>
<UButton
type="button"
color="neutral"
variant="outline"
size="lg"
icon="i-lucide-x"
:disabled="submitting"
class="justify-center"
@click="cancel"
>
Cancel
</UButton>
<UButton
type="button"
color="primary"
size="lg"
icon="i-lucide-save"
:loading="submitting"
:disabled="isBusy"
class="justify-center"
@click="submit"
>
Save
</UButton>
</div>
</div>
</div>
</template>

View file

@ -17,7 +17,7 @@
<UButton
type="button"
color="warning"
color="neutral"
variant="outline"
size="sm"
:loading="convertInProgress"
@ -94,7 +94,8 @@
<UButton
type="button"
color="primary"
color="neutral"
variant="outline"
icon="i-lucide-import"
size="lg"
:disabled="!import_string"

View file

@ -66,7 +66,7 @@
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
type="button"
color="warning"
color="neutral"
variant="outline"
icon="i-lucide-rotate-ccw"
:disabled="loading"

View file

@ -0,0 +1,50 @@
export const useExpandableMeta = () => {
const expandedItems = ref<Record<string, Set<string>>>({});
const normalizeId = (itemId: string | number | undefined | null): string | null => {
if (itemId === undefined || itemId === null) {
return null;
}
return String(itemId);
};
const toggleExpand = (itemId: string | number | undefined | null, field: string): void => {
const key = normalizeId(itemId);
if (!key) {
return;
}
if (!expandedItems.value[key]) {
expandedItems.value[key] = new Set();
}
if (expandedItems.value[key]?.has(field)) {
expandedItems.value[key]?.delete(field);
return;
}
expandedItems.value[key]?.add(field);
};
const isExpanded = (itemId: string | number | undefined | null, field: string): boolean => {
const key = normalizeId(itemId);
if (!key) {
return false;
}
return expandedItems.value[key]?.has(field) ?? false;
};
const expandClass = (itemId: string | number | undefined | null, field: string): string => {
return isExpanded(itemId, field)
? 'block max-w-full whitespace-pre-wrap break-words'
: 'block max-w-full truncate';
};
return {
toggleExpand,
isExpanded,
expandClass,
};
};

View file

@ -84,6 +84,41 @@
@navigate="navigatePage"
/>
<div
v-if="!isLoading && filteredItems.length > 0"
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-default bg-default px-3 py-3"
>
<div class="flex flex-wrap items-center gap-2">
<UButton
color="neutral"
variant="outline"
size="sm"
:icon="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
@click="toggleMasterSelection"
>
{{ allSelected ? 'Unselect' : 'Select' }}
</UButton>
<UBadge v-if="selectedIds.length > 0" color="error" variant="soft" size="sm">
{{ selectedIds.length }}
</UBadge>
<UDropdownMenu :items="bulkActionGroups" :modal="false">
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-list"
trailing-icon="i-lucide-chevron-down"
>
Actions
</UButton>
</UDropdownMenu>
</div>
<div class="text-xs text-toned">{{ filteredItems.length }} displayed</div>
</div>
<div
v-if="displayStyle === 'list' && filteredItems.length > 0"
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
@ -92,6 +127,14 @@
<table class="min-w-225 w-full text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
<th class="w-12">
<button type="button" class="cursor-pointer" @click="toggleMasterSelection">
<UIcon
:name="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
class="size-4"
/>
</button>
</th>
<th class="w-full text-left">Condition</th>
<th class="w-44 whitespace-nowrap">Actions</th>
</tr>
@ -99,57 +142,51 @@
<tbody class="divide-y divide-default">
<tr v-for="cond in filteredItems" :key="cond.id" class="hover:bg-muted/20">
<td class="px-3 py-3 text-center align-middle">
<label class="inline-flex cursor-pointer items-center justify-center">
<input
v-model="selectedIds"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="cond.id"
/>
</label>
</td>
<td class="px-3 py-3 align-middle">
<div class="space-y-2">
<div class="font-semibold text-highlighted">{{ cond.name }}</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-toned">
<div class="inline-flex items-center gap-2">
<UTooltip
:text="`Click to ${cond.enabled !== false ? 'disable' : 'enable'} condition`"
>
<USwitch
:model-value="cond.enabled !== false"
:disabled="conditions.addInProgress.value"
@update:model-value="() => void toggleEnabled(cond)"
/>
</UTooltip>
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
:disabled="conditions.addInProgress.value"
@click="() => void toggleEnabled(cond)"
>
<UIcon
name="i-lucide-power"
class="size-3.5"
:class="cond.enabled !== false ? 'text-success' : 'text-error'"
/>
<span>{{ cond.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
</div>
</button>
<span v-if="cond.priority > 0" class="inline-flex items-center gap-1">
<span
v-if="cond.priority > 0"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Priority: {{ cond.priority }}</span>
</span>
</div>
<div class="space-y-1 text-xs text-toned">
<div class="flex items-start gap-2">
<UIcon name="i-lucide-filter" class="mt-0.5 size-3.5 shrink-0" />
<code class="wrap-break-word">{{ cond.filter }}</code>
</div>
<div v-if="cond.cli" class="flex items-start gap-2">
<UIcon name="i-lucide-terminal" class="mt-0.5 size-3.5 shrink-0" />
<code class="wrap-break-word">{{ cond.cli }}</code>
</div>
<div
v-for="([key, value], index) in extrasEntries(cond.extras)"
:key="`${cond.id}-${key}-${index}`"
class="flex items-start gap-2"
>
<UIcon name="i-lucide-list" class="mt-0.5 size-3.5 shrink-0" />
<code class="wrap-break-word">{{ key }}: {{ value }}</code>
</div>
</div>
</div>
</td>
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<UButton
color="info"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-file-up"
@ -159,7 +196,7 @@
</UButton>
<UButton
color="warning"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-pencil"
@ -169,7 +206,7 @@
</UButton>
<UButton
color="error"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-trash"
@ -186,135 +223,172 @@
</div>
<div v-else-if="filteredItems.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<UCard
v-for="cond in filteredItems"
:key="cond.id"
class="flex h-full flex-col border bg-default"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
>
<template #header>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1 space-y-2">
<button
type="button"
class="min-w-0 w-full text-left text-sm font-semibold text-highlighted"
@click="toggleExpand(cond.id, 'name')"
>
<span :class="expandClass(cond.id, 'name')">{{ cond.name }}</span>
</button>
<div v-for="cond in filteredItems" :key="cond.id" class="min-w-0 w-full max-w-full">
<UCard
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
>
<template #header>
<div class="flex min-w-0 items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<button
type="button"
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
@click="toggleExpand(cond.id, 'name')"
>
<span :class="['block', expandClass(cond.id, 'name')]">{{ cond.name }}</span>
</button>
</div>
</div>
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
:disabled="conditions.addInProgress.value"
@click="() => void toggleEnabled(cond)"
<div class="flex shrink-0 items-center gap-2">
<UButton
color="neutral"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="exportItem(cond)"
>
<UIcon
name="i-lucide-power"
class="size-3.5"
:class="cond.enabled !== false ? 'text-success' : 'text-error'"
<span class="hidden sm:inline">Export Condition</span>
</UButton>
<label class="inline-flex cursor-pointer items-center justify-center">
<input
v-model="selectedIds"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="cond.id"
/>
<span>{{ cond.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
</button>
<span
v-if="cond.priority > 0"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Priority {{ cond.priority }}</span>
</span>
</label>
</div>
</div>
</template>
<div class="space-y-2 text-sm text-default">
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
:disabled="conditions.addInProgress.value"
@click="() => void toggleEnabled(cond)"
>
<UIcon
name="i-lucide-power"
class="size-3.5"
:class="cond.enabled !== false ? 'text-success' : 'text-error'"
/>
<span>{{ cond.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
</button>
<span
v-if="cond.priority > 0"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Priority: {{ cond.priority }}</span>
</span>
</div>
<button
type="button"
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(cond.id, 'filter')"
>
<UIcon name="i-lucide-filter" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Filter</div>
<span :class="['block', expandClass(cond.id, 'filter')]">{{ cond.filter }}</span>
</div>
</button>
<div v-if="cond.cli || cond.description" class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
v-if="cond.cli"
type="button"
:class="[
'flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left',
!cond.description && 'sm:col-span-2',
]"
@click="toggleExpand(cond.id, 'cli')"
>
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">CLI</div>
<span :class="['block', expandClass(cond.id, 'cli')]">{{ cond.cli }}</span>
</div>
</button>
<button
v-if="cond.description"
type="button"
:class="[
'flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left',
!cond.cli && 'sm:col-span-2',
]"
@click="toggleExpand(cond.id, 'description')"
>
<UIcon
name="i-lucide-message-square-text"
class="mt-0.5 size-4 shrink-0 text-toned"
/>
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Description</div>
<span :class="['block', expandClass(cond.id, 'description')]">{{
cond.description
}}</span>
</div>
</button>
</div>
<div
v-if="extrasEntries(cond.extras).length > 0"
class="rounded-md border border-default bg-muted/20 px-3 py-2"
>
<div class="mb-2 flex items-center gap-2 text-toned">
<UIcon name="i-lucide-list" class="size-4" />
<span class="text-sm font-medium">Extras</span>
</div>
<div class="flex flex-wrap gap-2">
<UBadge
v-for="([key, value], index) in extrasEntries(cond.extras)"
:key="`${cond.id}-${key}-${index}`"
color="info"
variant="soft"
size="sm"
>
<span class="font-semibold">{{ key }}</span
>: {{ value }}
</UBadge>
</div>
</div>
</div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@click="editItem(cond)"
>
Edit
</UButton>
<UButton
color="info"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="exportItem(cond)"
/>
color="neutral"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void deleteItem(cond)"
>
Delete
</UButton>
</div>
</template>
<div class="space-y-2 text-sm text-default">
<button
type="button"
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(cond.id, 'filter')"
>
<UIcon name="i-lucide-filter" class="mt-0.5 size-4 shrink-0 text-toned" />
<span :class="expandClass(cond.id, 'filter')">{{ cond.filter }}</span>
</button>
<button
v-if="cond.cli"
type="button"
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(cond.id, 'cli')"
>
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" />
<span :class="expandClass(cond.id, 'cli')">{{ cond.cli }}</span>
</button>
<div
v-if="extrasEntries(cond.extras).length > 0"
class="rounded-md border border-default bg-muted/20 px-3 py-2"
>
<div class="mb-2 flex items-center gap-2 text-toned">
<UIcon name="i-lucide-list" class="size-4" />
<span class="text-sm font-medium">Extras</span>
</div>
<div class="flex flex-wrap gap-2">
<UBadge
v-for="([key, value], index) in extrasEntries(cond.extras)"
:key="`${cond.id}-${key}-${index}`"
color="info"
variant="soft"
size="sm"
>
<span class="font-semibold">{{ key }}</span
>: {{ value }}
</UBadge>
</div>
</div>
<button
v-if="cond.description"
type="button"
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(cond.id, 'description')"
>
<UIcon name="i-lucide-message-square-text" class="mt-0.5 size-4 shrink-0 text-toned" />
<span :class="expandClass(cond.id, 'description')">{{ cond.description }}</span>
</button>
</div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
<UButton
color="warning"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@click="editItem(cond)"
>
Edit
</UButton>
<UButton
color="error"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void deleteItem(cond)"
>
Delete
</UButton>
</div>
</UCard>
</UCard>
</div>
</div>
<UAlert
@ -402,7 +476,10 @@
</template>
<script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui';
import { useStorage } from '@vueuse/core';
import { useDialog } from '~/composables/useDialog';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import { useConfirm } from '~/composables/useConfirm';
import { useConditions } from '~/composables/useConditions';
import type { Condition } from '~/types/conditions';
@ -414,9 +491,11 @@ type ConditionItemWithUI = Condition & { raw?: boolean };
const box = useConfirm();
const isMobile = useMediaQuery({ maxWidth: 1024 });
const displayStyle = useStorage<'list' | 'grid'>('conditions_display_style', 'grid');
const { toggleExpand, expandClass } = useExpandableMeta();
const conditions = useConditions();
const route = useRoute();
const router = useRouter();
const { confirmDialog } = useDialog();
const items = conditions.conditions as Ref<ConditionItemWithUI[]>;
const paging = conditions.pagination;
@ -428,9 +507,10 @@ const editorOpen = ref(false);
const query = ref('');
const showFilter = ref(false);
const filterInput = ref<HTMLInputElement | null>(null);
const selectedIds = ref<number[]>([]);
const massDelete = ref(false);
const removeKeys = ['raw', 'toggle_description'];
const expandedItems = reactive<Record<number, Set<string>>>({});
const filteredItems = computed<ConditionItemWithUI[]>(() => {
const normalizedQuery = query.value?.toLowerCase();
@ -441,6 +521,30 @@ const filteredItems = computed<ConditionItemWithUI[]>(() => {
return items.value.filter((entry) => deepIncludes(entry, normalizedQuery, new WeakSet()));
});
const selectableConditionIds = computed(() =>
filteredItems.value.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
);
const allSelected = computed(
() =>
selectableConditionIds.value.length > 0 &&
selectableConditionIds.value.every((id) => selectedIds.value.includes(id)),
);
const hasSelected = computed(() => selectedIds.value.length > 0);
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
[
{
label: 'Remove Selected',
icon: 'i-lucide-trash',
color: 'error',
disabled: !hasSelected.value || massDelete.value,
onSelect: () => void deleteSelected(),
},
],
]);
const modalTitle = computed(() =>
itemRef.value ? `Edit - ${item.value.name}` : 'Add new condition',
);
@ -457,6 +561,17 @@ watch(showFilter, (value) => {
}
});
watch(
filteredItems,
(items) => {
const validIds = new Set(
items.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
);
selectedIds.value = selectedIds.value.filter((id) => validIds.has(id));
},
{ deep: true },
);
const syncPageQuery = async (pageNumber: number): Promise<void> => {
const totalPages = conditions.pagination.value.total_pages;
const nextQuery = { ...route.query };
@ -515,32 +630,13 @@ const toggleDisplayStyle = (): void => {
displayStyle.value = displayStyle.value === 'list' ? 'grid' : 'list';
};
const toggleExpand = (itemId: number | undefined, field: string): void => {
if (!itemId) {
const toggleMasterSelection = (): void => {
if (allSelected.value) {
selectedIds.value = [];
return;
}
if (!expandedItems[itemId]) {
expandedItems[itemId] = new Set();
}
if (expandedItems[itemId].has(field)) {
expandedItems[itemId].delete(field);
} else {
expandedItems[itemId].add(field);
}
};
const isExpanded = (itemId: number | undefined, field: string): boolean => {
if (!itemId) {
return false;
}
return expandedItems[itemId]?.has(field) ?? false;
};
const expandClass = (itemId: number | undefined, field: string): string => {
return isExpanded(itemId, field) ? 'whitespace-pre-wrap break-words' : 'truncate';
selectedIds.value = [...selectableConditionIds.value];
};
const extrasEntries = (extras?: Record<string, unknown>): Array<[string, unknown]> => {
@ -559,6 +655,50 @@ const deleteItem = async (cond: Condition): Promise<void> => {
await conditions.deleteCondition(cond.id!);
};
const deleteSelected = async (): Promise<void> => {
if (selectedIds.value.length < 1) {
return;
}
const { status } = await confirmDialog({
title: 'Delete Selected Conditions',
rawHTML:
`Delete <strong class="text-red-500">${selectedIds.value.length}</strong> condition/s?<ul>` +
selectedIds.value
.map((id) => {
const item = filteredItems.value.find((cond) => cond.id === id);
return item ? `<li>${item.id}: ${item.name}</li>` : '';
})
.join('') +
'</ul>',
confirmText: 'Delete',
confirmColor: 'error',
});
if (true !== status) {
return;
}
const itemsToDelete = filteredItems.value.filter(
(item) => item.id && selectedIds.value.includes(item.id),
);
if (itemsToDelete.length < 1) {
return;
}
massDelete.value = true;
for (const item of itemsToDelete) {
if (!item.id) {
continue;
}
await conditions.deleteCondition(item.id);
}
selectedIds.value = [];
massDelete.value = false;
};
const updateItem = async ({
reference,
item: updatedItem,

View file

@ -84,6 +84,41 @@
@navigate="navigatePage"
/>
<div
v-if="!isLoading && filteredItems.length > 0"
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-default bg-default px-3 py-3"
>
<div class="flex flex-wrap items-center gap-2">
<UButton
color="neutral"
variant="outline"
size="sm"
:icon="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
@click="toggleMasterSelection"
>
{{ allSelected ? 'Unselect' : 'Select' }}
</UButton>
<UBadge v-if="selectedIds.length > 0" color="error" variant="soft" size="sm">
{{ selectedIds.length }}
</UBadge>
<UDropdownMenu :items="bulkActionGroups" :modal="false">
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-list"
trailing-icon="i-lucide-chevron-down"
>
Actions
</UButton>
</UDropdownMenu>
</div>
<div class="text-xs text-toned">{{ filteredItems.length }} displayed</div>
</div>
<div
v-if="displayStyle === 'list' && filteredItems.length > 0"
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
@ -92,6 +127,14 @@
<table class="min-w-225 w-full text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
<th class="w-12">
<button type="button" class="cursor-pointer" @click="toggleMasterSelection">
<UIcon
:name="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
class="size-4"
/>
</button>
</th>
<th class="w-full text-left">Field</th>
<th class="w-44 whitespace-nowrap">Actions</th>
</tr>
@ -99,29 +142,42 @@
<tbody class="divide-y divide-default">
<tr v-for="field in filteredItems" :key="field.id" class="hover:bg-muted/20">
<td class="px-3 py-3 text-center align-middle">
<label class="inline-flex cursor-pointer items-center justify-center">
<input
v-model="selectedIds"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="field.id"
/>
</label>
</td>
<td class="px-3 py-3 align-middle">
<div class="space-y-2">
<div class="font-semibold text-highlighted">{{ field.name }}</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-toned">
<span class="inline-flex items-center gap-1">
<UIcon name="i-lucide-terminal" class="size-3.5" />
<span>{{ field.field }}</span>
</span>
<span class="inline-flex items-center gap-1">
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Order: {{ field.order }}</span>
</span>
<span class="inline-flex items-center gap-1">
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-shapes" class="size-3.5" />
<span>{{ field.kind }}</span>
<span>Type: {{ field.kind }}</span>
</span>
</div>
<div v-if="field.description" class="text-xs text-toned">
{{ field.description }}
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-terminal" class="size-3.5" />
<span>Option: {{ field.field }}</span>
</span>
</div>
</div>
</td>
@ -129,7 +185,7 @@
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<UButton
color="info"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-file-up"
@ -139,7 +195,7 @@
</UButton>
<UButton
color="warning"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-pencil"
@ -149,7 +205,7 @@
</UButton>
<UButton
color="error"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-trash"
@ -166,89 +222,125 @@
</div>
<div v-else-if="filteredItems.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<UCard
v-for="field in filteredItems"
:key="field.id"
class="flex h-full flex-col border bg-default"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
>
<template #header>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1 space-y-2">
<div class="truncate text-sm font-semibold text-highlighted">{{ field.name }}</div>
<div v-for="field in filteredItems" :key="field.id" class="min-w-0 w-full max-w-full">
<UCard
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
>
<template #header>
<div class="flex min-w-0 items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<button
type="button"
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
@click="toggleExpand(field.id, 'title')"
>
<span :class="['block', expandClass(field.id, 'title')]">{{ field.name }}</span>
</button>
</div>
</div>
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
<div class="flex shrink-0 items-center gap-2">
<UButton
color="neutral"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="exportItem(field)"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Order {{ field.order }}</span>
</span>
<span class="hidden sm:inline">Export</span>
</UButton>
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-shapes" class="size-3.5" />
<span>{{ field.kind }}</span>
</span>
<label class="inline-flex cursor-pointer items-center justify-center">
<input
v-model="selectedIds"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="field.id"
/>
</label>
</div>
</div>
</template>
<div class="space-y-2 text-sm text-default">
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Order: {{ field.order }}</span>
</span>
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-shapes" class="size-3.5" />
<span>Type: {{ field.kind }}</span>
</span>
</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
type="button"
:class="[
'flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left',
!field.description && 'sm:col-span-2',
]"
@click="toggleExpand(field.id, 'field')"
>
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Associated option</div>
<span :class="['block', expandClass(field.id, 'field')]">{{ field.field }}</span>
</div>
</button>
<button
v-if="field.description"
type="button"
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(field.id, 'description')"
>
<UIcon
name="i-lucide-message-square-text"
class="mt-0.5 size-4 shrink-0 text-toned"
/>
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Description</div>
<span :class="['block', expandClass(field.id, 'description')]">
{{ field.description }}
</span>
</div>
</button>
</div>
</div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@click="editItem(field)"
>
Edit
</UButton>
<UButton
color="info"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="exportItem(field)"
/>
color="neutral"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void deleteItem(field)"
>
Delete
</UButton>
</div>
</template>
<div class="space-y-2 text-sm text-default">
<div class="rounded-md border border-default bg-muted/20 px-3 py-2">
<div class="flex items-start gap-2">
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" />
<span class="wrap-break-word">{{ field.field }}</span>
</div>
</div>
<div
v-if="field.description"
class="rounded-md border border-default bg-muted/20 px-3 py-2"
>
<div class="flex items-start gap-2">
<UIcon
name="i-lucide-message-square-text"
class="mt-0.5 size-4 shrink-0 text-toned"
/>
<span class="wrap-break-word">{{ field.description }}</span>
</div>
</div>
</div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
<UButton
color="warning"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@click="editItem(field)"
>
Edit
</UButton>
<UButton
color="error"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void deleteItem(field)"
>
Delete
</UButton>
</div>
</UCard>
</UCard>
</div>
</div>
<UAlert
@ -307,7 +399,10 @@
</template>
<script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui';
import { useStorage } from '@vueuse/core';
import { useDialog } from '~/composables/useDialog';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import { useConfirm } from '~/composables/useConfirm';
import { useDlFields } from '~/composables/useDlFields';
import type { DLField } from '~/types/dl_fields';
@ -316,10 +411,12 @@ import { copyText, encode } from '~/utils';
const box = useConfirm();
const isMobile = useMediaQuery({ maxWidth: 1024 });
const { toggleExpand, expandClass } = useExpandableMeta();
const displayStyle = useStorage<'list' | 'grid'>('dl_fields_display_style', 'grid');
const dlFields = useDlFields();
const route = useRoute();
const router = useRouter();
const { confirmDialog } = useDialog();
const items = dlFields.dlFields as Ref<DLField[]>;
const paging = dlFields.pagination;
@ -331,6 +428,8 @@ const editorOpen = ref(false);
const query = ref('');
const showFilter = ref(false);
const filterInput = ref<HTMLInputElement | null>(null);
const selectedIds = ref<number[]>([]);
const massDelete = ref(false);
const filteredItems = computed<DLField[]>(() => {
const normalizedQuery = query.value?.toLowerCase();
@ -341,6 +440,30 @@ const filteredItems = computed<DLField[]>(() => {
return items.value.filter((entry) => deepIncludes(entry, normalizedQuery, new WeakSet()));
});
const selectableFieldIds = computed(() =>
filteredItems.value.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
);
const allSelected = computed(
() =>
selectableFieldIds.value.length > 0 &&
selectableFieldIds.value.every((id) => selectedIds.value.includes(id)),
);
const hasSelected = computed(() => selectedIds.value.length > 0);
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
[
{
label: 'Remove Selected',
icon: 'i-lucide-trash',
color: 'error',
disabled: !hasSelected.value || massDelete.value,
onSelect: () => void deleteSelected(),
},
],
]);
const modalTitle = computed(() => (itemRef.value ? `Edit - ${item.value.name}` : 'Add new field'));
const modalDescription = computed(
() => 'Custom fields allow you to add new fields to the download form.',
@ -355,6 +478,17 @@ watch(showFilter, (value) => {
}
});
watch(
filteredItems,
(items) => {
const validIds = new Set(
items.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
);
selectedIds.value = selectedIds.value.filter((id) => validIds.has(id));
},
{ deep: true },
);
const syncPageQuery = async (pageNumber: number): Promise<void> => {
const totalPages = dlFields.pagination.value.total_pages;
const nextQuery = { ...route.query };
@ -413,6 +547,15 @@ const toggleDisplayStyle = (): void => {
displayStyle.value = displayStyle.value === 'list' ? 'grid' : 'list';
};
const toggleMasterSelection = (): void => {
if (allSelected.value) {
selectedIds.value = [];
return;
}
selectedIds.value = [...selectableFieldIds.value];
};
const deleteItem = async (field: DLField): Promise<void> => {
if (true !== (await box.confirm(`Delete '${field.name}'?`))) {
return;
@ -421,6 +564,50 @@ const deleteItem = async (field: DLField): Promise<void> => {
await dlFields.deleteDlField(field.id!);
};
const deleteSelected = async (): Promise<void> => {
if (selectedIds.value.length < 1) {
return;
}
const { status } = await confirmDialog({
title: 'Delete Selected Fields',
rawHTML:
`Delete <strong class="text-red-500">${selectedIds.value.length}</strong> field/s?<ul>` +
selectedIds.value
.map((id) => {
const item = filteredItems.value.find((field) => field.id === id);
return item ? `<li>${item.id}: ${item.name}</li>` : '';
})
.join('') +
'</ul>',
confirmText: 'Delete',
confirmColor: 'error',
});
if (true !== status) {
return;
}
const itemsToDelete = filteredItems.value.filter(
(item) => item.id && selectedIds.value.includes(item.id),
);
if (itemsToDelete.length < 1) {
return;
}
massDelete.value = true;
for (const item of itemsToDelete) {
if (!item.id) {
continue;
}
await dlFields.deleteDlField(item.id);
}
selectedIds.value = [];
massDelete.value = false;
};
const updateItem = async ({
reference,
item: updatedItem,

View file

@ -97,6 +97,41 @@
@navigate="navigatePage"
/>
<div
v-if="!isLoading && filteredTargets.length > 0"
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-default bg-default px-3 py-3"
>
<div class="flex flex-wrap items-center gap-2">
<UButton
color="neutral"
variant="outline"
size="sm"
:icon="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
@click="toggleMasterSelection"
>
{{ allSelected ? 'Unselect' : 'Select' }}
</UButton>
<UBadge v-if="selectedIds.length > 0" color="error" variant="soft" size="sm">
{{ selectedIds.length }}
</UBadge>
<UDropdownMenu :items="bulkActionGroups" :modal="false">
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-list"
trailing-icon="i-lucide-chevron-down"
>
Actions
</UButton>
</UDropdownMenu>
</div>
<div class="text-xs text-toned">{{ filteredTargets.length }} displayed</div>
</div>
<div
v-if="displayStyle === 'list' && filteredTargets.length > 0"
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
@ -105,6 +140,14 @@
<table class="min-w-225 w-full text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
<th class="w-12">
<button type="button" class="cursor-pointer" @click="toggleMasterSelection">
<UIcon
:name="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
class="size-4"
/>
</button>
</th>
<th class="w-full text-left">Targets</th>
<th class="w-44 whitespace-nowrap">Actions</th>
</tr>
@ -112,6 +155,17 @@
<tbody class="divide-y divide-default">
<tr v-for="item in filteredTargets" :key="item.id" class="hover:bg-muted/20">
<td class="px-3 py-3 text-center align-middle">
<label class="inline-flex cursor-pointer items-center justify-center">
<input
v-model="selectedIds"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="item.id"
/>
</label>
</td>
<td class="px-3 py-3 align-middle">
<div class="space-y-2">
<div class="min-w-0 text-sm font-semibold text-highlighted">
@ -141,19 +195,26 @@
<span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
</button>
<span class="inline-flex items-center gap-1">
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-bell-ring" class="size-3.5" />
<span>On: {{ joinEvents(item.on) }}</span>
</span>
<span class="inline-flex items-center gap-1">
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-sliders-horizontal" class="size-3.5" />
<span>Presets: {{ joinPresets(item.presets) }}</span>
</span>
<span v-if="headerKeys(item).length > 0" class="inline-flex items-center gap-1">
<span
v-if="headerKeys(item).length > 0"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-key" class="size-3.5" />
<span>Headers: {{ headerKeys(item).join(', ') }}</span>
<span>Headers: {{ headerKeys(item).length }}</span>
</span>
</div>
</div>
@ -162,7 +223,7 @@
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<UButton
color="info"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-file-up"
@ -172,7 +233,7 @@
</UButton>
<UButton
color="warning"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-pencil"
@ -182,7 +243,7 @@
</UButton>
<UButton
color="error"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-trash"
@ -199,104 +260,185 @@
</div>
<div v-else-if="filteredTargets.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<UCard
v-for="item in filteredTargets"
:key="item.id"
class="flex h-full flex-col border bg-default"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
>
<template #header>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1 space-y-2">
<span class="text-sm font-semibold text-highlighted">
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }})
</span>
@
<a
:href="item.request.url"
target="_blank"
rel="noreferrer"
class="text-sm text-primary hover:underline"
>
{{ item.name }}
</a>
<div v-for="item in filteredTargets" :key="item.id" class="min-w-0 w-full max-w-full">
<UCard
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
>
<template #header>
<div class="flex min-w-0 items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<button
type="button"
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
@click="toggleExpand(item.id, 'title')"
>
<span :class="['block', expandClass(item.id, 'title')]">
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
{{ item.name }}
</span>
</button>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-toned">
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
:disabled="addInProgress"
@click="() => void toggleEnabled(item)"
<div class="flex shrink-0 items-center gap-2">
<UButton
color="neutral"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="exportItem(item)"
>
<UIcon
name="i-lucide-power"
class="size-3.5"
:class="item.enabled !== false ? 'text-success' : 'text-error'"
<span class="hidden sm:inline">Export Target</span>
</UButton>
<label class="inline-flex cursor-pointer items-center justify-center">
<input
v-model="selectedIds"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="item.id"
/>
<span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
</button>
</label>
</div>
</div>
</template>
<div class="space-y-2 text-sm text-default">
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
:disabled="addInProgress"
@click="() => void toggleEnabled(item)"
>
<UIcon
name="i-lucide-power"
class="size-3.5"
:class="item.enabled !== false ? 'text-success' : 'text-error'"
/>
<span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
</button>
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-bell-ring" class="size-3.5" />
<span>Events: {{ item.on.length || 'All' }}</span>
</span>
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-sliders-horizontal" class="size-3.5" />
<span>Presets: {{ item.presets.length || 'All' }}</span>
</span>
<span
v-if="headerKeys(item).length > 0"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-key" class="size-3.5" />
<span>Headers: {{ headerKeys(item).length }}</span>
</span>
</div>
<button
type="button"
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(item.id, 'url')"
>
<UIcon name="i-lucide-link" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Target URL</div>
<a
:href="item.request.url"
target="_blank"
rel="noreferrer"
class="block text-highlighted hover:underline"
@click.stop
>
<span :class="['block', expandClass(item.id, 'url')]">
{{ item.request.url }}
</span>
</a>
</div>
</button>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
type="button"
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(item.id, 'events')"
>
<UIcon name="i-lucide-bell-ring" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Events</div>
<span :class="['block', expandClass(item.id, 'events')]">{{
joinEvents(item.on)
}}</span>
</div>
</button>
<button
type="button"
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(item.id, 'presets')"
>
<UIcon
name="i-lucide-sliders-horizontal"
class="mt-0.5 size-4 shrink-0 text-toned"
/>
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Presets</div>
<span :class="['block', expandClass(item.id, 'presets')]">
{{ joinPresets(item.presets) }}
</span>
</div>
</button>
</div>
<button
v-if="headerKeys(item).length > 0"
type="button"
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(item.id, 'headers')"
>
<UIcon name="i-lucide-key" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Headers</div>
<span :class="['block', expandClass(item.id, 'headers')]">
{{ headerKeys(item).join(', ') }}
</span>
</div>
</button>
</div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@click="editItem(item)"
>
Edit
</UButton>
<UButton
color="info"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="exportItem(item)"
/>
color="neutral"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void deleteItem(item)"
>
Delete
</UButton>
</div>
</template>
<div class="space-y-2 text-sm text-default">
<div class="rounded-md border border-default bg-muted/20 px-3 py-2">
<div class="flex items-start gap-2">
<UIcon name="i-lucide-bell-ring" class="mt-0.5 size-4 shrink-0 text-toned" />
<span class="wrap-break-word">On: {{ joinEvents(item.on) }}</span>
</div>
</div>
<div class="rounded-md border border-default bg-muted/20 px-3 py-2">
<div class="flex items-start gap-2">
<UIcon name="i-lucide-sliders-horizontal" class="mt-0.5 size-4 shrink-0 text-toned" />
<span class="wrap-break-word">Presets: {{ joinPresets(item.presets) }}</span>
</div>
</div>
<div
v-if="headerKeys(item).length > 0"
class="rounded-md border border-default bg-muted/20 px-3 py-2"
>
<div class="flex items-start gap-2">
<UIcon name="i-lucide-key" class="mt-0.5 size-4 shrink-0 text-toned" />
<span class="wrap-break-word">Headers: {{ headerKeys(item).join(', ') }}</span>
</div>
</div>
</div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
<UButton
color="warning"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@click="editItem(item)"
>
Edit
</UButton>
<UButton
color="error"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void deleteItem(item)"
>
Delete
</UButton>
</div>
</UCard>
</UCard>
</div>
</div>
<UAlert
@ -388,7 +530,10 @@
</template>
<script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui';
import { useStorage } from '@vueuse/core';
import { useDialog } from '~/composables/useDialog';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import { useConfirm } from '~/composables/useConfirm';
import { useNotifications } from '~/composables/useNotifications';
import { copyText, encode, parse_api_error, request, ucFirst } from '~/utils';
@ -397,7 +542,9 @@ import type { notification } from '~/types/notification';
const toast = useNotification();
const box = useConfirm();
const { confirmDialog } = useDialog();
const isMobile = useMediaQuery({ maxWidth: 1024 });
const { toggleExpand, expandClass } = useExpandableMeta();
const displayStyleState = useStorage<'list' | 'grid' | 'cards'>(
'notification_display_style',
'cards',
@ -419,6 +566,8 @@ const sendingTest = ref(false);
const query = ref('');
const showFilter = ref(false);
const filterInput = ref<HTMLInputElement | null>(null);
const selectedIds = ref<number[]>([]);
const massDelete = ref(false);
const displayStyle = computed<'list' | 'grid'>(() =>
displayStyleState.value === 'list' ? 'list' : 'grid',
@ -445,12 +594,47 @@ const filteredTargets = computed<notification[]>(() => {
return items.filter((item) => deepIncludes(item, normalizedQuery, new WeakSet()));
});
const selectableNotificationIds = computed(() =>
filteredTargets.value.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
);
const allSelected = computed(
() =>
selectableNotificationIds.value.length > 0 &&
selectableNotificationIds.value.every((id) => selectedIds.value.includes(id)),
);
const hasSelected = computed(() => selectedIds.value.length > 0);
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
[
{
label: 'Remove Selected',
icon: 'i-lucide-trash',
color: 'error',
disabled: !hasSelected.value || massDelete.value,
onSelect: () => void deleteSelected(),
},
],
]);
watch(showFilter, (value) => {
if (!value) {
query.value = '';
}
});
watch(
filteredTargets,
(items) => {
const validIds = new Set(
items.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
);
selectedIds.value = selectedIds.value.filter((id) => validIds.has(id));
},
{ deep: true },
);
function defaultState(): notification {
return {
name: '',
@ -500,6 +684,15 @@ const openCreate = (): void => {
editorOpen.value = true;
};
const toggleMasterSelection = (): void => {
if (allSelected.value) {
selectedIds.value = [];
return;
}
selectedIds.value = [...selectableNotificationIds.value];
};
const editItem = (item: notification): void => {
target.value = JSON.parse(JSON.stringify(item)) as notification;
targetRef.value = item.id ?? undefined;
@ -519,6 +712,50 @@ const deleteItem = async (item: notification): Promise<void> => {
await notificationsStore.deleteNotification(item.id);
};
const deleteSelected = async (): Promise<void> => {
if (selectedIds.value.length < 1) {
return;
}
const { status } = await confirmDialog({
title: 'Delete Selected Notifications',
rawHTML:
`Delete <strong class="text-red-500">${selectedIds.value.length}</strong> notification target/s?<ul>` +
selectedIds.value
.map((id) => {
const item = filteredTargets.value.find((target) => target.id === id);
return item ? `<li>${item.id}: ${item.name}</li>` : '';
})
.join('') +
'</ul>',
confirmText: 'Delete',
confirmColor: 'error',
});
if (true !== status) {
return;
}
const itemsToDelete = filteredTargets.value.filter(
(item) => item.id && selectedIds.value.includes(item.id),
);
if (itemsToDelete.length < 1) {
return;
}
massDelete.value = true;
for (const item of itemsToDelete) {
if (!item.id) {
continue;
}
await notificationsStore.deleteNotification(item.id);
}
selectedIds.value = [];
massDelete.value = false;
};
const toggleEnabled = async (item: notification): Promise<void> => {
if (!item.id) {
toast.error('Notification target not found.');

View file

@ -76,6 +76,41 @@
</div>
</div>
<div
v-if="!isLoading && filteredPresets.length > 0"
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-default bg-default px-3 py-3"
>
<div class="flex flex-wrap items-center gap-2">
<UButton
color="neutral"
variant="outline"
size="sm"
:icon="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
@click="toggleMasterSelection"
>
{{ allSelected ? 'Unselect' : 'Select' }}
</UButton>
<UBadge v-if="selectedIds.length > 0" color="error" variant="soft" size="sm">
{{ selectedIds.length }}
</UBadge>
<UDropdownMenu :items="bulkActionGroups" :modal="false">
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-list"
trailing-icon="i-lucide-chevron-down"
>
Actions
</UButton>
</UDropdownMenu>
</div>
<div class="text-xs text-toned">{{ filteredPresets.length }} displayed</div>
</div>
<div
v-if="display_style === 'list' && filteredPresets.length > 0"
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
@ -84,6 +119,14 @@
<table class="min-w-190 w-full text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
<th class="w-12">
<button type="button" class="cursor-pointer" @click="toggleMasterSelection">
<UIcon
:name="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
class="size-4"
/>
</button>
</th>
<th class="w-full text-left">Preset</th>
<th class="w-44 whitespace-nowrap">Actions</th>
</tr>
@ -91,6 +134,17 @@
<tbody class="divide-y divide-default">
<tr v-for="item in filteredPresets" :key="item.id" class="hover:bg-muted/20">
<td class="px-3 py-3 text-center align-middle">
<label class="inline-flex cursor-pointer items-center justify-center">
<input
v-model="selectedIds"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="item.id"
/>
</label>
</td>
<td class="px-3 py-3 align-middle">
<div class="space-y-1">
<div class="font-semibold text-highlighted">
@ -99,14 +153,20 @@
<div class="flex flex-wrap items-center gap-3 text-xs text-toned">
<span
class="inline-flex items-center gap-1"
:class="item.cookies ? 'text-info' : ''"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-cookie" class="size-3.5" />
<UIcon
name="i-lucide-cookie"
class="size-3.5"
:class="item.cookies ? 'text-success' : ''"
/>
<span>{{ item.cookies ? 'Has cookies' : 'No cookies' }}</span>
</span>
<span v-if="item.priority > 0" class="inline-flex items-center gap-1">
<span
v-if="item.priority > 0"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Priority: {{ item.priority }}</span>
</span>
@ -117,7 +177,7 @@
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<UButton
color="info"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-file-up"
@ -127,7 +187,7 @@
</UButton>
<UButton
color="warning"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-pencil"
@ -137,7 +197,7 @@
</UButton>
<UButton
color="error"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-trash"
@ -154,121 +214,163 @@
</div>
<div v-else-if="filteredPresets.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<UCard
v-for="item in filteredPresets"
:key="item.id"
class="flex h-full flex-col border bg-default"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
>
<template #header>
<div class="flex items-start justify-between gap-3">
<button
type="button"
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
@click="toggleExpand(item.id, 'title')"
>
<div v-for="item in filteredPresets" :key="item.id" class="min-w-0 w-full max-w-full">
<UCard
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
>
<template #header>
<div class="flex min-w-0 items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<button
type="button"
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
@click="toggleExpand(item.id, 'title')"
>
<span :class="['block', expandClass(item.id, 'title')]">
{{ prettyName(item.name) }}
</span>
</button>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<UButton
color="neutral"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="exportItem(item)"
>
<span class="hidden sm:inline">Export Preset</span>
</UButton>
<label class="inline-flex cursor-pointer items-center justify-center">
<input
v-model="selectedIds"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="item.id"
/>
</label>
</div>
</div>
</template>
<div class="space-y-2 text-sm text-default">
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
<span
:class="
!isExpanded(item.id, 'title') ? 'block truncate' : 'block whitespace-pre-wrap'
"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
{{ prettyName(item.name) }}
<UIcon
name="i-lucide-cookie"
class="size-3.5"
:class="item.cookies ? 'text-success' : ''"
/>
<span>Cookies: {{ item.cookies ? 'Configured' : 'Not set' }}</span>
</span>
<span
v-if="item.priority > 0"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Priority: {{ item.priority }}</span>
</span>
</div>
<button
v-if="item.folder"
type="button"
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(item.id, 'folder')"
>
<UIcon name="i-lucide-folder-output" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Download path</div>
<span :class="['block', expandClass(item.id, 'folder')]">{{
calcPath(item.folder)
}}</span>
</div>
</button>
<div v-if="item.template || item.cli" class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
v-if="item.template"
type="button"
:class="[
'flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left',
!item.cli && 'sm:col-span-2',
]"
@click="toggleExpand(item.id, 'template')"
>
<UIcon name="i-lucide-file-code-2" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Output template</div>
<span :class="['block', expandClass(item.id, 'template')]">{{
item.template
}}</span>
</div>
</button>
<button
v-if="item.cli"
type="button"
:class="[
'flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left',
!item.template && 'sm:col-span-2',
]"
@click="toggleExpand(item.id, 'cli')"
>
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">CLI options</div>
<span :class="['block', expandClass(item.id, 'cli')]">{{ item.cli }}</span>
</div>
</button>
</div>
<button
v-if="item.description"
type="button"
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(item.id, 'description')"
>
<UIcon name="i-lucide-align-left" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Description</div>
<span :class="['block', expandClass(item.id, 'description')]">{{
item.description
}}</span>
</div>
</button>
</div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
<UButton
color="info"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="exportItem(item)"
/>
</div>
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
:class="item.cookies ? 'border-info/40 text-info' : ''"
color="neutral"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@click="editor.openEdit(item)"
>
<UIcon name="i-lucide-cookie" class="size-3.5" />
<span>{{ item.cookies ? 'Has cookies' : 'No cookies' }}</span>
</span>
Edit
</UButton>
<span
v-if="item.priority > 0"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
<UButton
color="neutral"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void deleteItem(item)"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Priority {{ item.priority }}</span>
</span>
Delete
</UButton>
</div>
</template>
<div class="space-y-2 text-sm text-default">
<button
v-if="item.folder"
type="button"
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(item.id, 'folder')"
>
<UIcon name="i-lucide-folder-output" class="mt-0.5 size-4 shrink-0 text-toned" />
<span :class="expandClass(item.id, 'folder')">{{ calcPath(item.folder) }}</span>
</button>
<button
v-if="item.template"
type="button"
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(item.id, 'template')"
>
<UIcon name="i-lucide-file-code-2" class="mt-0.5 size-4 shrink-0 text-toned" />
<span :class="expandClass(item.id, 'template')">{{ item.template }}</span>
</button>
<button
v-if="item.cli"
type="button"
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(item.id, 'cli')"
>
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" />
<span :class="expandClass(item.id, 'cli')">{{ item.cli }}</span>
</button>
<button
v-if="item.description"
type="button"
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(item.id, 'description')"
>
<UIcon name="i-lucide-align-left" class="mt-0.5 size-4 shrink-0 text-toned" />
<span :class="expandClass(item.id, 'description')">{{ item.description }}</span>
</button>
</div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
<UButton
color="warning"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@click="editor.openEdit(item)"
>
Edit
</UButton>
<UButton
color="error"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void deleteItem(item)"
>
Delete
</UButton>
</div>
</UCard>
</UCard>
</div>
</div>
<UAlert
@ -308,7 +410,8 @@
<ul class="list-disc space-y-2 pl-5 text-sm text-default">
<li>
When you export preset, it doesn't include the cookies field contents for security
reasons.
reasons. However, there are some CLI options that could contain sensitive data like
username or password. Remove them before sharing your preset.
</li>
</ul>
</template>
@ -339,8 +442,11 @@
</template>
<script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui';
import { useStorage } from '@vueuse/core';
import type { Preset } from '~/types/presets';
import { useDialog } from '~/composables/useDialog';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import { useConfirm } from '~/composables/useConfirm';
import { prettyName } from '~/utils';
@ -350,6 +456,8 @@ const presetsStore = usePresets();
const config = useConfigStore();
const box = useConfirm();
const editor = usePresetEditor();
const { confirmDialog } = useDialog();
const { toggleExpand, expandClass } = useExpandableMeta();
const display_style = useStorage<string>('preset_display_style', 'grid');
const isMobile = useMediaQuery({ maxWidth: 1024 });
@ -357,10 +465,11 @@ const isMobile = useMediaQuery({ maxWidth: 1024 });
const query = ref('');
const showFilter = ref(false);
const filterInput = ref<HTMLInputElement | null>(null);
const selectedIds = ref<number[]>([]);
const massDelete = ref(false);
const presets = computed(() => presetsStore.presets.value as PresetWithUI[]);
const isLoading = presetsStore.isLoading;
const expandedItems = ref<Record<string, Set<string>>>({});
const presetsNoDefault = computed(() => presets.value.filter((item) => !item.default));
@ -375,12 +484,47 @@ const filteredPresets = computed<PresetWithUI[]>(() => {
);
});
const selectablePresetIds = computed(() =>
filteredPresets.value.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
);
const allSelected = computed(
() =>
selectablePresetIds.value.length > 0 &&
selectablePresetIds.value.every((id) => selectedIds.value.includes(id)),
);
const hasSelected = computed(() => selectedIds.value.length > 0);
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
[
{
label: 'Remove Selected',
icon: 'i-lucide-trash',
color: 'error',
disabled: !hasSelected.value || massDelete.value,
onSelect: () => void deleteSelected(),
},
],
]);
watch(showFilter, (value) => {
if (!value) {
query.value = '';
}
});
watch(
filteredPresets,
(items) => {
const validIds = new Set(
items.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
);
selectedIds.value = selectedIds.value.filter((id) => validIds.has(id));
},
{ deep: true },
);
const toggleFilterPanel = async (): Promise<void> => {
showFilter.value = !showFilter.value;
if (!showFilter.value) {
@ -396,6 +540,59 @@ const reloadContent = async (): Promise<void> => {
await presetsStore.loadPresets(1, 1000);
};
const toggleMasterSelection = (): void => {
if (allSelected.value) {
selectedIds.value = [];
return;
}
selectedIds.value = [...selectablePresetIds.value];
};
const deleteSelected = async (): Promise<void> => {
if (selectedIds.value.length < 1) {
return;
}
const { status } = await confirmDialog({
title: 'Delete Selected Presets',
rawHTML:
`Delete <strong class="text-red-500">${selectedIds.value.length}</strong> preset/s?<ul>` +
selectedIds.value
.map((id) => {
const item = filteredPresets.value.find((preset) => preset.id === id);
return item ? `<li>${item.id}: ${prettyName(item.name)}</li>` : '';
})
.join('') +
'</ul>',
confirmText: 'Delete',
confirmColor: 'error',
});
if (true !== status) {
return;
}
const itemsToDelete = filteredPresets.value.filter(
(item) => item.id && selectedIds.value.includes(item.id),
);
if (itemsToDelete.length < 1) {
return;
}
massDelete.value = true;
for (const item of itemsToDelete) {
if (!item.id) {
continue;
}
await presetsStore.deletePreset(item.id);
}
selectedIds.value = [];
massDelete.value = false;
};
const deleteItem = async (item: Preset): Promise<void> => {
if (true !== (await box.confirm(`Delete preset '${item.name}'?`))) {
return;
@ -410,35 +607,6 @@ const toggleDisplayStyle = (): void => {
display_style.value = display_style.value === 'list' ? 'grid' : 'list';
};
const toggleExpand = (itemId: number | string | undefined, field: string): void => {
if (itemId === undefined || itemId === null) {
return;
}
const key = String(itemId);
if (!expandedItems.value[key]) {
expandedItems.value[key] = new Set();
}
if (expandedItems.value[key]?.has(field)) {
expandedItems.value[key]?.delete(field);
} else {
expandedItems.value[key]?.add(field);
}
};
const isExpanded = (itemId: number | string | undefined, field: string): boolean => {
if (itemId === undefined || itemId === null) {
return false;
}
return expandedItems.value[String(itemId)]?.has(field) ?? false;
};
const expandClass = (itemId: number | string | undefined, field: string): string => {
return isExpanded(itemId, field) ? 'whitespace-pre-wrap break-words' : 'truncate';
};
const exportItem = (item: Preset): void => {
const excludedKeys = ['id', 'default', 'raw', 'cookies', 'toggle_description'];
const userData = Object.fromEntries(

View file

@ -93,6 +93,41 @@
:description="lastError"
/>
<div
v-if="!isLoading && filteredDefinitions.length > 0"
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-default bg-default px-3 py-3"
>
<div class="flex flex-wrap items-center gap-2">
<UButton
color="neutral"
variant="outline"
size="sm"
:icon="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
@click="toggleMasterSelection"
>
{{ allSelected ? 'Unselect' : 'Select' }}
</UButton>
<UBadge v-if="selectedIds.length > 0" color="error" variant="soft" size="sm">
{{ selectedIds.length }}
</UBadge>
<UDropdownMenu :items="bulkActionGroups" :modal="false">
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-list"
trailing-icon="i-lucide-chevron-down"
>
Actions
</UButton>
</UDropdownMenu>
</div>
<div class="text-xs text-toned">{{ filteredDefinitions.length }} displayed</div>
</div>
<div
v-if="display_style === 'list' && filteredDefinitions.length > 0"
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
@ -101,6 +136,14 @@
<table class="min-w-245 w-full text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
<th class="w-12">
<button type="button" class="cursor-pointer" @click="toggleMasterSelection">
<UIcon
:name="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
class="size-4"
/>
</button>
</th>
<th class="w-full text-left">Definition</th>
<th class="w-28 whitespace-nowrap">Priority</th>
<th class="w-36 whitespace-nowrap">Updated</th>
@ -114,6 +157,17 @@
:key="definition.id"
class="hover:bg-muted/20"
>
<td class="px-3 py-3 text-center align-middle">
<label class="inline-flex cursor-pointer items-center justify-center">
<input
v-model="selectedIds"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="definition.id"
/>
</label>
</td>
<td class="px-3 py-3 align-middle">
<div class="space-y-1">
<div class="font-semibold text-highlighted">
@ -123,7 +177,7 @@
<div class="flex flex-wrap items-center gap-3 text-xs text-toned">
<button
type="button"
class="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 transition hover:bg-muted"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
@click="() => void toggle(definition)"
>
<UIcon
@ -134,13 +188,11 @@
<span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span>
</button>
<span class="inline-flex items-center gap-1">
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-link" class="size-3.5" />
<span
>{{ definition.match_url.length }} match pattern{{
definition.match_url.length === 1 ? '' : 's'
}}</span
>
<span>Patterns: {{ definition.match_url.length }} match/s</span>
</span>
</div>
</div>
@ -161,7 +213,7 @@
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<UButton
color="info"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-file-up"
@ -171,7 +223,7 @@
</UButton>
<UButton
color="warning"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-pencil"
@ -181,7 +233,7 @@
</UButton>
<UButton
color="error"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-trash"
@ -201,110 +253,116 @@
v-else-if="filteredDefinitions.length > 0"
class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
>
<UCard
<div
v-for="definition in filteredDefinitions"
:key="definition.id"
class="flex h-full flex-col border bg-default"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
class="min-w-0 w-full max-w-full"
>
<template #header>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1 space-y-2">
<div class="text-sm font-semibold text-highlighted wrap-break-word">
{{ definition.name || '(Unnamed definition)' }}
<UCard
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
>
<template #header>
<div class="flex min-w-0 items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<button
type="button"
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
@click="toggleExpand(definition.id, 'title')"
>
<span :class="['block', expandClass(definition.id, 'title')]">
{{ definition.name || '(Unnamed)' }}
</span>
</button>
</div>
</div>
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
@click="() => void toggle(definition)"
<div class="flex shrink-0 items-center gap-2">
<UButton
color="neutral"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="() => void exportDefinition(definition)"
>
<UIcon
name="i-lucide-power"
class="size-3.5"
:class="definition.enabled ? 'text-success' : 'text-error'"
<span class="hidden sm:inline">Export Definition</span>
</UButton>
<label class="inline-flex cursor-pointer items-center justify-center">
<input
v-model="selectedIds"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="definition.id"
/>
<span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span>
</button>
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Priority {{ definition.priority }}</span>
</span>
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-link" class="size-3.5" />
<span
>{{ definition.match_url.length }} match pattern{{
definition.match_url.length === 1 ? '' : 's'
}}</span
>
</span>
</label>
</div>
</div>
</template>
<div class="space-y-2 text-sm text-default">
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
@click="() => void toggle(definition)"
>
<UIcon
name="i-lucide-power"
class="size-3.5"
:class="definition.enabled ? 'text-success' : 'text-error'"
/>
<span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span>
</button>
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Priority: {{ definition.priority }}</span>
</span>
</div>
<button
type="button"
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(definition.id, 'patterns')"
>
<UIcon name="i-lucide-link" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">URL patterns</div>
<span :class="['block', expandClass(definition.id, 'patterns')]">
{{ definition.match_url.join('\n') }}
</span>
</div>
</button>
</div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@click="() => void openEdit(definition)"
>
Edit
</UButton>
<UButton
color="info"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="() => void exportDefinition(definition)"
/>
</div>
</template>
<div class="space-y-3 text-sm text-default">
<div class="rounded-md border border-default bg-muted/20 px-3 py-2">
<div
class="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-toned"
color="neutral"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void remove(definition)"
>
<UIcon name="i-lucide-link" class="size-3.5" />
<span>Match patterns</span>
</div>
<div class="space-y-1 text-sm">
<div
v-for="pattern in definition.match_url.slice(0, 3)"
:key="pattern"
class="truncate text-default"
>
{{ pattern }}
</div>
<div v-if="definition.match_url.length > 3" class="text-xs text-toned">
+{{ definition.match_url.length - 3 }} more
</div>
</div>
Delete
</UButton>
</div>
</div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
<UButton
color="warning"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@click="() => void openEdit(definition)"
>
Edit
</UButton>
<UButton
color="error"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void remove(definition)"
>
Delete
</UButton>
</div>
</UCard>
</UCard>
</div>
</div>
<UAlert
@ -378,10 +436,12 @@
</template>
<script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui';
import moment from 'moment';
import { computed, onMounted, ref, watch } from 'vue';
import { useStorage } from '@vueuse/core';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import useTaskDefinitionsComposable from '~/composables/useTaskDefinitions';
import { useDialog } from '~/composables/useDialog';
import { useMediaQuery } from '~/composables/useMediaQuery';
@ -413,6 +473,7 @@ const DEFAULT_DEFINITION: TaskDefinitionDocument = {
};
const isMobile = useMediaQuery({ maxWidth: 1024 });
const { toggleExpand, expandClass } = useExpandableMeta();
const taskDefs = useTaskDefinitionsComposable();
const definitionsRef = taskDefs.definitions;
@ -442,6 +503,8 @@ const query = ref('');
const showFilter = ref(false);
const filterInput = ref<HTMLInputElement | null>(null);
const hideImportByDefault = ref(false);
const selectedIds = ref<number[]>([]);
const massDelete = ref(false);
const filteredDefinitions = computed<TaskDefinitionSummary[]>(() => {
const normalizedQuery = query.value.trim().toLowerCase();
@ -463,6 +526,32 @@ const filteredDefinitions = computed<TaskDefinitionSummary[]>(() => {
});
});
const selectableDefinitionIds = computed(() =>
filteredDefinitions.value
.map((item) => item.id)
.filter((id): id is number => typeof id === 'number'),
);
const allSelected = computed(
() =>
selectableDefinitionIds.value.length > 0 &&
selectableDefinitionIds.value.every((id) => selectedIds.value.includes(id)),
);
const hasSelected = computed(() => selectedIds.value.length > 0);
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
[
{
label: 'Remove Selected',
icon: 'i-lucide-trash',
color: 'error',
disabled: !hasSelected.value || massDelete.value,
onSelect: () => void deleteSelected(),
},
],
]);
const currentSummary = computed<TaskDefinitionSummary | undefined>(() => {
if (editorMode.value !== 'edit' || !workingId.value) {
return undefined;
@ -495,6 +584,17 @@ watch(showFilter, (value) => {
}
});
watch(
filteredDefinitions,
(items) => {
const validIds = new Set(
items.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
);
selectedIds.value = selectedIds.value.filter((id) => validIds.has(id));
},
{ deep: true },
);
const cloneDocument = (document: TaskDefinitionDocument): TaskDefinitionDocument => {
return JSON.parse(JSON.stringify(document)) as TaskDefinitionDocument;
};
@ -518,6 +618,15 @@ const toggleDisplayStyle = (): void => {
display_style.value = display_style.value === 'list' ? 'grid' : 'list';
};
const toggleMasterSelection = (): void => {
if (allSelected.value) {
selectedIds.value = [];
return;
}
selectedIds.value = [...selectableDefinitionIds.value];
};
const openCreate = (): void => {
editorMode.value = 'create';
workingId.value = null;
@ -625,6 +734,47 @@ const remove = async (summary: TaskDefinitionSummary): Promise<void> => {
await deleteDefinition(summary.id);
};
const deleteSelected = async (): Promise<void> => {
if (selectedIds.value.length < 1) {
return;
}
const { status } = await confirmDialog({
title: 'Delete Selected Task Definitions',
rawHTML:
`Delete <strong class="text-red-500">${selectedIds.value.length}</strong> task definition/s?<ul>` +
selectedIds.value
.map((id) => {
const item = filteredDefinitions.value.find((definition) => definition.id === id);
return item ? `<li>${item.id}: ${item.name || '(Unnamed definition)'}</li>` : '';
})
.join('') +
'</ul>',
confirmText: 'Delete',
confirmColor: 'error',
});
if (true !== status) {
return;
}
const itemsToDelete = filteredDefinitions.value.filter(
(item) => item.id && selectedIds.value.includes(item.id),
);
if (itemsToDelete.length < 1) {
return;
}
massDelete.value = true;
for (const item of itemsToDelete) {
await deleteDefinition(item.id);
}
selectedIds.value = [];
massDelete.value = false;
};
const toggle = async (summary: TaskDefinitionSummary): Promise<void> => {
await toggleEnabled(summary.id, !summary.enabled);
};

View file

@ -184,7 +184,7 @@
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
@click="() => void toggleEnabled(item)"
@click="() => void toggleFlag(item, 'enabled')"
>
<UIcon
name="i-lucide-power"
@ -194,55 +194,42 @@
<span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
</button>
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
@click="() => void toggleFlag(item, 'auto_start')"
>
<UIcon
:name="item.auto_start ? 'i-lucide-circle-pause' : 'i-lucide-circle-play'"
name="i-lucide-circle-play"
class="size-3.5"
:class="item.auto_start ? 'text-success' : 'text-error'"
/>
<span>{{ item.auto_start ? 'Auto' : 'Manual' }}</span>
</span>
<span>Auto start: {{ item.auto_start ? 'Yes' : 'No' }}</span>
</button>
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
@click="() => void toggleHandlerEnabled(item)"
@click="() => void toggleFlag(item, 'handler_enabled')"
>
<UIcon
name="i-lucide-rss"
class="size-3.5"
:class="item.handler_enabled !== false ? 'text-success' : 'text-error'"
/>
<span>{{ item.handler_enabled !== false ? 'Enabled' : 'Disabled' }}</span>
<span>Handler: {{ item.handler_enabled !== false ? 'On' : 'Off' }}</span>
</button>
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-sliders-horizontal" class="size-3.5" />
Preset:
<span class="capitalize">
{{ item.preset ?? config.app.default_preset }}
</span>
</span>
</div>
<div class="space-y-1 text-xs text-toned">
<div v-if="item.folder" class="flex items-start gap-2">
<UIcon name="i-lucide-folder-output" class="mt-0.5 size-3.5 shrink-0" />
<span class="break-all">{{ calcPath(item.folder) }}</span>
</div>
<div v-if="item.template" class="flex items-start gap-2">
<UIcon name="i-lucide-file-code-2" class="mt-0.5 size-3.5 shrink-0" />
<span class="break-all">{{ item.template }}</span>
</div>
<div v-if="item.cli" class="flex items-start gap-2">
<UIcon name="i-lucide-terminal" class="mt-0.5 size-3.5 shrink-0" />
<span class="break-all">{{ item.cli }}</span>
</div>
</div>
</div>
</div>
</td>
@ -273,7 +260,7 @@
>
<span class="inline-flex items-center gap-1 whitespace-nowrap">
<UIcon name="i-lucide-triangle-alert" class="size-3.5" />
<span>No timer or handler</span>
<span>Not configured</span>
</span>
</p>
@ -289,7 +276,7 @@
<td class="w-44 px-3 py-3 align-top whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<UButton
color="warning"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-pencil"
@ -299,7 +286,7 @@
</UButton>
<UButton
color="error"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-trash"
@ -343,10 +330,20 @@
<NuxtLink
target="_blank"
:href="item.url"
class="min-w-0 flex-1 truncate text-sm font-semibold text-highlighted hover:underline"
class="mt-0.5 shrink-0 text-toned transition hover:text-highlighted"
aria-label="Open source URL"
>
{{ remove_tags(item.name) }}
<UIcon name="i-lucide-external-link" class="size-4" />
</NuxtLink>
<button
type="button"
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
@click="toggleExpand(item.id, 'title')"
>
<span :class="['block', expandClass(item.id, 'title')]">
{{ remove_tags(item.name) }}
</span>
</button>
<UIcon
v-if="item.id && isTaskInProgress(item.id)"
@ -355,7 +352,10 @@
/>
</div>
<div class="flex flex-wrap items-center gap-1">
<div
v-if="get_tags(item.name).length > 0"
class="flex flex-wrap items-center gap-1"
>
<UBadge
v-for="tag in get_tags(item.name)"
:key="`${item.id}-${tag}`"
@ -370,14 +370,15 @@
<div class="flex shrink-0 items-center gap-2">
<UButton
color="info"
color="neutral"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="() => void exportItem(item)"
/>
>
<span class="hidden sm:inline">Export Task</span>
</UButton>
<label class="inline-flex cursor-pointer items-center justify-center">
<input
v-model="selectedElms"
@ -395,7 +396,7 @@
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
@click="() => void toggleEnabled(item)"
@click="() => void toggleFlag(item, 'enabled')"
>
<UIcon
name="i-lucide-power"
@ -405,39 +406,49 @@
<span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
</button>
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
@click="() => void toggleFlag(item, 'auto_start')"
>
<UIcon
:name="item.auto_start ? 'i-lucide-circle-pause' : 'i-lucide-circle-play'"
name="i-lucide-circle-play"
class="size-3.5"
:class="item.auto_start ? 'text-success' : 'text-error'"
/>
<span>{{ item.auto_start ? 'Auto' : 'Manual' }}</span>
</span>
<span>Auto start: {{ item.auto_start ? 'Yes' : 'No' }}</span>
</button>
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
@click="() => void toggleHandlerEnabled(item)"
@click="() => void toggleFlag(item, 'handler_enabled')"
>
<UIcon
name="i-lucide-rss"
class="size-3.5"
:class="item.handler_enabled !== false ? 'text-success' : 'text-error'"
/>
<span>{{ item.handler_enabled !== false ? 'Handler on' : 'Handler off' }}</span>
<span>Handler: {{ item.handler_enabled !== false ? 'On' : 'Off' }}</span>
</button>
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-sliders-horizontal" class="size-3.5" />
<span class="capitalize">{{ item.preset ?? config.app.default_preset }}</span>
<span>Preset: {{ item.preset ?? config.app.default_preset }}</span>
</span>
</div>
<div class="rounded-md border border-default bg-muted/20 px-3 py-2">
<div class="flex items-start gap-2 text-sm">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
type="button"
:class="[
'flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left',
!item.folder && 'sm:col-span-2',
]"
@click="toggleExpand(item.id, 'schedule')"
>
<UIcon
:name="
item.timer
@ -451,58 +462,88 @@
/>
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Schedule</div>
<template v-if="item.timer">
<a
target="_blank"
:href="`https://crontab.guru/#${item.timer.replace(/ /g, '_')}`"
class="break-all text-highlighted hover:underline"
class="block text-highlighted hover:underline"
@click.stop
>
{{ item.timer }}
<span :class="['block', expandClass(item.id, 'schedule')]">
{{ item.timer }} ( {{ tryParse(item.timer) }} )
</span>
</a>
<p
class="mt-1 text-xs"
:class="tryParse(item.timer) === 'Invalid' ? 'text-error' : 'text-toned'"
>
{{ tryParse(item.timer) }}
</p>
</template>
<p v-else-if="willTaskBeProcessed(item)" class="text-toned whitespace-nowrap">
<p
v-else-if="willTaskBeProcessed(item)"
:class="['text-sm text-default', expandClass(item.id, 'schedule')]"
>
Handler only
</p>
<p v-else class="text-error whitespace-nowrap">No timer or handler</p>
<p v-else :class="['text-sm text-error', expandClass(item.id, 'schedule')]">
Not configured
</p>
</div>
</div>
</div>
</button>
<div v-if="item.folder" class="rounded-md border border-default bg-muted/20 px-3 py-2">
<div class="flex items-start gap-2">
<button
v-if="item.folder"
type="button"
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(item.id, 'folder')"
>
<UIcon name="i-lucide-folder-output" class="mt-0.5 size-4 shrink-0 text-toned" />
<span class="break-all text-toned">{{ calcPath(item.folder) }}</span>
</div>
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Download path</div>
<span :class="['block', expandClass(item.id, 'folder')]">
{{ calcPath(item.folder) }}
</span>
</div>
</button>
</div>
<div
v-if="item.template"
class="rounded-md border border-default bg-muted/20 px-3 py-2"
>
<div class="flex items-start gap-2">
<div v-if="item.template || item.cli" class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
v-if="item.template"
type="button"
:class="[
'flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left',
!item.cli && 'sm:col-span-2',
]"
@click="toggleExpand(item.id, 'template')"
>
<UIcon name="i-lucide-file-code-2" class="mt-0.5 size-4 shrink-0 text-toned" />
<span class="break-all text-toned">{{ item.template }}</span>
</div>
</div>
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Output template</div>
<span :class="['block', expandClass(item.id, 'template')]">{{
item.template
}}</span>
</div>
</button>
<div v-if="item.cli" class="rounded-md border border-default bg-muted/20 px-3 py-2">
<div class="flex items-start gap-2">
<button
v-if="item.cli"
type="button"
:class="[
'flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left',
!item.template && 'sm:col-span-2',
]"
@click="toggleExpand(item.id, 'cli')"
>
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" />
<span class="break-all text-toned">{{ item.cli }}</span>
</div>
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">CLI options</div>
<span :class="['block', expandClass(item.id, 'cli')]">{{ item.cli }}</span>
</div>
</button>
</div>
</div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
<UButton
color="warning"
color="neutral"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@ -512,7 +553,7 @@
</UButton>
<UButton
color="error"
color="neutral"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@ -642,6 +683,7 @@ import type { DropdownMenuItem } from '@nuxt/ui';
import { useStorage } from '@vueuse/core';
import { CronExpressionParser } from 'cron-parser';
import { useConfirm } from '~/composables/useConfirm';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import { useTasks } from '~/composables/useTasks';
import TaskInspect from '~/components/TaskInspect.vue';
import type { ExportedTask, Task } from '~/types/tasks';
@ -657,6 +699,7 @@ const socket = useSocketStore();
const stateStore = useStateStore();
const { confirmDialog } = useDialog();
const sessionCache = useSessionCache();
const { toggleExpand, expandClass } = useExpandableMeta();
const display_style = useStorage<'list' | 'grid' | 'cards'>('tasks_display_style', 'grid');
const isMobile = useMediaQuery({ maxWidth: 1024 });
@ -956,34 +999,23 @@ const deleteItem = async (item: Task) => {
await tasksComposable.deleteTask(item.id);
};
const toggleEnabled = async (item: Task) => {
const toggleFlag = async (item: Task, field: 'enabled' | 'auto_start' | 'handler_enabled') => {
if (!item.id) {
toast.error('Task ID is missing');
return;
}
const updated = await tasksComposable.patchTask(item.id, { enabled: !item.enabled });
const currentValue = item[field] !== false;
const updated = await tasksComposable.patchTask(item.id, { [field]: !currentValue });
if (updated) {
item.enabled = updated.enabled;
if (updated.enabled) {
item[field] = updated[field];
if (field === 'enabled' && updated.enabled) {
await checkHandlerSupport(updated);
}
}
};
const toggleHandlerEnabled = async (item: Task) => {
if (!item.id) {
toast.error('Task ID is missing');
return;
}
const updated = await tasksComposable.patchTask(item.id, {
handler_enabled: !item.handler_enabled,
});
if (updated) {
item.handler_enabled = updated.handler_enabled;
if (updated.handler_enabled) {
if (field === 'handler_enabled' && updated.handler_enabled) {
await checkHandlerSupport(updated);
}
}
@ -1302,7 +1334,6 @@ const itemActionGroups = (item: Task): DropdownMenuItem[][] => [
{
label: 'Run now',
icon: 'i-lucide-square-play',
color: 'primary',
onSelect: () => void runNow(item),
},
{
@ -1336,7 +1367,6 @@ const itemActionGroups = (item: Task): DropdownMenuItem[][] => [
{
label: 'Export Task',
icon: 'i-lucide-file-up',
color: 'info',
onSelect: () => void exportItem(item),
},
],