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

View file

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

View file

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

View file

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

View file

@ -1,83 +1,17 @@
<template> <template>
<div class="space-y-6"> <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">
<div class="flex flex-wrap items-center gap-2"> <UButton
<UButton type="button"
type="button" color="neutral"
color="neutral" variant="ghost"
variant="ghost" size="sm"
size="sm" :icon="showImport ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
:icon="showImport ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" :disabled="isBusy"
:disabled="isBusy" @click="showImport = !showImport"
@click="showImport = !showImport" >
> {{ showImport ? 'Hide import' : 'Show import' }}
{{ showImport ? 'Hide import' : 'Show import' }} </UButton>
</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> </div>
<div <div
@ -132,7 +66,8 @@
<UButton <UButton
type="button" type="button"
color="primary" color="neutral"
variant="outline"
icon="i-lucide-import" icon="i-lucide-import"
class="justify-center sm:min-w-28" class="justify-center sm:min-w-28"
:disabled="isBusy || !importString.trim()" :disabled="isBusy || !importString.trim()"
@ -219,7 +154,7 @@
<UFormField <UFormField
class="md:col-span-3" class="md:col-span-3"
:ui="fieldUi" :ui="fieldUi"
description="Disabled definitions won't match tasks." description="Whether this definition is active or not."
> >
<template #label> <template #label>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@ -364,7 +299,7 @@
</div> </div>
<div class="grid gap-4 md:grid-cols-12"> <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> <template #label>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-shapes" class="size-4 text-toned" /> <UIcon name="i-lucide-shapes" class="size-4 text-toned" />
@ -383,7 +318,7 @@
/> />
</UFormField> </UFormField>
<UFormField class="md:col-span-8" :ui="fieldUi"> <UFormField class="md:col-span-8" :ui="fieldUi" description="Match expression">
<template #label> <template #label>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-crosshair" class="size-4 text-toned" /> <UIcon name="i-lucide-crosshair" class="size-4 text-toned" />
@ -415,7 +350,8 @@
<UButton <UButton
type="button" type="button"
color="primary" color="neutral"
variant="outline"
size="sm" size="sm"
icon="i-lucide-plus" icon="i-lucide-plus"
:disabled="isBusy" :disabled="isBusy"
@ -517,7 +453,7 @@
<td class="px-3 py-3 text-right"> <td class="px-3 py-3 text-right">
<UButton <UButton
type="button" type="button"
color="error" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-trash" icon="i-lucide-trash"
@ -571,6 +507,77 @@
:description="errorMessage" :description="errorMessage"
/> />
</template> </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> </div>
</template> </template>

View file

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

View file

@ -66,7 +66,7 @@
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"> <div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton <UButton
type="button" type="button"
color="warning" color="neutral"
variant="outline" variant="outline"
icon="i-lucide-rotate-ccw" icon="i-lucide-rotate-ccw"
:disabled="loading" :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" @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 <div
v-if="displayStyle === 'list' && filteredItems.length > 0" 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" 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"> <table class="min-w-225 w-full text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned"> <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"> <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-full text-left">Condition</th>
<th class="w-44 whitespace-nowrap">Actions</th> <th class="w-44 whitespace-nowrap">Actions</th>
</tr> </tr>
@ -99,57 +142,51 @@
<tbody class="divide-y divide-default"> <tbody class="divide-y divide-default">
<tr v-for="cond in filteredItems" :key="cond.id" class="hover:bg-muted/20"> <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"> <td class="px-3 py-3 align-middle">
<div class="space-y-2"> <div class="space-y-2">
<div class="font-semibold text-highlighted">{{ cond.name }}</div> <div class="font-semibold text-highlighted">{{ cond.name }}</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-toned"> <div class="flex flex-wrap items-center gap-3 text-xs text-toned">
<div class="inline-flex items-center gap-2"> <button
<UTooltip type="button"
:text="`Click to ${cond.enabled !== false ? 'disable' : 'enable'} condition`" 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"
<USwitch @click="() => void toggleEnabled(cond)"
:model-value="cond.enabled !== false" >
:disabled="conditions.addInProgress.value" <UIcon
@update:model-value="() => void toggleEnabled(cond)" name="i-lucide-power"
/> class="size-3.5"
</UTooltip> :class="cond.enabled !== false ? 'text-success' : 'text-error'"
/>
<span>{{ cond.enabled !== false ? 'Enabled' : 'Disabled' }}</span> <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" /> <UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Priority: {{ cond.priority }}</span> <span>Priority: {{ cond.priority }}</span>
</span> </span>
</div> </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> </div>
</td> </td>
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap"> <td class="w-44 px-3 py-3 align-middle whitespace-nowrap">
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<UButton <UButton
color="info" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-file-up" icon="i-lucide-file-up"
@ -159,7 +196,7 @@
</UButton> </UButton>
<UButton <UButton
color="warning" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-pencil" icon="i-lucide-pencil"
@ -169,7 +206,7 @@
</UButton> </UButton>
<UButton <UButton
color="error" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-trash" icon="i-lucide-trash"
@ -186,135 +223,172 @@
</div> </div>
<div v-else-if="filteredItems.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div v-else-if="filteredItems.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<UCard <div v-for="cond in filteredItems" :key="cond.id" class="min-w-0 w-full max-w-full">
v-for="cond in filteredItems" <UCard
:key="cond.id" class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
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' }"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }" >
> <template #header>
<template #header> <div class="flex min-w-0 items-start justify-between gap-3">
<div class="flex items-start justify-between gap-3"> <div class="min-w-0 flex-1">
<div class="min-w-0 flex-1 space-y-2"> <div class="flex items-start gap-2">
<button <button
type="button" type="button"
class="min-w-0 w-full text-left text-sm font-semibold text-highlighted" class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
@click="toggleExpand(cond.id, 'name')" @click="toggleExpand(cond.id, 'name')"
> >
<span :class="expandClass(cond.id, 'name')">{{ cond.name }}</span> <span :class="['block', expandClass(cond.id, 'name')]">{{ cond.name }}</span>
</button> </button>
</div>
</div>
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1"> <div class="flex shrink-0 items-center gap-2">
<button <UButton
type="button" color="neutral"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default" variant="ghost"
:disabled="conditions.addInProgress.value" size="xs"
@click="() => void toggleEnabled(cond)" icon="i-lucide-file-up"
square
@click="exportItem(cond)"
> >
<UIcon <span class="hidden sm:inline">Export Condition</span>
name="i-lucide-power" </UButton>
class="size-3.5"
:class="cond.enabled !== false ? 'text-success' : 'text-error'" <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> </label>
</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> </div>
</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 <UButton
color="info" color="neutral"
variant="ghost" variant="outline"
size="xs" icon="i-lucide-trash"
icon="i-lucide-file-up" class="w-full justify-center"
square @click="() => void deleteItem(cond)"
@click="exportItem(cond)" >
/> Delete
</UButton>
</div> </div>
</template> </UCard>
</div>
<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>
</div> </div>
<UAlert <UAlert
@ -402,7 +476,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { useDialog } from '~/composables/useDialog';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import { useConfirm } from '~/composables/useConfirm'; import { useConfirm } from '~/composables/useConfirm';
import { useConditions } from '~/composables/useConditions'; import { useConditions } from '~/composables/useConditions';
import type { Condition } from '~/types/conditions'; import type { Condition } from '~/types/conditions';
@ -414,9 +491,11 @@ type ConditionItemWithUI = Condition & { raw?: boolean };
const box = useConfirm(); const box = useConfirm();
const isMobile = useMediaQuery({ maxWidth: 1024 }); const isMobile = useMediaQuery({ maxWidth: 1024 });
const displayStyle = useStorage<'list' | 'grid'>('conditions_display_style', 'grid'); const displayStyle = useStorage<'list' | 'grid'>('conditions_display_style', 'grid');
const { toggleExpand, expandClass } = useExpandableMeta();
const conditions = useConditions(); const conditions = useConditions();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { confirmDialog } = useDialog();
const items = conditions.conditions as Ref<ConditionItemWithUI[]>; const items = conditions.conditions as Ref<ConditionItemWithUI[]>;
const paging = conditions.pagination; const paging = conditions.pagination;
@ -428,9 +507,10 @@ const editorOpen = ref(false);
const query = ref(''); const query = ref('');
const showFilter = ref(false); const showFilter = ref(false);
const filterInput = ref<HTMLInputElement | null>(null); const filterInput = ref<HTMLInputElement | null>(null);
const selectedIds = ref<number[]>([]);
const massDelete = ref(false);
const removeKeys = ['raw', 'toggle_description']; const removeKeys = ['raw', 'toggle_description'];
const expandedItems = reactive<Record<number, Set<string>>>({});
const filteredItems = computed<ConditionItemWithUI[]>(() => { const filteredItems = computed<ConditionItemWithUI[]>(() => {
const normalizedQuery = query.value?.toLowerCase(); const normalizedQuery = query.value?.toLowerCase();
@ -441,6 +521,30 @@ const filteredItems = computed<ConditionItemWithUI[]>(() => {
return items.value.filter((entry) => deepIncludes(entry, normalizedQuery, new WeakSet())); 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(() => const modalTitle = computed(() =>
itemRef.value ? `Edit - ${item.value.name}` : 'Add new condition', 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 syncPageQuery = async (pageNumber: number): Promise<void> => {
const totalPages = conditions.pagination.value.total_pages; const totalPages = conditions.pagination.value.total_pages;
const nextQuery = { ...route.query }; const nextQuery = { ...route.query };
@ -515,32 +630,13 @@ const toggleDisplayStyle = (): void => {
displayStyle.value = displayStyle.value === 'list' ? 'grid' : 'list'; displayStyle.value = displayStyle.value === 'list' ? 'grid' : 'list';
}; };
const toggleExpand = (itemId: number | undefined, field: string): void => { const toggleMasterSelection = (): void => {
if (!itemId) { if (allSelected.value) {
selectedIds.value = [];
return; return;
} }
if (!expandedItems[itemId]) { selectedIds.value = [...selectableConditionIds.value];
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';
}; };
const extrasEntries = (extras?: Record<string, unknown>): Array<[string, unknown]> => { 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!); 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 ({ const updateItem = async ({
reference, reference,
item: updatedItem, item: updatedItem,

View file

@ -84,6 +84,41 @@
@navigate="navigatePage" @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 <div
v-if="displayStyle === 'list' && filteredItems.length > 0" 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" 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"> <table class="min-w-225 w-full text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned"> <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"> <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-full text-left">Field</th>
<th class="w-44 whitespace-nowrap">Actions</th> <th class="w-44 whitespace-nowrap">Actions</th>
</tr> </tr>
@ -99,29 +142,42 @@
<tbody class="divide-y divide-default"> <tbody class="divide-y divide-default">
<tr v-for="field in filteredItems" :key="field.id" class="hover:bg-muted/20"> <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"> <td class="px-3 py-3 align-middle">
<div class="space-y-2"> <div class="space-y-2">
<div class="font-semibold text-highlighted">{{ field.name }}</div> <div class="font-semibold text-highlighted">{{ field.name }}</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-toned"> <div class="flex flex-wrap items-center gap-3 text-xs text-toned">
<span class="inline-flex items-center gap-1"> <span
<UIcon name="i-lucide-terminal" class="size-3.5" /> class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
<span>{{ field.field }}</span> >
</span>
<span class="inline-flex items-center gap-1">
<UIcon name="i-lucide-list-ordered" class="size-3.5" /> <UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Order: {{ field.order }}</span> <span>Order: {{ field.order }}</span>
</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" /> <UIcon name="i-lucide-shapes" class="size-3.5" />
<span>{{ field.kind }}</span> <span>Type: {{ field.kind }}</span>
</span> </span>
</div>
<div v-if="field.description" class="text-xs text-toned"> <span
{{ field.description }} 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>
</div> </div>
</td> </td>
@ -129,7 +185,7 @@
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap"> <td class="w-44 px-3 py-3 align-middle whitespace-nowrap">
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<UButton <UButton
color="info" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-file-up" icon="i-lucide-file-up"
@ -139,7 +195,7 @@
</UButton> </UButton>
<UButton <UButton
color="warning" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-pencil" icon="i-lucide-pencil"
@ -149,7 +205,7 @@
</UButton> </UButton>
<UButton <UButton
color="error" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-trash" icon="i-lucide-trash"
@ -166,89 +222,125 @@
</div> </div>
<div v-else-if="filteredItems.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div v-else-if="filteredItems.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<UCard <div v-for="field in filteredItems" :key="field.id" class="min-w-0 w-full max-w-full">
v-for="field in filteredItems" <UCard
:key="field.id" class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
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' }"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }" >
> <template #header>
<template #header> <div class="flex min-w-0 items-start justify-between gap-3">
<div class="flex items-start justify-between gap-3"> <div class="min-w-0 flex-1">
<div class="min-w-0 flex-1 space-y-2"> <div class="flex items-start gap-2">
<div class="truncate text-sm font-semibold text-highlighted">{{ field.name }}</div> <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"> <div class="flex shrink-0 items-center gap-2">
<span <UButton
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1" 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 class="hidden sm:inline">Export</span>
<span>Order {{ field.order }}</span> </UButton>
</span>
<span <label class="inline-flex cursor-pointer items-center justify-center">
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1" <input
> v-model="selectedIds"
<UIcon name="i-lucide-shapes" class="size-3.5" /> class="completed-checkbox size-4 rounded border-default"
<span>{{ field.kind }}</span> type="checkbox"
</span> :value="field.id"
/>
</label>
</div> </div>
</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 <UButton
color="info" color="neutral"
variant="ghost" variant="outline"
size="xs" icon="i-lucide-trash"
icon="i-lucide-file-up" class="w-full justify-center"
square @click="() => void deleteItem(field)"
@click="exportItem(field)" >
/> Delete
</UButton>
</div> </div>
</template> </UCard>
</div>
<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>
</div> </div>
<UAlert <UAlert
@ -307,7 +399,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { useDialog } from '~/composables/useDialog';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import { useConfirm } from '~/composables/useConfirm'; import { useConfirm } from '~/composables/useConfirm';
import { useDlFields } from '~/composables/useDlFields'; import { useDlFields } from '~/composables/useDlFields';
import type { DLField } from '~/types/dl_fields'; import type { DLField } from '~/types/dl_fields';
@ -316,10 +411,12 @@ import { copyText, encode } from '~/utils';
const box = useConfirm(); const box = useConfirm();
const isMobile = useMediaQuery({ maxWidth: 1024 }); const isMobile = useMediaQuery({ maxWidth: 1024 });
const { toggleExpand, expandClass } = useExpandableMeta();
const displayStyle = useStorage<'list' | 'grid'>('dl_fields_display_style', 'grid'); const displayStyle = useStorage<'list' | 'grid'>('dl_fields_display_style', 'grid');
const dlFields = useDlFields(); const dlFields = useDlFields();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { confirmDialog } = useDialog();
const items = dlFields.dlFields as Ref<DLField[]>; const items = dlFields.dlFields as Ref<DLField[]>;
const paging = dlFields.pagination; const paging = dlFields.pagination;
@ -331,6 +428,8 @@ const editorOpen = ref(false);
const query = ref(''); const query = ref('');
const showFilter = ref(false); const showFilter = ref(false);
const filterInput = ref<HTMLInputElement | null>(null); const filterInput = ref<HTMLInputElement | null>(null);
const selectedIds = ref<number[]>([]);
const massDelete = ref(false);
const filteredItems = computed<DLField[]>(() => { const filteredItems = computed<DLField[]>(() => {
const normalizedQuery = query.value?.toLowerCase(); const normalizedQuery = query.value?.toLowerCase();
@ -341,6 +440,30 @@ const filteredItems = computed<DLField[]>(() => {
return items.value.filter((entry) => deepIncludes(entry, normalizedQuery, new WeakSet())); 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 modalTitle = computed(() => (itemRef.value ? `Edit - ${item.value.name}` : 'Add new field'));
const modalDescription = computed( const modalDescription = computed(
() => 'Custom fields allow you to add new fields to the download form.', () => '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 syncPageQuery = async (pageNumber: number): Promise<void> => {
const totalPages = dlFields.pagination.value.total_pages; const totalPages = dlFields.pagination.value.total_pages;
const nextQuery = { ...route.query }; const nextQuery = { ...route.query };
@ -413,6 +547,15 @@ const toggleDisplayStyle = (): void => {
displayStyle.value = displayStyle.value === 'list' ? 'grid' : 'list'; 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> => { const deleteItem = async (field: DLField): Promise<void> => {
if (true !== (await box.confirm(`Delete '${field.name}'?`))) { if (true !== (await box.confirm(`Delete '${field.name}'?`))) {
return; return;
@ -421,6 +564,50 @@ const deleteItem = async (field: DLField): Promise<void> => {
await dlFields.deleteDlField(field.id!); 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 ({ const updateItem = async ({
reference, reference,
item: updatedItem, item: updatedItem,

View file

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

View file

@ -76,6 +76,41 @@
</div> </div>
</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 <div
v-if="display_style === 'list' && filteredPresets.length > 0" 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" 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"> <table class="min-w-190 w-full text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned"> <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"> <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-full text-left">Preset</th>
<th class="w-44 whitespace-nowrap">Actions</th> <th class="w-44 whitespace-nowrap">Actions</th>
</tr> </tr>
@ -91,6 +134,17 @@
<tbody class="divide-y divide-default"> <tbody class="divide-y divide-default">
<tr v-for="item in filteredPresets" :key="item.id" class="hover:bg-muted/20"> <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"> <td class="px-3 py-3 align-middle">
<div class="space-y-1"> <div class="space-y-1">
<div class="font-semibold text-highlighted"> <div class="font-semibold text-highlighted">
@ -99,14 +153,20 @@
<div class="flex flex-wrap items-center gap-3 text-xs text-toned"> <div class="flex flex-wrap items-center gap-3 text-xs text-toned">
<span <span
class="inline-flex items-center gap-1" class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
:class="item.cookies ? 'text-info' : ''"
> >
<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>{{ item.cookies ? 'Has cookies' : 'No cookies' }}</span>
</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" /> <UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Priority: {{ item.priority }}</span> <span>Priority: {{ item.priority }}</span>
</span> </span>
@ -117,7 +177,7 @@
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap"> <td class="w-44 px-3 py-3 align-middle whitespace-nowrap">
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<UButton <UButton
color="info" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-file-up" icon="i-lucide-file-up"
@ -127,7 +187,7 @@
</UButton> </UButton>
<UButton <UButton
color="warning" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-pencil" icon="i-lucide-pencil"
@ -137,7 +197,7 @@
</UButton> </UButton>
<UButton <UButton
color="error" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-trash" icon="i-lucide-trash"
@ -154,121 +214,163 @@
</div> </div>
<div v-else-if="filteredPresets.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div v-else-if="filteredPresets.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<UCard <div v-for="item in filteredPresets" :key="item.id" class="min-w-0 w-full max-w-full">
v-for="item in filteredPresets" <UCard
:key="item.id" class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
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' }"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }" >
> <template #header>
<template #header> <div class="flex min-w-0 items-start justify-between gap-3">
<div class="flex items-start justify-between gap-3"> <div class="min-w-0 flex-1">
<button <div class="flex items-start gap-2">
type="button" <button
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted" type="button"
@click="toggleExpand(item.id, 'title')" 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 <span
:class=" class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
!isExpanded(item.id, 'title') ? 'block truncate' : 'block whitespace-pre-wrap'
"
> >
{{ 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>
<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> </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 <UButton
color="info" color="neutral"
variant="ghost" variant="outline"
size="xs" icon="i-lucide-pencil"
icon="i-lucide-file-up" class="w-full justify-center"
square @click="editor.openEdit(item)"
@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' : ''"
> >
<UIcon name="i-lucide-cookie" class="size-3.5" /> Edit
<span>{{ item.cookies ? 'Has cookies' : 'No cookies' }}</span> </UButton>
</span>
<span <UButton
v-if="item.priority > 0" color="neutral"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1" 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" /> Delete
<span>Priority {{ item.priority }}</span> </UButton>
</span>
</div> </div>
</template> </UCard>
</div>
<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>
</div> </div>
<UAlert <UAlert
@ -308,7 +410,8 @@
<ul class="list-disc space-y-2 pl-5 text-sm text-default"> <ul class="list-disc space-y-2 pl-5 text-sm text-default">
<li> <li>
When you export preset, it doesn't include the cookies field contents for security 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> </li>
</ul> </ul>
</template> </template>
@ -339,8 +442,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import type { Preset } from '~/types/presets'; import type { Preset } from '~/types/presets';
import { useDialog } from '~/composables/useDialog';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import { useConfirm } from '~/composables/useConfirm'; import { useConfirm } from '~/composables/useConfirm';
import { prettyName } from '~/utils'; import { prettyName } from '~/utils';
@ -350,6 +456,8 @@ const presetsStore = usePresets();
const config = useConfigStore(); const config = useConfigStore();
const box = useConfirm(); const box = useConfirm();
const editor = usePresetEditor(); const editor = usePresetEditor();
const { confirmDialog } = useDialog();
const { toggleExpand, expandClass } = useExpandableMeta();
const display_style = useStorage<string>('preset_display_style', 'grid'); const display_style = useStorage<string>('preset_display_style', 'grid');
const isMobile = useMediaQuery({ maxWidth: 1024 }); const isMobile = useMediaQuery({ maxWidth: 1024 });
@ -357,10 +465,11 @@ const isMobile = useMediaQuery({ maxWidth: 1024 });
const query = ref(''); const query = ref('');
const showFilter = ref(false); const showFilter = ref(false);
const filterInput = ref<HTMLInputElement | null>(null); const filterInput = ref<HTMLInputElement | null>(null);
const selectedIds = ref<number[]>([]);
const massDelete = ref(false);
const presets = computed(() => presetsStore.presets.value as PresetWithUI[]); const presets = computed(() => presetsStore.presets.value as PresetWithUI[]);
const isLoading = presetsStore.isLoading; const isLoading = presetsStore.isLoading;
const expandedItems = ref<Record<string, Set<string>>>({});
const presetsNoDefault = computed(() => presets.value.filter((item) => !item.default)); 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) => { watch(showFilter, (value) => {
if (!value) { if (!value) {
query.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> => { const toggleFilterPanel = async (): Promise<void> => {
showFilter.value = !showFilter.value; showFilter.value = !showFilter.value;
if (!showFilter.value) { if (!showFilter.value) {
@ -396,6 +540,59 @@ const reloadContent = async (): Promise<void> => {
await presetsStore.loadPresets(1, 1000); 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> => { const deleteItem = async (item: Preset): Promise<void> => {
if (true !== (await box.confirm(`Delete preset '${item.name}'?`))) { if (true !== (await box.confirm(`Delete preset '${item.name}'?`))) {
return; return;
@ -410,35 +607,6 @@ const toggleDisplayStyle = (): void => {
display_style.value = display_style.value === 'list' ? 'grid' : 'list'; 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 exportItem = (item: Preset): void => {
const excludedKeys = ['id', 'default', 'raw', 'cookies', 'toggle_description']; const excludedKeys = ['id', 'default', 'raw', 'cookies', 'toggle_description'];
const userData = Object.fromEntries( const userData = Object.fromEntries(

View file

@ -93,6 +93,41 @@
:description="lastError" :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 <div
v-if="display_style === 'list' && filteredDefinitions.length > 0" 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" 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"> <table class="min-w-245 w-full text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned"> <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"> <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-full text-left">Definition</th>
<th class="w-28 whitespace-nowrap">Priority</th> <th class="w-28 whitespace-nowrap">Priority</th>
<th class="w-36 whitespace-nowrap">Updated</th> <th class="w-36 whitespace-nowrap">Updated</th>
@ -114,6 +157,17 @@
:key="definition.id" :key="definition.id"
class="hover:bg-muted/20" 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"> <td class="px-3 py-3 align-middle">
<div class="space-y-1"> <div class="space-y-1">
<div class="font-semibold text-highlighted"> <div class="font-semibold text-highlighted">
@ -123,7 +177,7 @@
<div class="flex flex-wrap items-center gap-3 text-xs text-toned"> <div class="flex flex-wrap items-center gap-3 text-xs text-toned">
<button <button
type="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)" @click="() => void toggle(definition)"
> >
<UIcon <UIcon
@ -134,13 +188,11 @@
<span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span> <span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span>
</button> </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" /> <UIcon name="i-lucide-link" class="size-3.5" />
<span <span>Patterns: {{ definition.match_url.length }} match/s</span>
>{{ definition.match_url.length }} match pattern{{
definition.match_url.length === 1 ? '' : 's'
}}</span
>
</span> </span>
</div> </div>
</div> </div>
@ -161,7 +213,7 @@
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap"> <td class="w-44 px-3 py-3 align-middle whitespace-nowrap">
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<UButton <UButton
color="info" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-file-up" icon="i-lucide-file-up"
@ -171,7 +223,7 @@
</UButton> </UButton>
<UButton <UButton
color="warning" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-pencil" icon="i-lucide-pencil"
@ -181,7 +233,7 @@
</UButton> </UButton>
<UButton <UButton
color="error" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-trash" icon="i-lucide-trash"
@ -201,110 +253,116 @@
v-else-if="filteredDefinitions.length > 0" v-else-if="filteredDefinitions.length > 0"
class="grid gap-4 md:grid-cols-2 xl:grid-cols-3" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
> >
<UCard <div
v-for="definition in filteredDefinitions" v-for="definition in filteredDefinitions"
:key="definition.id" :key="definition.id"
class="flex h-full flex-col border bg-default" class="min-w-0 w-full max-w-full"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
> >
<template #header> <UCard
<div class="flex items-start justify-between gap-3"> class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
<div class="min-w-0 flex-1 space-y-2"> :ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
<div class="text-sm font-semibold text-highlighted wrap-break-word"> >
{{ definition.name || '(Unnamed definition)' }} <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>
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1"> <div class="flex shrink-0 items-center gap-2">
<button <UButton
type="button" color="neutral"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default" variant="ghost"
@click="() => void toggle(definition)" size="xs"
icon="i-lucide-file-up"
square
@click="() => void exportDefinition(definition)"
> >
<UIcon <span class="hidden sm:inline">Export Definition</span>
name="i-lucide-power" </UButton>
class="size-3.5"
:class="definition.enabled ? 'text-success' : 'text-error'" <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> </label>
</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>
</div> </div>
</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 <UButton
color="info" color="neutral"
variant="ghost" variant="outline"
size="xs" icon="i-lucide-trash"
icon="i-lucide-file-up" class="w-full justify-center"
square @click="() => void remove(definition)"
@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"
> >
<UIcon name="i-lucide-link" class="size-3.5" /> Delete
<span>Match patterns</span> </UButton>
</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>
</div> </div>
</div> </UCard>
</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>
</div> </div>
<UAlert <UAlert
@ -378,10 +436,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui';
import moment from 'moment'; import moment from 'moment';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import useTaskDefinitionsComposable from '~/composables/useTaskDefinitions'; import useTaskDefinitionsComposable from '~/composables/useTaskDefinitions';
import { useDialog } from '~/composables/useDialog'; import { useDialog } from '~/composables/useDialog';
import { useMediaQuery } from '~/composables/useMediaQuery'; import { useMediaQuery } from '~/composables/useMediaQuery';
@ -413,6 +473,7 @@ const DEFAULT_DEFINITION: TaskDefinitionDocument = {
}; };
const isMobile = useMediaQuery({ maxWidth: 1024 }); const isMobile = useMediaQuery({ maxWidth: 1024 });
const { toggleExpand, expandClass } = useExpandableMeta();
const taskDefs = useTaskDefinitionsComposable(); const taskDefs = useTaskDefinitionsComposable();
const definitionsRef = taskDefs.definitions; const definitionsRef = taskDefs.definitions;
@ -442,6 +503,8 @@ const query = ref('');
const showFilter = ref(false); const showFilter = ref(false);
const filterInput = ref<HTMLInputElement | null>(null); const filterInput = ref<HTMLInputElement | null>(null);
const hideImportByDefault = ref(false); const hideImportByDefault = ref(false);
const selectedIds = ref<number[]>([]);
const massDelete = ref(false);
const filteredDefinitions = computed<TaskDefinitionSummary[]>(() => { const filteredDefinitions = computed<TaskDefinitionSummary[]>(() => {
const normalizedQuery = query.value.trim().toLowerCase(); 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>(() => { const currentSummary = computed<TaskDefinitionSummary | undefined>(() => {
if (editorMode.value !== 'edit' || !workingId.value) { if (editorMode.value !== 'edit' || !workingId.value) {
return undefined; 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 => { const cloneDocument = (document: TaskDefinitionDocument): TaskDefinitionDocument => {
return JSON.parse(JSON.stringify(document)) as 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'; 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 => { const openCreate = (): void => {
editorMode.value = 'create'; editorMode.value = 'create';
workingId.value = null; workingId.value = null;
@ -625,6 +734,47 @@ const remove = async (summary: TaskDefinitionSummary): Promise<void> => {
await deleteDefinition(summary.id); 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> => { const toggle = async (summary: TaskDefinitionSummary): Promise<void> => {
await toggleEnabled(summary.id, !summary.enabled); await toggleEnabled(summary.id, !summary.enabled);
}; };

View file

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