This commit is contained in:
arabcoders 2026-04-15 20:16:19 +03:00
parent e7fd60010b
commit bb246cc6f5
15 changed files with 968 additions and 725 deletions

View file

@ -1,12 +1,15 @@
<template> <template>
<div class="w-full min-w-0 max-w-full space-y-4"> <div class="w-full min-w-0 max-w-full space-y-4">
<div v-if="hasItems" class="flex flex-wrap items-center justify-between gap-3"> <div
v-if="hasItems"
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"> <div class="flex flex-wrap items-center gap-2">
<UButton <UButton
color="neutral" color="neutral"
variant="outline" variant="outline"
size="sm" size="sm"
:icon="masterSelectAll ? 'i-lucide-square' : 'i-lucide-check'" :icon="masterSelectAll ? 'i-lucide-square' : 'i-lucide-square-check-big'"
@click="toggleMasterSelection" @click="toggleMasterSelection"
> >
{{ masterSelectAll ? 'Unselect' : 'Select' }} {{ masterSelectAll ? 'Unselect' : 'Select' }}
@ -34,9 +37,10 @@
variant="outline" variant="outline"
size="sm" size="sm"
:icon="direction === 'desc' ? 'i-lucide-arrow-down-a-z' : 'i-lucide-arrow-up-a-z'" :icon="direction === 'desc' ? 'i-lucide-arrow-down-a-z' : 'i-lucide-arrow-up-a-z'"
square
@click="toggleDirection" @click="toggleDirection"
/> >
<span>Sort</span>
</UButton>
</div> </div>
<UAlert <UAlert
@ -48,17 +52,24 @@
/> />
<div <div
v-if="'list' === display_style && hasItems" v-if="'list' === contentStyle && hasItems"
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"
> >
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain"> <div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-190 table-fixed w-full text-sm"> <table class="min-w-210 table-fixed 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]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th class="w-12"> <th class="w-12">
<button type="button" class="cursor-pointer" @click="toggleMasterSelection"> <button
type="button"
class="cursor-pointer"
:aria-label="masterSelectAll ? 'Unselect all items' : 'Select all items'"
@click="toggleMasterSelection"
>
<UIcon <UIcon
:name="masterSelectAll ? 'i-lucide-square' : 'i-lucide-check'" :name="masterSelectAll ? 'i-lucide-square' : 'i-lucide-square-check-big'"
class="size-4" class="size-4"
/> />
</button> </button>
@ -67,13 +78,17 @@
<th class="w-32 whitespace-nowrap">Status</th> <th class="w-32 whitespace-nowrap">Status</th>
<th class="w-36 whitespace-nowrap">Created</th> <th class="w-36 whitespace-nowrap">Created</th>
<th class="w-36 whitespace-nowrap">Size/Starts</th> <th class="w-36 whitespace-nowrap">Size/Starts</th>
<th class="w-44 whitespace-nowrap">Actions</th> <th class="w-80 whitespace-nowrap">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-default"> <tbody class="divide-y divide-default">
<tr v-for="item in displayedItems" :key="item._id" class="align-top hover:bg-muted/20"> <tr
<td class="px-3 py-3 text-center align-top"> v-for="item in displayedItems"
:key="item._id"
class="align-top transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
>
<td class="border-r border-default/60 px-3 py-3 text-center align-top">
<label class="inline-flex cursor-pointer items-center justify-center"> <label class="inline-flex cursor-pointer items-center justify-center">
<input <input
:id="`checkbox-${item._id}`" :id="`checkbox-${item._id}`"
@ -85,7 +100,7 @@
</label> </label>
</td> </td>
<td class="w-0 px-3 py-3 align-top"> <td class="w-0 border-r border-default/60 px-3 py-3 align-top">
<div class="flex min-w-0 items-start justify-between gap-3"> <div class="flex min-w-0 items-start justify-between gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<UTooltip <UTooltip
@ -174,7 +189,7 @@
</p> </p>
</td> </td>
<td class="px-3 py-3 text-center align-top text-sm"> <td class="border-r border-default/60 px-3 py-3 text-center align-top text-sm">
<div class="inline-flex items-center gap-2 text-default"> <div class="inline-flex items-center gap-2 text-default">
<span class="inline-flex items-center"> <span class="inline-flex items-center">
<UIcon <UIcon
@ -186,13 +201,17 @@
</div> </div>
</td> </td>
<td class="px-3 py-3 text-center align-top text-sm text-toned whitespace-nowrap"> <td
class="border-r border-default/60 px-3 py-3 text-center align-top text-sm text-toned whitespace-nowrap"
>
<UTooltip :text="moment(item.datetime).format('YYYY-M-DD H:mm Z')"> <UTooltip :text="moment(item.datetime).format('YYYY-M-DD H:mm Z')">
<span :date-datetime="item.datetime" v-rtime="item.datetime" /> <span :date-datetime="item.datetime" v-rtime="item.datetime" />
</UTooltip> </UTooltip>
</td> </td>
<td class="px-3 py-3 text-center align-top text-sm text-toned whitespace-nowrap"> <td
class="border-r border-default/60 px-3 py-3 text-center align-top text-sm text-toned whitespace-nowrap"
>
<template <template
v-if="'not_live' === item.status && (item.live_in || item.extras?.release_in)" v-if="'not_live' === item.status && (item.live_in || item.extras?.release_in)"
> >
@ -211,38 +230,41 @@
</template> </template>
</td> </td>
<td class="w-44 px-3 py-3 align-top whitespace-nowrap"> <td class="w-80 px-3 py-3 align-top whitespace-nowrap">
<div class="flex items-center justify-end gap-1"> <div class="flex items-center justify-end gap-1">
<UButton <UButton
v-if="showRetryAction(item)" v-if="showRetryAction(item)"
color="warning" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-rotate-cw" icon="i-lucide-rotate-cw"
square
@click="() => void retryItem(item, true)" @click="() => void retryItem(item, true)"
/> >
Retry
</UButton>
<UButton <UButton
v-if="item.filename" v-if="item.filename"
color="info" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-download" icon="i-lucide-download"
square
external external
:href="makeDownload(config, item)" :href="makeDownload(config, item)"
:download="item.filename?.split('/').reverse()[0]" :download="item.filename?.split('/').reverse()[0]"
/> >
Download
</UButton>
<UButton <UButton
color="error" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-trash" icon="i-lucide-trash"
square
@click="() => void removeItem(item)" @click="() => void removeItem(item)"
/> >
Remove
</UButton>
<UDropdownMenu v-if="item.url" :items="itemActionGroups(item)" :modal="false"> <UDropdownMenu v-if="item.url" :items="itemActionGroups(item)" :modal="false">
<UButton <UButton
@ -273,8 +295,12 @@
> >
<UCard <UCard
class="flex h-full min-w-0 w-full max-w-full flex-col overflow-hidden border" class="flex h-full min-w-0 w-full max-w-full flex-col overflow-hidden border"
:class="historyCardClass(item)" :ui="{
:ui="{ body: 'flex flex-1 flex-col gap-4 p-4', header: 'p-4 pb-3', root: 'bg-default' }" body: 'flex flex-1 flex-col gap-4 p-4',
footer: 'border-t border-default px-4 py-4',
header: 'p-4 pb-3',
root: 'bg-default',
}"
> >
<template #header> <template #header>
<div class="flex min-w-0 flex-wrap items-start justify-between gap-3"> <div class="flex min-w-0 flex-wrap items-start justify-between gap-3">
@ -390,102 +416,85 @@
</div> </div>
<div class="flex flex-wrap gap-2 text-sm *:min-w-32 *:flex-1"> <div class="flex flex-wrap gap-2 text-sm *:min-w-32 *:flex-1">
<div <button
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-center text-default" type="button"
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-default transition hover:border-primary hover:text-default"
@click="toggleExpand(item._id, 'status')"
> >
<span class="inline-flex items-center gap-2"> <span class="inline-flex w-full items-center justify-center gap-2">
<UIcon <UIcon
:name="setIcon(item)" :name="setIcon(item)"
:class="[setIconColor(item), isQueuedAnimation(item), 'size-4 shrink-0']" :class="[setIconColor(item), isQueuedAnimation(item), 'size-4 shrink-0']"
/> />
<span>{{ setStatus(item) }}</span> <span :class="['min-w-0 text-center', expandClass(item._id, 'status')]">
{{ setStatus(item) }}
</span>
</span> </span>
</div> </button>
<div <button
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-center text-default" type="button"
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-default transition hover:border-primary hover:text-default"
@click="toggleExpand(item._id, 'preset')"
> >
<UTooltip :text="`Preset: ${item.preset}`"> <span class="inline-flex w-full items-center justify-center gap-2">
<span class="block min-w-0 truncate">{{ item.preset }}</span> <UIcon name="i-lucide-sliders-horizontal" class="size-4 shrink-0 text-toned" />
</UTooltip> <span :class="['min-w-0 text-center', expandClass(item._id, 'preset')]">
</div> {{ item.preset }}
</span>
</span>
</button>
<div <button
v-if="'not_live' === item.status && (item.live_in || item.extras?.release_in)" v-if="'not_live' === item.status && (item.live_in || item.extras?.release_in)"
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-center text-toned" type="button"
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-toned transition hover:border-primary hover:text-default"
@click="toggleExpand(item._id, 'retry_at')"
> >
<UTooltip <UTooltip
:text="`Retry at: ${moment(item.live_in || item.extras?.release_in).format('YYYY-M-DD H:mm Z')}`" :text="`Retry at: ${moment(item.live_in || item.extras?.release_in).format('YYYY-M-DD H:mm Z')}`"
> >
<span <span class="inline-flex w-full items-center justify-center gap-2">
:date-datetime="item.live_in || item.extras?.release_in" <UIcon name="i-lucide-calendar" class="size-4 shrink-0 text-toned" />
v-rtime="item.live_in || item.extras?.release_in" <span
/> :class="['min-w-0 text-center', expandClass(item._id, 'retry_at')]"
:date-datetime="item.live_in || item.extras?.release_in"
v-rtime="item.live_in || item.extras?.release_in"
/>
</span>
</UTooltip> </UTooltip>
</div> </button>
<div <button
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-center text-toned" type="button"
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-toned transition hover:border-primary hover:text-default"
@click="toggleExpand(item._id, 'datetime')"
> >
<UTooltip :text="moment(item.datetime).format('YYYY-M-DD H:mm Z')"> <UTooltip :text="moment(item.datetime).format('YYYY-M-DD H:mm Z')">
<span :date-datetime="item.datetime" v-rtime="item.datetime" /> <span class="inline-flex w-full items-center justify-center gap-2">
<UIcon name="i-lucide-clock-3" class="size-4 shrink-0 text-toned" />
<span
:class="['min-w-0 text-center', expandClass(item._id, 'datetime')]"
:date-datetime="item.datetime"
v-rtime="item.datetime"
/>
</span>
</UTooltip> </UTooltip>
</div> </button>
<div <button
v-if="item.file_size" v-if="item.file_size"
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-center text-toned" type="button"
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-toned transition hover:border-primary hover:text-default"
@click="toggleExpand(item._id, 'size')"
> >
{{ formatBytes(item.file_size) }} <span class="inline-flex w-full items-center justify-center gap-2">
</div> <UIcon name="i-lucide-hard-drive" class="size-4 shrink-0 text-toned" />
</div> <span :class="['min-w-0 text-center', expandClass(item._id, 'size')]">
{{ formatBytes(item.file_size) }}
<div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1"> </span>
<UButton </span>
v-if="showRetryAction(item)" </button>
color="warning"
variant="outline"
icon="i-lucide-rotate-cw"
class="w-full justify-center"
@click="() => void retryItem(item, false)"
>
Retry
</UButton>
<UButton
v-if="item.filename"
color="info"
variant="outline"
icon="i-lucide-download"
class="w-full justify-center"
external
:href="makeDownload(config, item)"
:download="item.filename?.split('/').reverse()[0]"
>
Download
</UButton>
<UButton
color="error"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void removeItem(item)"
>
{{ config.app.remove_files ? 'Remove' : 'Clear' }}
</UButton>
<UDropdownMenu :items="itemActionGroups(item)" :modal="false" class="w-full">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-settings-2"
trailing-icon="i-lucide-chevron-down"
class="w-full justify-center"
>
Actions
</UButton>
</UDropdownMenu>
</div> </div>
<div <div
@ -508,6 +517,56 @@
{{ item.msg }} {{ item.msg }}
</p> </p>
</div> </div>
<template #footer>
<div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
<UButton
v-if="showRetryAction(item)"
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="w-full justify-center"
@click="() => void retryItem(item, false)"
>
Retry
</UButton>
<UButton
v-if="item.filename"
color="neutral"
variant="outline"
icon="i-lucide-download"
class="w-full justify-center"
external
:href="makeDownload(config, item)"
:download="item.filename?.split('/').reverse()[0]"
>
Download
</UButton>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void removeItem(item)"
>
{{ config.app.remove_files ? 'Remove' : 'Clear' }}
</UButton>
<UDropdownMenu :items="itemActionGroups(item)" :modal="false" class="w-full">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-settings-2"
trailing-icon="i-lucide-chevron-down"
class="w-full justify-center"
>
Actions
</UButton>
</UDropdownMenu>
</div>
</template>
</UCard> </UCard>
</LateLoader> </LateLoader>
</div> </div>
@ -614,6 +673,7 @@
import moment from 'moment'; import moment from 'moment';
import { useStorage, useIntersectionObserver } from '@vueuse/core'; import { useStorage, useIntersectionObserver } from '@vueuse/core';
import { useDialog } from '~/composables/useDialog'; import { useDialog } from '~/composables/useDialog';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import type { StoreItem } from '~/types/store'; import type { StoreItem } from '~/types/store';
import { useConfirm } from '~/composables/useConfirm'; import { useConfirm } from '~/composables/useConfirm';
import { deepIncludes, getPath, getImage, isDownloadSkipped } from '~/utils'; import { deepIncludes, getPath, getImage, isDownloadSkipped } from '~/utils';
@ -637,11 +697,13 @@ const socket = useSocketStore();
const toast = useNotification(); const toast = useNotification();
const box = useConfirm(); const box = useConfirm();
const { confirmDialog } = useDialog(); const { confirmDialog } = useDialog();
const { toggleExpand, expandClass } = useExpandableMeta();
const showCompleted = useStorage<boolean>('showCompleted', true); const showCompleted = useStorage<boolean>('showCompleted', true);
const hideThumbnail = useStorage<boolean>('hideThumbnailHistory', false); const hideThumbnail = useStorage<boolean>('hideThumbnailHistory', false);
const direction = useStorage<'asc' | 'desc'>('sortCompleted', 'desc'); const direction = useStorage<'asc' | 'desc'>('sortCompleted', 'desc');
const display_style = useStorage<'grid' | 'list'>('display_style', 'grid'); const display_style = useStorage<'grid' | 'list'>('display_style', 'grid');
const isMobile = useMediaQuery({ maxWidth: 639 });
const bg_enable = useStorage<boolean>('random_bg', true); const bg_enable = useStorage<boolean>('random_bg', true);
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95); const bg_opacity = useStorage<number>('random_bg_opacity', 0.95);
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1'); const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1');
@ -654,6 +716,10 @@ const video_item = ref<StoreItem | null>(null);
const loadMoreTrigger = ref<HTMLElement | null>(null); const loadMoreTrigger = ref<HTMLElement | null>(null);
const expandedMessages = reactive<Record<string, Set<string>>>({}); const expandedMessages = reactive<Record<string, Set<string>>>({});
const contentStyle = computed<'grid' | 'list'>(() =>
isMobile.value ? 'grid' : display_style.value,
);
const paginationInfo = computed(() => stateStore.getPagination()); const paginationInfo = computed(() => stateStore.getPagination());
// Function to load more history items // Function to load more history items
@ -818,18 +884,6 @@ const getGridImage = (item: StoreItem): string => getImage(config.app.download_p
const showRetryAction = (item: StoreItem): boolean => !item.filename && !isDownloadSkipped(item); const showRetryAction = (item: StoreItem): boolean => !item.filename && !isDownloadSkipped(item);
const historyCardClass = (item: StoreItem): string => {
if (item.status === 'error') {
return 'border-error';
}
if (item.live_in || item.is_live) {
return 'border-info';
}
return 'border-default';
};
const bulkActionGroups = computed(() => { const bulkActionGroups = computed(() => {
const groups: Array<Array<Record<string, unknown>>> = [ const groups: Array<Array<Record<string, unknown>>> = [
[ [
@ -842,7 +896,6 @@ const bulkActionGroups = computed(() => {
{ {
label: config.app.remove_files ? 'Remove' : 'Clear', label: config.app.remove_files ? 'Remove' : 'Clear',
icon: 'i-lucide-trash', icon: 'i-lucide-trash',
color: 'error',
disabled: !hasSelected.value, disabled: !hasSelected.value,
onSelect: () => void deleteSelectedItems(), onSelect: () => void deleteSelectedItems(),
}, },
@ -869,7 +922,6 @@ const bulkActionGroups = computed(() => {
{ {
label: 'Retry Incomplete', label: 'Retry Incomplete',
icon: 'i-lucide-rotate-cw', icon: 'i-lucide-rotate-cw',
color: 'warning',
onSelect: () => void retryIncomplete(), onSelect: () => void retryIncomplete(),
}, },
); );
@ -897,7 +949,6 @@ const itemActionGroups = (item: StoreItem) => {
mediaActions.push({ mediaActions.push({
label: 'Retry download', label: 'Retry download',
icon: 'i-lucide-rotate-cw', icon: 'i-lucide-rotate-cw',
color: 'warning',
onSelect: () => void retryItem(item, true), onSelect: () => void retryItem(item, true),
}); });
} }
@ -905,14 +956,12 @@ const itemActionGroups = (item: StoreItem) => {
mediaActions.push({ mediaActions.push({
label: 'Generate NFO', label: 'Generate NFO',
icon: 'i-lucide-file-code-2', icon: 'i-lucide-file-code-2',
color: 'info',
onSelect: () => void generateNfo(item), onSelect: () => void generateNfo(item),
}); });
} else if (isEmbedable(item.url)) { } else if (isEmbedable(item.url)) {
mediaActions.push({ mediaActions.push({
label: 'Play video', label: 'Play video',
icon: 'i-lucide-play', icon: 'i-lucide-play',
color: 'error',
onSelect: () => { onSelect: () => {
embed_url.value = getEmbedable(item.url) as string; embed_url.value = getEmbedable(item.url) as string;
}, },
@ -946,7 +995,6 @@ const itemActionGroups = (item: StoreItem) => {
{ {
label: 'Add to archive', label: 'Add to archive',
icon: 'i-lucide-archive', icon: 'i-lucide-archive',
color: 'error',
onSelect: () => addArchiveDialog(item), onSelect: () => addArchiveDialog(item),
}, },
]); ]);
@ -957,7 +1005,6 @@ const itemActionGroups = (item: StoreItem) => {
{ {
label: 'Remove from archive', label: 'Remove from archive',
icon: 'i-lucide-archive-x', icon: 'i-lucide-archive-x',
color: 'error',
onSelect: () => removeFromArchiveDialog(item), onSelect: () => removeFromArchiveDialog(item),
}, },
]); ]);
@ -1113,7 +1160,7 @@ const setStatus = (item: StoreItem) => {
if (item.extras?.is_premiere) { if (item.extras?.is_premiere) {
return 'Premiere'; return 'Premiere';
} }
return display_style.value === 'grid' ? 'Stream' : 'Live'; return 'Live';
} }
if ('skip' === item.status) { if ('skip' === item.status) {
return 'Skipped'; return 'Skipped';

View file

@ -13,13 +13,16 @@
</UButton> </UButton>
</div> </div>
<div v-if="hasItems" class="flex flex-wrap items-center justify-between gap-3"> <div
v-if="hasItems"
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"> <div class="flex flex-wrap items-center gap-2">
<UButton <UButton
color="neutral" color="neutral"
variant="outline" variant="outline"
size="sm" size="sm"
:icon="masterSelectAll ? 'i-lucide-square' : 'i-lucide-check'" :icon="masterSelectAll ? 'i-lucide-square' : 'i-lucide-square-check-big'"
@click="toggleMasterSelection" @click="toggleMasterSelection"
> >
{{ masterSelectAll ? 'Unselect' : 'Select' }} {{ masterSelectAll ? 'Unselect' : 'Select' }}
@ -44,17 +47,24 @@
</div> </div>
<div <div
v-if="'list' === display_style && hasItems" v-if="'list' === contentStyle && hasItems"
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"
> >
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain"> <div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-190 table-fixed w-full text-sm"> <table class="min-w-210 table-fixed 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]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th class="w-12"> <th class="w-12">
<button type="button" class="cursor-pointer" @click="toggleMasterSelection"> <button
type="button"
class="cursor-pointer"
:aria-label="masterSelectAll ? 'Unselect all items' : 'Select all items'"
@click="toggleMasterSelection"
>
<UIcon <UIcon
:name="masterSelectAll ? 'i-lucide-square' : 'i-lucide-check'" :name="masterSelectAll ? 'i-lucide-square' : 'i-lucide-square-check-big'"
class="size-4" class="size-4"
/> />
</button> </button>
@ -63,13 +73,17 @@
<th class="w-56">Progress</th> <th class="w-56">Progress</th>
<th class="w-32 whitespace-nowrap">Status</th> <th class="w-32 whitespace-nowrap">Status</th>
<th class="w-36 whitespace-nowrap">Created</th> <th class="w-36 whitespace-nowrap">Created</th>
<th class="w-44 whitespace-nowrap">Actions</th> <th class="w-80 whitespace-nowrap">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-default"> <tbody class="divide-y divide-default">
<tr v-for="item in displayedItems" :key="item._id" class="align-top hover:bg-muted/20"> <tr
<td class="px-3 py-3 text-center align-top"> v-for="item in displayedItems"
:key="item._id"
class="align-top transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
>
<td class="border-r border-default/60 px-3 py-3 text-center align-top">
<label class="inline-flex cursor-pointer items-center justify-center"> <label class="inline-flex cursor-pointer items-center justify-center">
<input <input
:id="`checkbox-${item._id}`" :id="`checkbox-${item._id}`"
@ -81,7 +95,7 @@
</label> </label>
</td> </td>
<td class="px-3 py-3 align-top"> <td class="border-r border-default/60 px-3 py-3 align-top">
<div class="flex 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">
<UTooltip :text="`[${item.preset}] - ${item.title}`"> <UTooltip :text="`[${item.preset}] - ${item.title}`">
@ -153,7 +167,7 @@
</div> </div>
</td> </td>
<td class="w-56 px-3 py-3 align-top"> <td class="w-56 border-r border-default/60 px-3 py-3 align-top">
<div <div
class="queue-progress queue-progress--compact w-56 rounded-md border border-default bg-muted/20" class="queue-progress queue-progress--compact w-56 rounded-md border border-default bg-muted/20"
> >
@ -180,7 +194,7 @@
</div> </div>
</td> </td>
<td class="px-3 py-3 text-center align-top text-sm"> <td class="border-r border-default/60 px-3 py-3 text-center align-top text-sm">
<div class="inline-flex items-center gap-2 text-default whitespace-nowrap"> <div class="inline-flex items-center gap-2 text-default whitespace-nowrap">
<span class="inline-flex items-center"> <span class="inline-flex items-center">
<UIcon <UIcon
@ -192,44 +206,47 @@
</div> </div>
</td> </td>
<td class="px-3 py-3 text-center align-top text-sm text-toned whitespace-nowrap"> <td
class="border-r border-default/60 px-3 py-3 text-center align-top text-sm text-toned whitespace-nowrap"
>
<UTooltip :text="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')"> <UTooltip :text="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')">
<span :data-datetime="item.datetime" v-rtime="item.datetime" /> <span :data-datetime="item.datetime" v-rtime="item.datetime" />
</UTooltip> </UTooltip>
</td> </td>
<td class="w-44 px-3 py-3 align-top whitespace-nowrap"> <td class="w-80 px-3 py-3 align-top whitespace-nowrap">
<div class="flex items-center justify-end gap-1"> <div class="flex items-center justify-end gap-1">
<UButton <UButton
color="warning" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-circle-off" icon="i-lucide-circle-off"
square
:title="item.is_live ? 'Stop Stream' : 'Cancel Download'"
:aria-label="item.is_live ? 'Stop Stream' : 'Cancel Download'"
@click="() => void confirmCancel(item)" @click="() => void confirmCancel(item)"
/> >
{{ item.is_live ? 'Stop Stream' : 'Cancel' }}
</UButton>
<UButton <UButton
v-if="canStartItem(item)" v-if="canStartItem(item)"
color="success" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-circle-play" icon="i-lucide-circle-play"
square
@click="() => void startItem(item)" @click="() => void startItem(item)"
/> >
Start
</UButton>
<UButton <UButton
v-if="canPauseItem(item)" v-if="canPauseItem(item)"
color="warning" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-pause" icon="i-lucide-pause"
square
@click="() => void pauseItem(item)" @click="() => void pauseItem(item)"
/> >
Pause
</UButton>
<UDropdownMenu :items="itemActionGroups(item)" :modal="false"> <UDropdownMenu :items="itemActionGroups(item)" :modal="false">
<UButton <UButton
@ -260,8 +277,12 @@
> >
<UCard <UCard
class="flex h-full min-w-0 w-full max-w-full flex-col overflow-hidden border" class="flex h-full min-w-0 w-full max-w-full flex-col overflow-hidden border"
:class="queueCardClass(item)" :ui="{
:ui="{ body: 'flex flex-1 flex-col gap-4 p-4', header: 'p-4 pb-3', root: 'bg-default' }" body: 'flex flex-1 flex-col gap-4 p-4',
footer: 'border-t border-default px-4 py-4',
header: 'p-4 pb-3',
root: 'bg-default',
}"
> >
<template #header> <template #header>
<div class="flex min-w-0 flex-wrap items-start justify-between gap-3"> <div class="flex min-w-0 flex-wrap items-start justify-between gap-3">
@ -387,87 +408,114 @@
</div> </div>
<div class="flex flex-wrap gap-2 text-sm *:min-w-32 *:flex-1"> <div class="flex flex-wrap gap-2 text-sm *:min-w-32 *:flex-1">
<div <button
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-center text-default" type="button"
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-default transition hover:border-primary hover:text-default"
@click="toggleExpand(item._id, 'status')"
> >
<span class="inline-flex items-center gap-2"> <span class="inline-flex w-full items-center justify-center gap-2">
<UIcon <UIcon
:name="setIcon(item)" :name="setIcon(item)"
:class="[setIconColor(item), setIconAnimation(item), 'size-4 shrink-0']" :class="[setIconColor(item), setIconAnimation(item), 'size-4 shrink-0']"
/> />
<span>{{ setStatus(item) }}</span> <span :class="['min-w-0 text-center', expandClass(item._id, 'status')]">
{{ setStatus(item) }}
</span>
</span> </span>
</div> </button>
<div <button
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-center text-default" type="button"
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-default transition hover:border-primary hover:text-default"
@click="toggleExpand(item._id, 'preset')"
> >
<UTooltip :text="`Preset: ${item.preset}`"> <span class="inline-flex w-full items-center justify-center gap-2">
<span class="block min-w-0 truncate">{{ item.preset }}</span> <UIcon name="i-lucide-sliders-horizontal" class="size-4 shrink-0 text-toned" />
</UTooltip> <span :class="['min-w-0 text-center', expandClass(item._id, 'preset')]">
</div> {{ item.preset }}
</span>
</span>
</button>
<div <button
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-center text-toned" type="button"
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-toned transition hover:border-primary hover:text-default"
@click="toggleExpand(item._id, 'datetime')"
> >
<UTooltip :text="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')"> <UTooltip :text="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')">
<span :data-datetime="item.datetime" v-rtime="item.datetime" /> <span class="inline-flex w-full items-center justify-center gap-2">
<UIcon name="i-lucide-clock-3" class="size-4 shrink-0 text-toned" />
<span
:class="['min-w-0 text-center', expandClass(item._id, 'datetime')]"
:data-datetime="item.datetime"
v-rtime="item.datetime"
/>
</span>
</UTooltip> </UTooltip>
</div> </button>
<div <button
v-if="item.downloaded_bytes" v-if="item.downloaded_bytes"
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-center text-toned" type="button"
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-toned transition hover:border-primary hover:text-default"
@click="toggleExpand(item._id, 'size')"
> >
{{ formatBytes(item.downloaded_bytes) }} <span class="inline-flex w-full items-center justify-center gap-2">
</div> <UIcon name="i-lucide-hard-drive" class="size-4 shrink-0 text-toned" />
<span :class="['min-w-0 text-center', expandClass(item._id, 'size')]">
{{ formatBytes(item.downloaded_bytes) }}
</span>
</span>
</button>
</div> </div>
<div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1"> <template #footer>
<UButton <div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
color="warning"
variant="outline"
icon="i-lucide-circle-off"
class="w-full justify-center"
@click="() => void confirmCancel(item)"
>
{{ item.is_live ? 'Stop' : 'Cancel' }}
</UButton>
<UButton
v-if="canStartItem(item)"
color="success"
variant="outline"
icon="i-lucide-circle-play"
class="w-full justify-center"
@click="() => void startItem(item)"
>
Start
</UButton>
<UButton
v-if="canPauseItem(item)"
color="warning"
variant="outline"
icon="i-lucide-pause"
class="w-full justify-center"
@click="() => void pauseItem(item)"
>
Pause
</UButton>
<UDropdownMenu :items="itemActionGroups(item)" :modal="false" class="w-full">
<UButton <UButton
color="neutral" color="neutral"
variant="outline" variant="outline"
icon="i-lucide-settings-2" icon="i-lucide-circle-off"
trailing-icon="i-lucide-chevron-down"
class="w-full justify-center" class="w-full justify-center"
@click="() => void confirmCancel(item)"
> >
Actions {{ item.is_live ? 'Stop Stream' : 'Cancel' }}
</UButton> </UButton>
</UDropdownMenu>
</div> <UButton
v-if="canStartItem(item)"
color="neutral"
variant="outline"
icon="i-lucide-circle-play"
class="w-full justify-center"
@click="() => void startItem(item)"
>
Start
</UButton>
<UButton
v-if="canPauseItem(item)"
color="neutral"
variant="outline"
icon="i-lucide-pause"
class="w-full justify-center"
@click="() => void pauseItem(item)"
>
Pause
</UButton>
<UDropdownMenu :items="itemActionGroups(item)" :modal="false" class="w-full">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-settings-2"
trailing-icon="i-lucide-chevron-down"
class="w-full justify-center"
>
Actions
</UButton>
</UDropdownMenu>
</div>
</template>
</UCard> </UCard>
</LateLoader> </LateLoader>
</div> </div>
@ -540,6 +588,7 @@
<script setup lang="ts"> <script setup lang="ts">
import moment from 'moment'; import moment from 'moment';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import type { StoreItem } from '~/types/store'; import type { StoreItem } from '~/types/store';
import { useConfirm } from '~/composables/useConfirm'; import { useConfirm } from '~/composables/useConfirm';
import { deepIncludes, getPath, getImage } from '~/utils'; import { deepIncludes, getPath, getImage } from '~/utils';
@ -560,9 +609,11 @@ const stateStore = useStateStore();
const socket = useSocketStore(); const socket = useSocketStore();
const box = useConfirm(); const box = useConfirm();
const toast = useNotification(); const toast = useNotification();
const { toggleExpand, expandClass } = useExpandableMeta();
const hideThumbnail = useStorage<boolean>('hideThumbnailQueue', false); const hideThumbnail = useStorage<boolean>('hideThumbnailQueue', false);
const display_style = useStorage<'grid' | 'list'>('display_style', 'grid'); const display_style = useStorage<'grid' | 'list'>('display_style', 'grid');
const isMobile = useMediaQuery({ maxWidth: 639 });
const bg_enable = useStorage<boolean>('random_bg', true); const bg_enable = useStorage<boolean>('random_bg', true);
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95); const bg_opacity = useStorage<number>('random_bg_opacity', 0.95);
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1'); const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1');
@ -576,6 +627,10 @@ const autoRefreshInterval = ref<NodeJS.Timeout | null>(null);
const autoRefreshEnabled = useStorage<boolean>('queue_auto_refresh', true); const autoRefreshEnabled = useStorage<boolean>('queue_auto_refresh', true);
const autoRefreshDelay = useStorage<number>('queue_auto_refresh_delay', 10000); const autoRefreshDelay = useStorage<number>('queue_auto_refresh_delay', 10000);
const contentStyle = computed<'grid' | 'list'>(() =>
isMobile.value ? 'grid' : display_style.value,
);
const showThumbnails = computed(() => Boolean(props.thumbnails) && !hideThumbnail.value); const showThumbnails = computed(() => Boolean(props.thumbnails) && !hideThumbnail.value);
const refreshQueue = async () => { const refreshQueue = async () => {
@ -706,22 +761,6 @@ const getListImage = (item: StoreItem): string =>
const getGridImage = (item: StoreItem): string => getImage(config.app.download_path, item) || ''; const getGridImage = (item: StoreItem): string => getImage(config.app.download_path, item) || '';
const queueCardClass = (item: StoreItem): string => {
if ('postprocessing' === item.status) {
return 'border-info';
}
if (['downloading', 'started'].includes(item.status || '')) {
return 'border-success';
}
if (!item.auto_start || (null === item.status && true === config.paused)) {
return 'border-warning';
}
return 'border-default';
};
const canStartItem = (item: StoreItem): boolean => !item.auto_start && !item.status; const canStartItem = (item: StoreItem): boolean => !item.auto_start && !item.status;
const canPauseItem = (item: StoreItem): boolean => item.auto_start && !item.status; const canPauseItem = (item: StoreItem): boolean => item.auto_start && !item.status;
@ -739,7 +778,6 @@ const bulkActionGroups = computed(() => {
groups[0]?.push({ groups[0]?.push({
label: 'Start', label: 'Start',
icon: 'i-lucide-circle-play', icon: 'i-lucide-circle-play',
color: 'success',
disabled: !hasSelected.value, disabled: !hasSelected.value,
onSelect: () => void startItems(), onSelect: () => void startItems(),
}); });
@ -749,7 +787,6 @@ const bulkActionGroups = computed(() => {
groups[0]?.push({ groups[0]?.push({
label: 'Pause', label: 'Pause',
icon: 'i-lucide-pause', icon: 'i-lucide-pause',
color: 'warning',
disabled: !hasSelected.value, disabled: !hasSelected.value,
onSelect: () => void pauseSelected(), onSelect: () => void pauseSelected(),
}); });
@ -758,7 +795,6 @@ const bulkActionGroups = computed(() => {
groups[0]?.push({ groups[0]?.push({
label: selectedLiveOnly ? 'Stop' : 'Cancel', label: selectedLiveOnly ? 'Stop' : 'Cancel',
icon: 'i-lucide-circle-off', icon: 'i-lucide-circle-off',
color: 'warning',
disabled: !hasSelected.value, disabled: !hasSelected.value,
onSelect: () => void cancelSelected(), onSelect: () => void cancelSelected(),
}); });
@ -774,7 +810,6 @@ const itemActionGroups = (item: StoreItem) => {
primaryActions.push({ primaryActions.push({
label: 'Play video', label: 'Play video',
icon: 'i-lucide-play', icon: 'i-lucide-play',
color: 'error',
onSelect: () => { onSelect: () => {
embed_url.value = getEmbedable(item.url) as string; embed_url.value = getEmbedable(item.url) as string;
}, },
@ -784,7 +819,6 @@ const itemActionGroups = (item: StoreItem) => {
primaryActions.push({ primaryActions.push({
label: item.is_live ? 'Stop Stream' : 'Cancel Download', label: item.is_live ? 'Stop Stream' : 'Cancel Download',
icon: 'i-lucide-circle-off', icon: 'i-lucide-circle-off',
color: 'warning',
onSelect: () => void confirmCancel(item), onSelect: () => void confirmCancel(item),
}); });
@ -792,7 +826,6 @@ const itemActionGroups = (item: StoreItem) => {
primaryActions.push({ primaryActions.push({
label: 'Start Download', label: 'Start Download',
icon: 'i-lucide-circle-play', icon: 'i-lucide-circle-play',
color: 'success',
onSelect: () => void startItem(item), onSelect: () => void startItem(item),
}); });
} }
@ -801,7 +834,6 @@ const itemActionGroups = (item: StoreItem) => {
primaryActions.push({ primaryActions.push({
label: 'Pause Download', label: 'Pause Download',
icon: 'i-lucide-pause', icon: 'i-lucide-pause',
color: 'warning',
onSelect: () => void pauseItem(item), onSelect: () => void pauseItem(item),
}); });
} }

View file

@ -367,7 +367,9 @@
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain"> <div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-215 table-fixed w-full text-sm"> <table class="min-w-215 table-fixed 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-left [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold"> <tr
class="text-left [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th class="w-40"> <th class="w-40">
<span class="inline-flex items-center gap-1.5"> <span class="inline-flex items-center gap-1.5">
<UIcon name="i-lucide-key" class="size-3.5 text-toned" /> <UIcon name="i-lucide-key" class="size-3.5 text-toned" />
@ -409,7 +411,7 @@
<tr <tr
v-for="(field, index) in guiState.fields" v-for="(field, index) in guiState.fields"
:key="`${index}-${field.key}`" :key="`${index}-${field.key}`"
class="align-top" class="align-top [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
> >
<td class="px-3 py-3"> <td class="px-3 py-3">
<UInput <UInput

View file

@ -121,7 +121,9 @@
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain"> <div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-180 w-full table-auto text-sm"> <table class="min-w-180 w-full table-auto 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-left [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold"> <tr
class="text-left [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th class="w-80 whitespace-nowrap">Flags</th> <th class="w-80 whitespace-nowrap">Flags</th>
<th>Description</th> <th>Description</th>
</tr> </tr>
@ -130,7 +132,7 @@
<tr <tr
v-for="opt in group.items" v-for="opt in group.items"
:key="opt.flags.join('|')" :key="opt.flags.join('|')"
class="align-top hover:bg-muted/20" class="align-top transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
> >
<td class="w-80 px-3 py-3 align-top"> <td class="w-80 px-3 py-3 align-top">
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
@ -185,7 +187,9 @@
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain"> <div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-215 w-full table-auto text-sm"> <table class="min-w-215 w-full table-auto 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-left [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold"> <tr
class="text-left [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th class="w-80 whitespace-nowrap">Flags</th> <th class="w-80 whitespace-nowrap">Flags</th>
<th class="w-36 whitespace-nowrap">Group</th> <th class="w-36 whitespace-nowrap">Group</th>
<th>Description</th> <th>Description</th>
@ -195,7 +199,7 @@
<tr <tr
v-for="opt in visible" v-for="opt in visible"
:key="opt.flags.join('|')" :key="opt.flags.join('|')"
class="align-top hover:bg-muted/20" class="align-top transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
> >
<td class="w-80 px-3 py-3 align-top"> <td class="w-80 px-3 py-3 align-top">
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">

View file

@ -552,11 +552,33 @@ const navigationUi = (collapsed: boolean) => ({
linkLabel: collapsed ? 'hidden' : 'truncate', linkLabel: collapsed ? 'hidden' : 'truncate',
}); });
const navEntryPath = (item: NavEntry): string => (item.to ? item.to.split(/[?#]/)[0] || '/' : '');
const isNavEntryActive = (item: NavEntry): boolean => {
if (item.id === 'downloads') {
return route.path === '/' && route.hash !== '#history';
}
if (item.id === 'history') {
return route.path === '/' && route.hash === '#history';
}
const path = navEntryPath(item);
if (!path) {
return false;
}
return path === '/'
? route.path === '/'
: route.path === path || route.path.startsWith(`${path}/`);
};
const makeNavigationItem = (item: NavEntry): NavigationMenuItem => ({ const makeNavigationItem = (item: NavEntry): NavigationMenuItem => ({
label: item.label, label: item.label,
icon: item.icon, icon: item.icon,
to: item.to, to: item.to,
value: item.id, value: item.id,
active: isNavEntryActive(item),
}); });
const docsNavigationEntries = getDocsNavigationEntries(); const docsNavigationEntries = getDocsNavigationEntries();
@ -565,10 +587,17 @@ const allNavItems = computed<NavEntry[]>(() => [
{ {
id: 'downloads', id: 'downloads',
label: 'Downloads', label: 'Downloads',
description: 'Queued and completed downloads list.', description: 'Manage queued, active, and completed downloads in one workspace.',
icon: 'i-lucide-download', icon: 'i-lucide-download',
to: '/', to: '/',
}, },
{
id: 'history',
label: 'History',
description: 'Jump to the history section on the downloads page.',
icon: 'i-lucide-history',
to: '/#history',
},
{ {
id: 'files', id: 'files',
label: 'Files', label: 'Files',
@ -674,11 +703,14 @@ const allNavItems = computed<NavEntry[]>(() => [
]); ]);
const pageTitle = computed(() => { const pageTitle = computed(() => {
const path = route.path; if (route.path === '/') {
return 'Downloads';
}
const flat = allNavItems.value.flatMap((item) => [item, ...(item.children || [])]); const flat = allNavItems.value.flatMap((item) => [item, ...(item.children || [])]);
const match = flat const match = flat
.filter((item) => item.to && (item.to === '/' ? path === '/' : path.startsWith(item.to))) .filter((item) => isNavEntryActive(item))
.sort((left, right) => (right.to?.length || 0) - (left.to?.length || 0))[0]; .sort((left, right) => navEntryPath(right).length - navEntryPath(left).length)[0];
return match?.label || 'YTPTube'; return match?.label || 'YTPTube';
}); });
@ -720,8 +752,8 @@ const sidebarSections = computed<Array<SidebarSection>>(() => {
id: 'downloads', id: 'downloads',
label: 'Downloads', label: 'Downloads',
items: items:
topLevelItems(['downloads', 'files']).length > 0 topLevelItems(['downloads', 'history', 'files']).length > 0
? [topLevelItems(['downloads', 'files'])] ? [topLevelItems(['downloads', 'history', 'files'])]
: [], : [],
}, },
{ {
@ -732,10 +764,10 @@ const sidebarSections = computed<Array<SidebarSection>>(() => {
{ {
id: 'configuration', id: 'configuration',
label: 'Configuration', label: 'Configuration',
items: [ items:
topLevelItems(['presets', 'custom-fields']), topLevelItems(['presets', 'custom-fields', 'conditions', 'notifications']).length > 0
topLevelItems(['conditions', 'notifications']), ? [topLevelItems(['presets', 'custom-fields', 'conditions', 'notifications'])]
].filter((group) => group.length > 0), : [],
}, },
{ {
id: 'tools', id: 'tools',

View file

@ -60,7 +60,7 @@
icon="i-lucide-filter" icon="i-lucide-filter"
@click="toggleFilter" @click="toggleFilter"
> >
<span v-if="!isMobile">Filter</span> <span>Filter</span>
</UButton> </UButton>
<UButton <UButton
@ -71,7 +71,7 @@
icon="i-lucide-folder-plus" icon="i-lucide-folder-plus"
@click="() => void handleCreateDirectory()" @click="() => void handleCreateDirectory()"
> >
<span v-if="!isMobile">New Folder</span> <span>New Folder</span>
</UButton> </UButton>
<UButton <UButton
@ -79,9 +79,10 @@
variant="outline" variant="outline"
size="sm" size="sm"
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'" :icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle" @click="toggleDisplayStyle"
> >
<span v-if="!isMobile">{{ display_style === 'list' ? 'List' : 'Grid' }}</span> <span class="hidden sm:inline">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
</UButton> </UButton>
<UDropdownMenu v-if="hasItems" :items="sortGroups" :modal="false"> <UDropdownMenu v-if="hasItems" :items="sortGroups" :modal="false">
@ -92,7 +93,7 @@
icon="i-lucide-arrow-up-down" icon="i-lucide-arrow-up-down"
trailing-icon="i-lucide-chevron-down" trailing-icon="i-lucide-chevron-down"
> >
<span v-if="!isMobile">Sort</span> <span>Sort</span>
</UButton> </UButton>
</UDropdownMenu> </UDropdownMenu>
@ -105,7 +106,7 @@
:disabled="isLoading" :disabled="isLoading"
@click="() => void reloadContent(browserPath)" @click="() => void reloadContent(browserPath)"
> >
<span v-if="!isMobile">Reload</span> <span>Reload</span>
</UButton> </UButton>
</div> </div>
</div> </div>
@ -119,9 +120,9 @@
color="neutral" color="neutral"
variant="outline" variant="outline"
size="sm" size="sm"
:icon="masterSelectAll ? 'i-lucide-square' : 'i-lucide-check'" :icon="masterSelectAll ? 'i-lucide-square' : 'i-lucide-square-check-big'"
:disabled="isLoading || filteredItems.length < 1" :disabled="isLoading || filteredItems.length < 1"
@click="masterSelectAll = !masterSelectAll" @click="toggleMasterSelection"
> >
{{ masterSelectAll ? 'Unselect' : 'Select' }} {{ masterSelectAll ? 'Unselect' : 'Select' }}
</UButton> </UButton>
@ -130,27 +131,17 @@
{{ selectedElms.length }} {{ selectedElms.length }}
</UBadge> </UBadge>
<UButton <UDropdownMenu :items="bulkActionGroups" :modal="false">
color="info" <UButton
variant="outline" color="neutral"
size="sm" variant="outline"
icon="i-lucide-arrow-right-left" size="sm"
:disabled="!hasSelected || isLoading" icon="i-lucide-list"
@click="() => void handleMoveSelected()" trailing-icon="i-lucide-chevron-down"
> >
Move Actions
</UButton> </UButton>
</UDropdownMenu>
<UButton
color="error"
variant="outline"
size="sm"
icon="i-lucide-trash"
:disabled="!hasSelected || isLoading"
@click="() => void handleDeleteSelected()"
>
Delete
</UButton>
</div> </div>
<p class="text-sm text-toned"> <p class="text-sm text-toned">
@ -167,14 +158,28 @@
/> />
<div <div
v-if="display_style === 'list' && hasItems" v-if="contentStyle === 'list' && hasItems"
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"
> >
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain"> <div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-345 w-full text-sm"> <table class="min-w-360 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
<th v-if="controlEnabled" class="w-16">Select</th> class="text-center [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th v-if="controlEnabled" class="w-16">
<button
type="button"
class="cursor-pointer"
:aria-label="masterSelectAll ? 'Unselect all items' : 'Select all items'"
@click="toggleMasterSelection"
>
<UIcon
:name="masterSelectAll ? 'i-lucide-square' : 'i-lucide-square-check-big'"
class="size-4"
/>
</button>
</th>
<th :class="controlEnabled ? 'w-20' : 'w-24'"> <th :class="controlEnabled ? 'w-20' : 'w-24'">
# #
<UIcon <UIcon
@ -207,12 +212,16 @@
class="ml-1 inline-flex size-3.5" class="ml-1 inline-flex size-3.5"
/> />
</th> </th>
<th v-if="controlEnabled" class="w-80 whitespace-nowrap">Actions</th> <th v-if="controlEnabled" class="w-96 whitespace-nowrap">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-default"> <tbody class="divide-y divide-default">
<tr v-for="item in filteredItems" :key="item.path" class="hover:bg-muted/20"> <tr
v-for="item in filteredItems"
:key="item.path"
class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
>
<td v-if="controlEnabled" class="px-3 py-3 text-center align-middle"> <td v-if="controlEnabled" class="px-3 py-3 text-center align-middle">
<label class="inline-flex cursor-pointer items-center justify-center"> <label class="inline-flex cursor-pointer items-center justify-center">
<input <input
@ -245,19 +254,6 @@
</a> </a>
</UTooltip> </UTooltip>
</div> </div>
<UButton
v-if="item.type === 'file'"
color="info"
variant="ghost"
size="xs"
icon="i-lucide-download"
class="shrink-0"
external
:href="downloadHref(item)"
:download="downloadName(item)"
square
/>
</div> </div>
</td> </td>
@ -271,10 +267,23 @@
</UTooltip> </UTooltip>
</td> </td>
<td v-if="controlEnabled" class="w-80 px-3 py-3 align-middle whitespace-nowrap"> <td v-if="controlEnabled" class="w-96 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="warning" v-if="item.type === 'file'"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-download"
class="shrink-0"
external
:href="downloadHref(item)"
:download="downloadName(item)"
>
Download
</UButton>
<UButton
color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-pencil" icon="i-lucide-pencil"
@ -284,7 +293,7 @@
</UButton> </UButton>
<UButton <UButton
color="info" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-arrow-right-left" icon="i-lucide-arrow-right-left"
@ -294,7 +303,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"
@ -315,7 +324,11 @@
v-for="item in filteredItems" v-for="item in filteredItems"
:key="item.path" :key="item.path"
class="flex h-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',
footer: 'border-t border-default px-4 py-4',
}"
> >
<template #header> <template #header>
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
@ -340,18 +353,6 @@
</div> </div>
<div class="flex shrink-0 items-center gap-1"> <div class="flex shrink-0 items-center gap-1">
<UButton
v-if="item.type === 'file'"
color="info"
variant="ghost"
size="xs"
icon="i-lucide-download"
external
:href="downloadHref(item)"
:download="downloadName(item)"
square
/>
<label v-if="controlEnabled" class="inline-flex cursor-pointer items-center px-1"> <label v-if="controlEnabled" class="inline-flex cursor-pointer items-center px-1">
<input <input
v-model="selectedElms" v-model="selectedElms"
@ -386,40 +387,56 @@
</div> </div>
</div> </div>
<div v-if="controlEnabled" class="mt-auto flex flex-wrap gap-2 pt-1 *:min-w-32 *:flex-1"> <template v-if="controlEnabled" #footer>
<UButton <div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
color="warning" <UButton
variant="outline" v-if="item.type === 'file'"
size="sm" color="neutral"
icon="i-lucide-pencil" variant="outline"
class="w-full justify-center" size="xs"
@click="() => void handleAction('rename', item)" icon="i-lucide-download"
> external
Rename :href="downloadHref(item)"
</UButton> :download="downloadName(item)"
class="w-full justify-center"
>
Download
</UButton>
<UButton <UButton
color="info" color="neutral"
variant="outline" variant="outline"
size="sm" size="sm"
icon="i-lucide-arrow-right-left" icon="i-lucide-pencil"
class="w-full justify-center" class="w-full justify-center"
@click="() => void handleAction('move', item)" @click="() => void handleAction('rename', item)"
> >
Move Rename
</UButton> </UButton>
<UButton <UButton
color="error" color="neutral"
variant="outline" variant="outline"
size="sm" size="sm"
icon="i-lucide-trash" icon="i-lucide-arrow-right-left"
class="w-full justify-center" class="w-full justify-center"
@click="() => void handleAction('delete', item)" @click="() => void handleAction('move', item)"
> >
Delete Move
</UButton> </UButton>
</div>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void handleAction('delete', item)"
>
Delete
</UButton>
</div>
</template>
</UCard> </UCard>
</div> </div>
@ -521,9 +538,9 @@ const toast = useNotification();
const config = useConfigStore(); const config = useConfigStore();
const dialog = useDialog(); const dialog = useDialog();
const browser = useBrowser(); const browser = useBrowser();
const isMobile = useMediaQuery({ maxWidth: 1024 });
const display_style = useStorage<string>('browser_display_style', 'list'); const display_style = useStorage<string>('browser_display_style', 'list');
const isMobile = useMediaQuery({ maxWidth: 639 });
const show_filter = ref(false); const show_filter = ref(false);
const localSearch = ref(''); const localSearch = ref('');
const searchInput = ref<HTMLInputElement | null>(null); const searchInput = ref<HTMLInputElement | null>(null);
@ -539,6 +556,9 @@ const sort_order = browser.sort_order;
const filteredItems = browser.filteredItems; const filteredItems = browser.filteredItems;
const controlEnabled = computed(() => Boolean(config.app.browser_control_enabled)); const controlEnabled = computed(() => Boolean(config.app.browser_control_enabled));
const contentStyle = computed<'list' | 'grid'>(() =>
isMobile.value ? 'grid' : 'list' === display_style.value ? 'list' : 'grid',
);
const hasItems = computed(() => filteredItems.value.length > 0); const hasItems = computed(() => filteredItems.value.length > 0);
const hasSelected = computed(() => selectedElms.value.length > 0); const hasSelected = computed(() => selectedElms.value.length > 0);
const displayedItemPaths = computed(() => filteredItems.value.map((item) => item.path)); const displayedItemPaths = computed(() => filteredItems.value.map((item) => item.path));
@ -567,6 +587,25 @@ const sortGroups = computed<DropdownMenuItem[][]>(() => [
})), })),
]); ]);
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
[
{
label: 'Move Selected',
icon: 'i-lucide-arrow-right-left',
color: 'primary',
disabled: !hasSelected.value || isLoading.value,
onSelect: () => void handleMoveSelected(),
},
{
label: 'Delete Selected',
icon: 'i-lucide-trash',
color: 'error',
disabled: !hasSelected.value || isLoading.value,
onSelect: () => void handleDeleteSelected(),
},
],
]);
const initialPath = (() => { const initialPath = (() => {
const slug = route.params.slug; const slug = route.params.slug;
if (Array.isArray(slug) && slug.length > 0) { if (Array.isArray(slug) && slug.length > 0) {
@ -917,6 +956,10 @@ const toggleDisplayStyle = (): void => {
display_style.value = display_style.value === 'list' ? 'grid' : 'list'; display_style.value = display_style.value === 'list' ? 'grid' : 'list';
}; };
const toggleMasterSelection = (): void => {
masterSelectAll.value = !masterSelectAll.value;
};
const handleCreateDirectory = async (): Promise<void> => { const handleCreateDirectory = async (): Promise<void> => {
if (!controlEnabled.value) { if (!controlEnabled.value) {
return; return;

View file

@ -38,7 +38,7 @@
icon="i-lucide-filter" icon="i-lucide-filter"
@click="toggleFilter = !toggleFilter" @click="toggleFilter = !toggleFilter"
> >
<span v-if="!isMobile">Filter</span> <span>Filter</span>
</UButton> </UButton>
<USwitch <USwitch
@ -79,17 +79,25 @@
Installed Installed
</UBadge> </UBadge>
</div> </div>
<p v-if="log.date" class="text-xs text-toned">
<UTooltip :text="`Release Date: ${log.date}`">
<span>{{ moment(log.date).fromNow() }}</span>
</UTooltip>
</p>
</div> </div>
<UBadge color="neutral" variant="soft" size="sm"> <div class="flex flex-wrap items-center justify-end gap-2 text-xs text-toned">
{{ log.commits?.length || 0 }} commits <span
</UBadge> class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-list" class="size-3.5" />
<span>{{ log.commits?.length || 0 }} commits</span>
</span>
<UTooltip v-if="log.date" :text="`Release Date: ${log.date}`">
<span
class="inline-flex cursor-help items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-calendar-days" class="size-3.5" />
<span>{{ moment(log.date).fromNow() }}</span>
</span>
</UTooltip>
</div>
</div> </div>
<div class="space-y-3 border-t border-default pt-4"> <div class="space-y-3 border-t border-default pt-4">
@ -98,35 +106,48 @@
:key="commit.sha" :key="commit.sha"
class="flex flex-col gap-2 rounded-md border border-default bg-muted/20 px-3 py-3" class="flex flex-col gap-2 rounded-md border border-default bg-muted/20 px-3 py-3"
> >
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="min-w-0">
<p class="min-w-0 flex-1 text-sm text-default"> <NuxtLink
:to="`${REPO}/commit/${commit.full_sha}`"
target="_blank"
class="block min-w-0 text-sm text-default hover:underline"
>
<span class="font-semibold text-highlighted"> <span class="font-semibold text-highlighted">
{{ ucFirst(commit.message).replace(/\.$/, '') }}. {{ ucFirst(commit.message).replace(/\.$/, '') }}.
</span> </span>
</p> </NuxtLink>
<div class="flex shrink-0 items-center gap-2">
<NuxtLink
:to="`${REPO}/commit/${commit.full_sha}`"
target="_blank"
class="text-xs font-medium text-primary hover:underline"
>
{{ commit.sha }}
</NuxtLink>
<UIcon
v-if="commit.full_sha === app_sha"
name="i-lucide-check"
class="size-4 text-success"
/>
</div>
</div> </div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-toned"> <div class="flex flex-wrap items-center gap-2 text-xs text-toned">
<span>{{ commit.author }}</span> <span
<UTooltip :text="`SHA: ${commit.full_sha} - Date: ${commit.date}`"> class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
<span>{{ moment(commit.date).fromNow() }}</span> >
<UIcon name="i-lucide-user" class="size-3.5" />
{{ commit.author }}
</span>
<UTooltip :text="`Date: ${commit.date}`">
<span
class="inline-flex cursor-help items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-clock-3" class="size-3.5" />
{{ moment(commit.date).fromNow() }}
</span>
</UTooltip> </UTooltip>
<UTooltip :text="`SHA: ${commit.full_sha}`">
<span
class="inline-flex cursor-help items-center gap-1 rounded-md border border-default px-2 py-1 font-medium"
>
<UIcon name="i-lucide-git-commit-horizontal" class="size-3.5" />
{{ commit.sha }}
</span>
</UTooltip>
<span
v-if="commit.full_sha === app_sha"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 font-medium"
>
<UIcon name="i-lucide-check" class="size-3.5 text-success" />
<span>Installed</span>
</span>
</div> </div>
</article> </article>
</div> </div>
@ -170,7 +191,6 @@ import { request, ucFirst, uri } from '~/utils';
const toast = useNotification(); const toast = useNotification();
const config = useConfigStore(); const config = useConfigStore();
const isMobile = useMediaQuery({ maxWidth: 1024 });
useHead({ title: 'CHANGELOG' }); useHead({ title: 'CHANGELOG' });

View file

@ -37,7 +37,7 @@
icon="i-lucide-filter" icon="i-lucide-filter"
@click="toggleFilterPanel" @click="toggleFilterPanel"
> >
<span v-if="!isMobile">Filter</span> <span>Filter</span>
</UButton> </UButton>
<UButton <UButton
@ -47,7 +47,7 @@
icon="i-lucide-plus" icon="i-lucide-plus"
@click="openCreate" @click="openCreate"
> >
<span v-if="!isMobile">New Condition</span> <span>New Condition</span>
</UButton> </UButton>
<UButton <UButton
@ -56,9 +56,10 @@
variant="outline" variant="outline"
size="sm" size="sm"
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'" :icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle" @click="toggleDisplayStyle"
> >
<span v-if="!isMobile">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span> <span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
</UButton> </UButton>
<UButton <UButton
@ -71,7 +72,7 @@
:disabled="isLoading" :disabled="isLoading"
@click="() => void reloadContent()" @click="() => void reloadContent()"
> >
<span v-if="!isMobile">Reload</span> <span>Reload</span>
</UButton> </UButton>
</div> </div>
</div> </div>
@ -120,13 +121,15 @@
</div> </div>
<div <div
v-if="displayStyle === 'list' && filteredItems.length > 0" v-if="contentStyle === '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"
> >
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain"> <div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-225 w-full text-sm"> <table class="min-w-235 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]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th class="w-12"> <th class="w-12">
<button type="button" class="cursor-pointer" @click="toggleMasterSelection"> <button type="button" class="cursor-pointer" @click="toggleMasterSelection">
<UIcon <UIcon
@ -136,12 +139,16 @@
</button> </button>
</th> </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-48 whitespace-nowrap">Actions</th>
</tr> </tr>
</thead> </thead>
<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="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
>
<td class="px-3 py-3 text-center align-middle"> <td class="px-3 py-3 text-center align-middle">
<label class="inline-flex cursor-pointer items-center justify-center"> <label class="inline-flex cursor-pointer items-center justify-center">
<input <input
@ -183,7 +190,7 @@
</div> </div>
</td> </td>
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap"> <td class="w-48 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="neutral" color="neutral"
@ -192,7 +199,7 @@
icon="i-lucide-file-up" icon="i-lucide-file-up"
@click="exportItem(cond)" @click="exportItem(cond)"
> >
<span v-if="!isMobile">Export</span> <span class="hidden sm:inline">Export</span>
</UButton> </UButton>
<UButton <UButton
@ -202,7 +209,7 @@
icon="i-lucide-pencil" icon="i-lucide-pencil"
@click="editItem(cond)" @click="editItem(cond)"
> >
<span v-if="!isMobile">Edit</span> <span class="hidden sm:inline">Edit</span>
</UButton> </UButton>
<UButton <UButton
@ -212,7 +219,7 @@
icon="i-lucide-trash" icon="i-lucide-trash"
@click="() => void deleteItem(cond)" @click="() => void deleteItem(cond)"
> >
<span v-if="!isMobile">Delete</span> <span class="hidden sm:inline">Delete</span>
</UButton> </UButton>
</div> </div>
</td> </td>
@ -226,7 +233,11 @@
<div v-for="cond in filteredItems" :key="cond.id" class="min-w-0 w-full max-w-full"> <div v-for="cond in filteredItems" :key="cond.id" class="min-w-0 w-full max-w-full">
<UCard <UCard
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default" class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }" :ui="{
header: 'p-4 pb-3',
body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
footer: 'border-t border-default px-4 py-4',
}"
> >
<template #header> <template #header>
<div class="flex min-w-0 items-start justify-between gap-3"> <div class="flex min-w-0 items-start justify-between gap-3">
@ -251,7 +262,7 @@
square square
@click="exportItem(cond)" @click="exportItem(cond)"
> >
<span class="hidden sm:inline">Export Condition</span> <span>Export Condition</span>
</UButton> </UButton>
<label class="inline-flex cursor-pointer items-center justify-center"> <label class="inline-flex cursor-pointer items-center justify-center">
@ -366,27 +377,29 @@
</div> </div>
</div> </div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1"> <template #footer>
<UButton <div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
color="neutral" <UButton
variant="outline" color="neutral"
icon="i-lucide-pencil" variant="outline"
class="w-full justify-center" icon="i-lucide-pencil"
@click="editItem(cond)" class="w-full justify-center"
> @click="editItem(cond)"
Edit >
</UButton> Edit
</UButton>
<UButton <UButton
color="neutral" color="neutral"
variant="outline" variant="outline"
icon="i-lucide-trash" icon="i-lucide-trash"
class="w-full justify-center" class="w-full justify-center"
@click="() => void deleteItem(cond)" @click="() => void deleteItem(cond)"
> >
Delete Delete
</UButton> </UButton>
</div> </div>
</template>
</UCard> </UCard>
</div> </div>
</div> </div>
@ -490,8 +503,8 @@ import { cleanObject, copyText, encode } from '~/utils';
type ConditionItemWithUI = Condition & { raw?: boolean }; type ConditionItemWithUI = Condition & { raw?: boolean };
const box = useConfirm(); const box = useConfirm();
const isMobile = useMediaQuery({ maxWidth: 1024 });
const displayStyle = useStorage<'list' | 'grid'>('conditions_display_style', 'grid'); const displayStyle = useStorage<'list' | 'grid'>('conditions_display_style', 'grid');
const isMobile = useMediaQuery({ maxWidth: 639 });
const { toggleExpand, expandClass } = useExpandableMeta(); const { toggleExpand, expandClass } = useExpandableMeta();
const conditions = useConditions(); const conditions = useConditions();
const route = useRoute(); const route = useRoute();
@ -534,6 +547,9 @@ const allSelected = computed(
); );
const hasSelected = computed(() => selectedIds.value.length > 0); const hasSelected = computed(() => selectedIds.value.length > 0);
const contentStyle = computed<'list' | 'grid'>(() =>
isMobile.value ? 'grid' : displayStyle.value,
);
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [ const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
[ [

View file

@ -247,7 +247,7 @@
<tr <tr
v-for="(cmd, index) in historyEntries" v-for="(cmd, index) in historyEntries"
:key="`${index}-${cmd}`" :key="`${index}-${cmd}`"
class="hover:bg-muted/20" class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
> >
<td class="px-3 py-3 align-middle"> <td class="px-3 py-3 align-middle">
<button <button

View file

@ -37,7 +37,7 @@
icon="i-lucide-filter" icon="i-lucide-filter"
@click="toggleFilterPanel" @click="toggleFilterPanel"
> >
<span v-if="!isMobile">Filter</span> <span>Filter</span>
</UButton> </UButton>
<UButton <UButton
@ -47,7 +47,7 @@
icon="i-lucide-plus" icon="i-lucide-plus"
@click="openCreate" @click="openCreate"
> >
<span v-if="!isMobile">New Field</span> <span>New Field</span>
</UButton> </UButton>
<UButton <UButton
@ -56,9 +56,10 @@
variant="outline" variant="outline"
size="sm" size="sm"
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'" :icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle" @click="toggleDisplayStyle"
> >
<span v-if="!isMobile">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span> <span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
</UButton> </UButton>
<UButton <UButton
@ -71,7 +72,7 @@
:disabled="isLoading" :disabled="isLoading"
@click="() => void reloadContent()" @click="() => void reloadContent()"
> >
<span v-if="!isMobile">Reload</span> <span>Reload</span>
</UButton> </UButton>
</div> </div>
</div> </div>
@ -120,13 +121,15 @@
</div> </div>
<div <div
v-if="displayStyle === 'list' && filteredItems.length > 0" v-if="contentStyle === '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"
> >
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain"> <div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-225 w-full text-sm"> <table class="min-w-235 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]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th class="w-12"> <th class="w-12">
<button type="button" class="cursor-pointer" @click="toggleMasterSelection"> <button type="button" class="cursor-pointer" @click="toggleMasterSelection">
<UIcon <UIcon
@ -136,12 +139,16 @@
</button> </button>
</th> </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-48 whitespace-nowrap">Actions</th>
</tr> </tr>
</thead> </thead>
<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="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
>
<td class="px-3 py-3 text-center align-middle"> <td class="px-3 py-3 text-center align-middle">
<label class="inline-flex cursor-pointer items-center justify-center"> <label class="inline-flex cursor-pointer items-center justify-center">
<input <input
@ -182,7 +189,7 @@
</div> </div>
</td> </td>
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap"> <td class="w-48 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="neutral" color="neutral"
@ -191,7 +198,7 @@
icon="i-lucide-file-up" icon="i-lucide-file-up"
@click="exportItem(field)" @click="exportItem(field)"
> >
<span v-if="!isMobile">Export</span> Export
</UButton> </UButton>
<UButton <UButton
@ -201,7 +208,7 @@
icon="i-lucide-pencil" icon="i-lucide-pencil"
@click="editItem(field)" @click="editItem(field)"
> >
<span v-if="!isMobile">Edit</span> Edit
</UButton> </UButton>
<UButton <UButton
@ -211,7 +218,7 @@
icon="i-lucide-trash" icon="i-lucide-trash"
@click="() => void deleteItem(field)" @click="() => void deleteItem(field)"
> >
<span v-if="!isMobile">Delete</span> Delete
</UButton> </UButton>
</div> </div>
</td> </td>
@ -225,7 +232,11 @@
<div v-for="field in filteredItems" :key="field.id" class="min-w-0 w-full max-w-full"> <div v-for="field in filteredItems" :key="field.id" class="min-w-0 w-full max-w-full">
<UCard <UCard
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default" class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }" :ui="{
header: 'p-4 pb-3',
body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
footer: 'border-t border-default px-4 py-4',
}"
> >
<template #header> <template #header>
<div class="flex min-w-0 items-start justify-between gap-3"> <div class="flex min-w-0 items-start justify-between gap-3">
@ -250,7 +261,7 @@
square square
@click="exportItem(field)" @click="exportItem(field)"
> >
<span class="hidden sm:inline">Export</span> <span>Export Field</span>
</UButton> </UButton>
<label class="inline-flex cursor-pointer items-center justify-center"> <label class="inline-flex cursor-pointer items-center justify-center">
@ -318,27 +329,29 @@
</div> </div>
</div> </div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1"> <template #footer>
<UButton <div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
color="neutral" <UButton
variant="outline" color="neutral"
icon="i-lucide-pencil" variant="outline"
class="w-full justify-center" icon="i-lucide-pencil"
@click="editItem(field)" class="w-full justify-center"
> @click="editItem(field)"
Edit >
</UButton> Edit
</UButton>
<UButton <UButton
color="neutral" color="neutral"
variant="outline" variant="outline"
icon="i-lucide-trash" icon="i-lucide-trash"
class="w-full justify-center" class="w-full justify-center"
@click="() => void deleteItem(field)" @click="() => void deleteItem(field)"
> >
Delete Delete
</UButton> </UButton>
</div> </div>
</template>
</UCard> </UCard>
</div> </div>
</div> </div>
@ -411,9 +424,9 @@ import type { APIResponse } from '~/types/responses';
import { copyText, encode } from '~/utils'; import { copyText, encode } from '~/utils';
const box = useConfirm(); const box = useConfirm();
const isMobile = useMediaQuery({ maxWidth: 1024 });
const { toggleExpand, expandClass } = useExpandableMeta(); 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 isMobile = useMediaQuery({ maxWidth: 639 });
const dlFields = useDlFields(); const dlFields = useDlFields();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@ -453,6 +466,9 @@ const allSelected = computed(
); );
const hasSelected = computed(() => selectedIds.value.length > 0); const hasSelected = computed(() => selectedIds.value.length > 0);
const contentStyle = computed<'list' | 'grid'>(() =>
isMobile.value ? 'grid' : displayStyle.value,
);
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [ const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
[ [

View file

@ -2,7 +2,7 @@
<div class="space-y-6"> <div class="space-y-6">
<UPageHeader <UPageHeader
title="Downloads" title="Downloads"
description="Queued and completed downloads are displayed here." description="Manage queued, active, and completed downloads in one workspace."
:ui="{ :ui="{
root: 'border-b border-default py-4', root: 'border-b border-default py-4',
headline: 'hidden', headline: 'hidden',
@ -39,7 +39,7 @@
icon="i-lucide-filter" icon="i-lucide-filter"
@click="toggleFilter = !toggleFilter" @click="toggleFilter = !toggleFilter"
> >
<span v-if="!isMobile">Filter</span> <span>Filter</span>
</UButton> </UButton>
<UButton <UButton
@ -50,7 +50,7 @@
icon="i-lucide-pause" icon="i-lucide-pause"
@click="() => void pauseDownload()" @click="() => void pauseDownload()"
> >
<span v-if="!isMobile">Pause</span> <span>Pause</span>
</UButton> </UButton>
<UButton <UButton
@ -61,7 +61,7 @@
icon="i-lucide-play" icon="i-lucide-play"
@click="() => void resumeDownload()" @click="() => void resumeDownload()"
> >
<span v-if="!isMobile">Resume</span> <span>Resume</span>
</UButton> </UButton>
<UButton <UButton
@ -71,7 +71,7 @@
icon="i-lucide-plus" icon="i-lucide-plus"
@click="config.showForm = !config.showForm" @click="config.showForm = !config.showForm"
> >
<span v-if="!isMobile">Add</span> <span>Add</span>
</UButton> </UButton>
</template> </template>
@ -81,16 +81,17 @@
variant="outline" variant="outline"
size="sm" size="sm"
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'" :icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="changeDisplay" @click="changeDisplay"
> >
<span v-if="!isMobile">{{ display_style === 'list' ? 'List' : 'Grid' }}</span> <span class="hidden sm:inline">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
</UButton> </UButton>
</template> </template>
</UDashboardToolbar> </UDashboardToolbar>
</template> </template>
</UPageHeader> </UPageHeader>
<div v-if="config.showForm" class="page-form-wrap"> <div v-if="config.showForm" ref="formSection" class="page-form-wrap scroll-mt-24">
<NewDownload <NewDownload
:item="item_form" :item="item_form"
@clear_form="item_form = {}" @clear_form="item_form = {}"
@ -100,63 +101,68 @@
/> />
</div> </div>
<UPageCard <UEmpty
variant="outline" v-if="!hasDownloadsContent"
:ui="{ icon="i-lucide-inbox"
root: 'w-full min-w-0 max-w-full bg-default', title="No downloads yet"
container: 'w-full min-w-0 max-w-full p-0 sm:p-0', description="Add a URL to get started."
wrapper: 'w-full min-w-0 items-stretch', class="rounded-lg border border-dashed border-default bg-muted/10 py-10"
body: 'w-full min-w-0 max-w-full p-0', />
}"
>
<template #body>
<UTabs
v-model="activeTab"
:items="tabItems"
value-key="value"
color="neutral"
variant="link"
size="md"
:content="false"
:ui="{
root: 'flex-col gap-4',
list: 'border-b border-default px-2 sm:px-4',
trigger: 'justify-start rounded-none px-3 py-3 text-sm font-medium',
trailingBadge: 'ml-1',
}"
@update:model-value="(value) => void setActiveTab(value as TabType)"
/>
<div class="w-full min-w-0 max-w-full px-0 pt-2"> <div v-else class="space-y-8">
<div v-show="'queue' === activeTab" class="w-full min-w-0 max-w-full"> <section id="queue" class="scroll-mt-24 space-y-4">
<Queue <div class="flex flex-wrap items-center justify-between gap-3 border-b border-default pb-3">
:thumbnails="show_thumbnail" <div class="space-y-1">
:query="query" <div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
@getInfo=" <UIcon name="i-lucide-download" class="size-4 text-toned" />
(url: string, preset: string = '', cli: string = '') => <h2>Queue</h2>
view_info(url, false, preset, cli) </div>
"
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" <p class="text-sm text-toned">Active and queued downloads.</p>
@clear_search="query = ''"
/>
</div> </div>
<div v-show="'history' === activeTab" class="w-full min-w-0 max-w-full"> <UBadge color="info" variant="soft" size="sm">{{ queueCount }}</UBadge>
<History
:query="query"
:thumbnails="show_thumbnail"
@getInfo="
(url: string, preset: string = '', cli: string = '') =>
view_info(url, false, preset, cli)
"
@add_new="(item: item_request) => toNewDownload(item)"
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)"
@clear_search="query = ''"
/>
</div>
</div> </div>
</template>
</UPageCard> <Queue
:thumbnails="show_thumbnail"
:query="query"
@getInfo="
(url: string, preset: string = '', cli: string = '') =>
view_info(url, false, preset, cli)
"
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)"
@clear_search="query = ''"
/>
</section>
<section id="history" class="scroll-mt-24 space-y-4">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-default pb-3">
<div class="space-y-1">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon name="i-lucide-history" class="size-4 text-toned" />
<h2>History</h2>
</div>
<p class="text-sm text-toned">Completed, skipped, and failed downloads.</p>
</div>
<UBadge color="neutral" variant="soft" size="sm">{{ historyCount }}</UBadge>
</div>
<History
:query="query"
:thumbnails="show_thumbnail"
@getInfo="
(url: string, preset: string = '', cli: string = '') =>
view_info(url, false, preset, cli)
"
@add_new="(item: item_request) => void toNewDownload(item)"
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)"
@clear_search="query = ''"
/>
</section>
</div>
<GetInfo <GetInfo
v-if="info_view.url" v-if="info_view.url"
@ -178,15 +184,14 @@ import type { StoreItem } from '~/types/store';
const config = useConfigStore(); const config = useConfigStore();
const stateStore = useStateStore(); const stateStore = useStateStore();
const route = useRoute(); const route = useRoute();
const router = useRouter();
const { confirmDialog } = useDialog(); const { confirmDialog } = useDialog();
const bg_enable = useStorage<boolean>('random_bg', true); const bg_enable = useStorage<boolean>('random_bg', true);
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95); const bg_opacity = useStorage<number>('random_bg_opacity', 0.95);
const display_style = useStorage<string>('display_style', 'grid'); const display_style = useStorage<string>('display_style', 'grid');
const show_thumbnail = useStorage<boolean>('show_thumbnail', true); const show_thumbnail = useStorage<boolean>('show_thumbnail', true);
const isMobile = useMediaQuery({ maxWidth: 1024 });
const formSection = ref<HTMLElement | null>(null);
const info_view = ref({ const info_view = ref({
url: '', url: '',
preset: '', preset: '',
@ -197,33 +202,7 @@ const item_form = ref<item_request | object>({});
const query = ref(''); const query = ref('');
const toggleFilter = ref(false); const toggleFilter = ref(false);
type TabType = 'queue' | 'history';
const activeTab = ref<TabType>('queue');
const getInitialTab = (): TabType => {
const tabParam = route.query.tab as string;
return tabParam === 'queue' || tabParam === 'history' ? tabParam : 'queue';
};
const setActiveTab = async (tab: TabType): Promise<void> => {
activeTab.value = tab;
await router.push({ query: { ...route.query, tab }, replace: true });
};
watch(
() => route.query.tab,
(newTab) => {
if (!['queue', 'history'].includes(newTab as string)) {
return;
}
activeTab.value = newTab as TabType;
},
);
onMounted(async () => { onMounted(async () => {
const route = useRoute();
if (route.query?.simple !== undefined) { if (route.query?.simple !== undefined) {
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false); const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false);
simpleMode.value = ['true', '1', 'yes', 'on'].includes(route.query.simple as string); simpleMode.value = ['true', '1', 'yes', 'on'].includes(route.query.simple as string);
@ -233,31 +212,14 @@ onMounted(async () => {
window.history.replaceState({}, '', url.toString()); window.history.replaceState({}, '', url.toString());
} }
activeTab.value = getInitialTab();
useHead({ title: getTitle() }); useHead({ title: getTitle() });
}); });
const queueCount = computed(() => stateStore.count('queue')); const queueCount = computed(() => stateStore.count('queue'));
const historyCount = computed(() => stateStore.count('history')); const historyCount = computed(() => stateStore.count('history'));
const hasDownloadsContent = computed(
const tabItems = computed(() => [ () => queueCount.value > 0 || historyCount.value > 0 || query.value.trim().length > 0,
{ );
label: 'Downloads',
icon: 'i-lucide-download',
value: 'queue',
badge: { label: String(queueCount.value), color: 'info' as const, variant: 'soft' as const },
},
{
label: 'History',
icon: 'i-lucide-history',
value: 'history',
badge: {
label: String(historyCount.value),
color: 'primary' as const,
variant: 'soft' as const,
},
},
]);
watch(toggleFilter, () => { watch(toggleFilter, () => {
if (!toggleFilter.value) { if (!toggleFilter.value) {
@ -356,6 +318,8 @@ const toNewDownload = async (item: item_request | Partial<StoreItem>) => {
await nextTick(); await nextTick();
config.showForm = true; config.showForm = true;
await nextTick();
formSection.value?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}; };
</script> </script>

View file

@ -37,7 +37,7 @@
icon="i-lucide-filter" icon="i-lucide-filter"
@click="toggleFilterPanel" @click="toggleFilterPanel"
> >
<span v-if="!isMobile">Filter</span> <span>Filter</span>
</UButton> </UButton>
<UButton <UButton
@ -47,7 +47,7 @@
icon="i-lucide-plus" icon="i-lucide-plus"
@click="openCreate" @click="openCreate"
> >
<span v-if="!isMobile">New Notification</span> <span>New Notification</span>
</UButton> </UButton>
<UButton <UButton
@ -60,7 +60,7 @@
:disabled="sendingTest" :disabled="sendingTest"
@click="() => void sendTest()" @click="() => void sendTest()"
> >
<span v-if="!isMobile">Send Test</span> <span>Send Test</span>
</UButton> </UButton>
<UButton <UButton
@ -69,9 +69,10 @@
variant="outline" variant="outline"
size="sm" size="sm"
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'" :icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle" @click="toggleDisplayStyle"
> >
<span v-if="!isMobile">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span> <span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
</UButton> </UButton>
<UButton <UButton
@ -84,7 +85,7 @@
:disabled="isLoading" :disabled="isLoading"
@click="() => void reloadContent()" @click="() => void reloadContent()"
> >
<span v-if="!isMobile">Reload</span> <span>Reload</span>
</UButton> </UButton>
</div> </div>
</div> </div>
@ -133,13 +134,15 @@
</div> </div>
<div <div
v-if="displayStyle === 'list' && filteredTargets.length > 0" v-if="contentStyle === '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"
> >
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain"> <div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-225 w-full text-sm"> <table class="min-w-235 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]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th class="w-12"> <th class="w-12">
<button type="button" class="cursor-pointer" @click="toggleMasterSelection"> <button type="button" class="cursor-pointer" @click="toggleMasterSelection">
<UIcon <UIcon
@ -149,12 +152,16 @@
</button> </button>
</th> </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-48 whitespace-nowrap">Actions</th>
</tr> </tr>
</thead> </thead>
<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="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
>
<td class="px-3 py-3 text-center align-middle"> <td class="px-3 py-3 text-center align-middle">
<label class="inline-flex cursor-pointer items-center justify-center"> <label class="inline-flex cursor-pointer items-center justify-center">
<input <input
@ -220,7 +227,7 @@
</div> </div>
</td> </td>
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap"> <td class="w-48 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="neutral" color="neutral"
@ -229,7 +236,7 @@
icon="i-lucide-file-up" icon="i-lucide-file-up"
@click="exportItem(item)" @click="exportItem(item)"
> >
<span v-if="!isMobile">Export</span> <span class="hidden sm:inline">Export</span>
</UButton> </UButton>
<UButton <UButton
@ -239,7 +246,7 @@
icon="i-lucide-pencil" icon="i-lucide-pencil"
@click="editItem(item)" @click="editItem(item)"
> >
<span v-if="!isMobile">Edit</span> <span class="hidden sm:inline">Edit</span>
</UButton> </UButton>
<UButton <UButton
@ -249,7 +256,7 @@
icon="i-lucide-trash" icon="i-lucide-trash"
@click="() => void deleteItem(item)" @click="() => void deleteItem(item)"
> >
<span v-if="!isMobile">Delete</span> <span class="hidden sm:inline">Delete</span>
</UButton> </UButton>
</div> </div>
</td> </td>
@ -263,7 +270,11 @@
<div v-for="item in filteredTargets" :key="item.id" class="min-w-0 w-full max-w-full"> <div v-for="item in filteredTargets" :key="item.id" class="min-w-0 w-full max-w-full">
<UCard <UCard
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default" class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }" :ui="{
header: 'p-4 pb-3',
body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
footer: 'border-t border-default px-4 py-4',
}"
> >
<template #header> <template #header>
<div class="flex min-w-0 items-start justify-between gap-3"> <div class="flex min-w-0 items-start justify-between gap-3">
@ -291,7 +302,7 @@
square square
@click="exportItem(item)" @click="exportItem(item)"
> >
<span class="hidden sm:inline">Export Target</span> <span>Export Target</span>
</UButton> </UButton>
<label class="inline-flex cursor-pointer items-center justify-center"> <label class="inline-flex cursor-pointer items-center justify-center">
@ -416,27 +427,29 @@
</button> </button>
</div> </div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1"> <template #footer>
<UButton <div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
color="neutral" <UButton
variant="outline" color="neutral"
icon="i-lucide-pencil" variant="outline"
class="w-full justify-center" icon="i-lucide-pencil"
@click="editItem(item)" class="w-full justify-center"
> @click="editItem(item)"
Edit >
</UButton> Edit
</UButton>
<UButton <UButton
color="neutral" color="neutral"
variant="outline" variant="outline"
icon="i-lucide-trash" icon="i-lucide-trash"
class="w-full justify-center" class="w-full justify-center"
@click="() => void deleteItem(item)" @click="() => void deleteItem(item)"
> >
Delete Delete
</UButton> </UButton>
</div> </div>
</template>
</UCard> </UCard>
</div> </div>
</div> </div>
@ -544,12 +557,12 @@ import type { notification } from '~/types/notification';
const toast = useNotification(); const toast = useNotification();
const box = useConfirm(); const box = useConfirm();
const { confirmDialog } = useDialog(); const { confirmDialog } = useDialog();
const isMobile = useMediaQuery({ maxWidth: 1024 });
const { toggleExpand, expandClass } = useExpandableMeta(); const { toggleExpand, expandClass } = useExpandableMeta();
const displayStyleState = useStorage<'list' | 'grid' | 'cards'>( const displayStyleState = useStorage<'list' | 'grid' | 'cards'>(
'notification_display_style', 'notification_display_style',
'cards', 'cards',
); );
const isMobile = useMediaQuery({ maxWidth: 639 });
const notificationsStore = useNotifications(); const notificationsStore = useNotifications();
const notifications = notificationsStore.notifications; const notifications = notificationsStore.notifications;
@ -574,6 +587,9 @@ const massDelete = ref(false);
const displayStyle = computed<'list' | 'grid'>(() => const displayStyle = computed<'list' | 'grid'>(() =>
displayStyleState.value === 'list' ? 'list' : 'grid', displayStyleState.value === 'list' ? 'list' : 'grid',
); );
const contentStyle = computed<'list' | 'grid'>(() =>
isMobile.value ? 'grid' : displayStyle.value,
);
const modalTitle = computed(() => const modalTitle = computed(() =>
targetRef.value ? `Edit - ${target.value.name}` : 'Add new notification target', targetRef.value ? `Edit - ${target.value.name}` : 'Add new notification target',

View file

@ -38,7 +38,7 @@
icon="i-lucide-filter" icon="i-lucide-filter"
@click="toggleFilterPanel" @click="toggleFilterPanel"
> >
<span v-if="!isMobile">Filter</span> <span>Filter</span>
</UButton> </UButton>
<UButton <UButton
@ -48,7 +48,7 @@
icon="i-lucide-plus" icon="i-lucide-plus"
@click="editor.openCreate()" @click="editor.openCreate()"
> >
<span v-if="!isMobile">New Preset</span> <span>New Preset</span>
</UButton> </UButton>
<UButton <UButton
@ -56,9 +56,10 @@
variant="outline" variant="outline"
size="sm" size="sm"
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'" :icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle" @click="toggleDisplayStyle"
> >
<span v-if="!isMobile">{{ display_style === 'list' ? 'List' : 'Grid' }}</span> <span class="hidden sm:inline">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
</UButton> </UButton>
<UButton <UButton
@ -71,7 +72,7 @@
:disabled="isLoading" :disabled="isLoading"
@click="() => void reloadContent()" @click="() => void reloadContent()"
> >
<span v-if="!isMobile">Reload</span> <span>Reload</span>
</UButton> </UButton>
</div> </div>
</div> </div>
@ -112,13 +113,15 @@
</div> </div>
<div <div
v-if="display_style === 'list' && filteredPresets.length > 0" v-if="contentStyle === '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"
> >
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain"> <div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-190 w-full text-sm"> <table class="min-w-200 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]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th class="w-12"> <th class="w-12">
<button type="button" class="cursor-pointer" @click="toggleMasterSelection"> <button type="button" class="cursor-pointer" @click="toggleMasterSelection">
<UIcon <UIcon
@ -128,12 +131,16 @@
</button> </button>
</th> </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-48 whitespace-nowrap">Actions</th>
</tr> </tr>
</thead> </thead>
<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="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
>
<td class="px-3 py-3 text-center align-middle"> <td class="px-3 py-3 text-center align-middle">
<label class="inline-flex cursor-pointer items-center justify-center"> <label class="inline-flex cursor-pointer items-center justify-center">
<input <input
@ -160,7 +167,7 @@
class="size-3.5" class="size-3.5"
:class="item.cookies ? 'text-success' : ''" :class="item.cookies ? 'text-success' : ''"
/> />
<span>{{ item.cookies ? 'Has cookies' : 'No cookies' }}</span> <span>Cookies: {{ item.cookies ? 'Configured' : 'Not set' }}</span>
</span> </span>
<span <span
@ -174,7 +181,7 @@
</div> </div>
</td> </td>
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap"> <td class="w-48 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="neutral" color="neutral"
@ -183,7 +190,7 @@
icon="i-lucide-file-up" icon="i-lucide-file-up"
@click="exportItem(item)" @click="exportItem(item)"
> >
<span v-if="!isMobile">Export</span> <span class="hidden sm:inline">Export</span>
</UButton> </UButton>
<UButton <UButton
@ -193,7 +200,7 @@
icon="i-lucide-pencil" icon="i-lucide-pencil"
@click="editor.openEdit(item)" @click="editor.openEdit(item)"
> >
<span v-if="!isMobile">Edit</span> <span class="hidden sm:inline">Edit</span>
</UButton> </UButton>
<UButton <UButton
@ -203,7 +210,7 @@
icon="i-lucide-trash" icon="i-lucide-trash"
@click="() => void deleteItem(item)" @click="() => void deleteItem(item)"
> >
<span v-if="!isMobile">Delete</span> <span class="hidden sm:inline">Delete</span>
</UButton> </UButton>
</div> </div>
</td> </td>
@ -217,7 +224,11 @@
<div v-for="item in filteredPresets" :key="item.id" class="min-w-0 w-full max-w-full"> <div v-for="item in filteredPresets" :key="item.id" class="min-w-0 w-full max-w-full">
<UCard <UCard
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default" class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }" :ui="{
header: 'p-4 pb-3',
body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
footer: 'border-t border-default px-4 py-4',
}"
> >
<template #header> <template #header>
<div class="flex min-w-0 items-start justify-between gap-3"> <div class="flex min-w-0 items-start justify-between gap-3">
@ -244,7 +255,7 @@
square square
@click="exportItem(item)" @click="exportItem(item)"
> >
<span class="hidden sm:inline">Export Preset</span> <span>Export Preset</span>
</UButton> </UButton>
<label class="inline-flex cursor-pointer items-center justify-center"> <label class="inline-flex cursor-pointer items-center justify-center">
@ -348,27 +359,29 @@
</button> </button>
</div> </div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1"> <template #footer>
<UButton <div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
color="neutral" <UButton
variant="outline" color="neutral"
icon="i-lucide-pencil" variant="outline"
class="w-full justify-center" icon="i-lucide-pencil"
@click="editor.openEdit(item)" class="w-full justify-center"
> @click="editor.openEdit(item)"
Edit >
</UButton> Edit
</UButton>
<UButton <UButton
color="neutral" color="neutral"
variant="outline" variant="outline"
icon="i-lucide-trash" icon="i-lucide-trash"
class="w-full justify-center" class="w-full justify-center"
@click="() => void deleteItem(item)" @click="() => void deleteItem(item)"
> >
Delete Delete
</UButton> </UButton>
</div> </div>
</template>
</UCard> </UCard>
</div> </div>
</div> </div>
@ -461,7 +474,7 @@ const { confirmDialog } = useDialog();
const { toggleExpand, expandClass } = useExpandableMeta(); 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: 639 });
const query = ref(''); const query = ref('');
const showFilter = ref(false); const showFilter = ref(false);
@ -496,6 +509,9 @@ const allSelected = computed(
); );
const hasSelected = computed(() => selectedIds.value.length > 0); const hasSelected = computed(() => selectedIds.value.length > 0);
const contentStyle = computed<'list' | 'grid'>(() =>
isMobile.value ? 'grid' : 'list' === display_style.value ? 'list' : 'grid',
);
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [ const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
[ [

View file

@ -37,7 +37,7 @@
icon="i-lucide-filter" icon="i-lucide-filter"
@click="toggleFilterPanel" @click="toggleFilterPanel"
> >
<span v-if="!isMobile">Filter</span> <span>Filter</span>
</UButton> </UButton>
<UButton <UButton
@ -47,7 +47,7 @@
icon="i-lucide-search" icon="i-lucide-search"
@click="inspect = true" @click="inspect = true"
> >
<span v-if="!isMobile">Inspect</span> <span>Inspect</span>
</UButton> </UButton>
<UButton <UButton
@ -57,7 +57,7 @@
icon="i-lucide-plus" icon="i-lucide-plus"
@click="openCreate" @click="openCreate"
> >
<span v-if="!isMobile">New Definition</span> <span>New Definition</span>
</UButton> </UButton>
<UButton <UButton
@ -65,9 +65,10 @@
variant="outline" variant="outline"
size="sm" size="sm"
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'" :icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle" @click="toggleDisplayStyle"
> >
<span v-if="!isMobile">{{ display_style === 'list' ? 'List' : 'Grid' }}</span> <span class="hidden sm:inline">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
</UButton> </UButton>
<UButton <UButton
@ -79,7 +80,7 @@
:disabled="isLoading" :disabled="isLoading"
@click="() => void reloadContent()" @click="() => void reloadContent()"
> >
<span v-if="!isMobile">Reload</span> <span>Reload</span>
</UButton> </UButton>
</div> </div>
</div> </div>
@ -129,13 +130,15 @@
</div> </div>
<div <div
v-if="display_style === 'list' && filteredDefinitions.length > 0" v-if="contentStyle === '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"
> >
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain"> <div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-245 w-full text-sm"> <table class="min-w-255 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]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th class="w-12"> <th class="w-12">
<button type="button" class="cursor-pointer" @click="toggleMasterSelection"> <button type="button" class="cursor-pointer" @click="toggleMasterSelection">
<UIcon <UIcon
@ -147,7 +150,7 @@
<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>
<th class="w-44 whitespace-nowrap">Actions</th> <th class="w-48 whitespace-nowrap">Actions</th>
</tr> </tr>
</thead> </thead>
@ -155,7 +158,7 @@
<tr <tr
v-for="definition in filteredDefinitions" v-for="definition in filteredDefinitions"
:key="definition.id" :key="definition.id"
class="hover:bg-muted/20" class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
> >
<td class="px-3 py-3 text-center align-middle"> <td class="px-3 py-3 text-center align-middle">
<label class="inline-flex cursor-pointer items-center justify-center"> <label class="inline-flex cursor-pointer items-center justify-center">
@ -198,7 +201,9 @@
</div> </div>
</td> </td>
<td class="px-3 py-3 text-center align-middle">{{ definition.priority }}</td> <td class="px-3 py-3 text-center align-middle">
{{ definition.priority }}
</td>
<td class="px-3 py-3 text-center align-middle whitespace-nowrap"> <td class="px-3 py-3 text-center align-middle whitespace-nowrap">
<UTooltip :text="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')"> <UTooltip :text="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')">
@ -210,7 +215,7 @@
</UTooltip> </UTooltip>
</td> </td>
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap"> <td class="w-48 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="neutral" color="neutral"
@ -219,7 +224,7 @@
icon="i-lucide-file-up" icon="i-lucide-file-up"
@click="() => void exportDefinition(definition)" @click="() => void exportDefinition(definition)"
> >
<span v-if="!isMobile">Export</span> <span class="hidden sm:inline">Export</span>
</UButton> </UButton>
<UButton <UButton
@ -229,7 +234,7 @@
icon="i-lucide-pencil" icon="i-lucide-pencil"
@click="() => void openEdit(definition)" @click="() => void openEdit(definition)"
> >
<span v-if="!isMobile">Edit</span> <span class="hidden sm:inline">Edit</span>
</UButton> </UButton>
<UButton <UButton
@ -239,7 +244,7 @@
icon="i-lucide-trash" icon="i-lucide-trash"
@click="() => void remove(definition)" @click="() => void remove(definition)"
> >
<span v-if="!isMobile">Delete</span> <span class="hidden sm:inline">Delete</span>
</UButton> </UButton>
</div> </div>
</td> </td>
@ -260,7 +265,11 @@
> >
<UCard <UCard
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default" class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }" :ui="{
header: 'p-4 pb-3',
body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
footer: 'border-t border-default px-4 py-4',
}"
> >
<template #header> <template #header>
<div class="flex min-w-0 items-start justify-between gap-3"> <div class="flex min-w-0 items-start justify-between gap-3">
@ -287,7 +296,7 @@
square square
@click="() => void exportDefinition(definition)" @click="() => void exportDefinition(definition)"
> >
<span class="hidden sm:inline">Export Definition</span> <span>Export Definition</span>
</UButton> </UButton>
<label class="inline-flex cursor-pointer items-center justify-center"> <label class="inline-flex cursor-pointer items-center justify-center">
@ -340,27 +349,29 @@
</button> </button>
</div> </div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1"> <template #footer>
<UButton <div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
color="neutral" <UButton
variant="outline" color="neutral"
icon="i-lucide-pencil" variant="outline"
class="w-full justify-center" icon="i-lucide-pencil"
@click="() => void openEdit(definition)" class="w-full justify-center"
> @click="() => void openEdit(definition)"
Edit >
</UButton> Edit
</UButton>
<UButton <UButton
color="neutral" color="neutral"
variant="outline" variant="outline"
icon="i-lucide-trash" icon="i-lucide-trash"
class="w-full justify-center" class="w-full justify-center"
@click="() => void remove(definition)" @click="() => void remove(definition)"
> >
Delete Delete
</UButton> </UButton>
</div> </div>
</template>
</UCard> </UCard>
</div> </div>
</div> </div>
@ -473,7 +484,6 @@ const DEFAULT_DEFINITION: TaskDefinitionDocument = {
}, },
}; };
const isMobile = useMediaQuery({ maxWidth: 1024 });
const { toggleExpand, expandClass } = useExpandableMeta(); const { toggleExpand, expandClass } = useExpandableMeta();
const taskDefs = useTaskDefinitionsComposable(); const taskDefs = useTaskDefinitionsComposable();
@ -500,6 +510,7 @@ const workingDefinition = ref<TaskDefinitionDocument | null>(null);
const workingId = ref<number | null>(null); const workingId = ref<number | null>(null);
const inspect = ref(false); const inspect = ref(false);
const display_style = useStorage<'list' | 'grid'>('task-definitions:display', 'grid'); const display_style = useStorage<'list' | 'grid'>('task-definitions:display', 'grid');
const isMobile = useMediaQuery({ maxWidth: 639 });
const query = ref(''); const query = ref('');
const showFilter = ref(false); const showFilter = ref(false);
@ -541,6 +552,9 @@ const allSelected = computed(
); );
const hasSelected = computed(() => selectedIds.value.length > 0); const hasSelected = computed(() => selectedIds.value.length > 0);
const contentStyle = computed<'list' | 'grid'>(() =>
isMobile.value ? 'grid' : display_style.value,
);
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [ const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
[ [

View file

@ -38,7 +38,7 @@
icon="i-lucide-filter" icon="i-lucide-filter"
@click="toggleFilterPanel" @click="toggleFilterPanel"
> >
<span v-if="!isMobile">Filter</span> <span>Filter</span>
</UButton> </UButton>
<UButton <UButton
@ -48,7 +48,7 @@
icon="i-lucide-plus" icon="i-lucide-plus"
@click="openCreateForm" @click="openCreateForm"
> >
<span v-if="!isMobile">New Task</span> <span>New Task</span>
</UButton> </UButton>
<UButton <UButton
@ -56,9 +56,10 @@
variant="outline" variant="outline"
size="sm" size="sm"
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'" :icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle" @click="toggleDisplayStyle"
> >
<span v-if="!isMobile">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span> <span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
</UButton> </UButton>
<UButton <UButton
@ -71,7 +72,7 @@
:disabled="isLoading" :disabled="isLoading"
@click="() => void reloadContent()" @click="() => void reloadContent()"
> >
<span v-if="!isMobile">Reload</span> <span>Reload</span>
</UButton> </UButton>
</div> </div>
</div> </div>
@ -112,13 +113,15 @@
</div> </div>
<div <div
v-if="displayStyle === 'list' && filteredTasks.length > 0" v-if="contentStyle === 'list' && filteredTasks.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"
> >
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain"> <div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-190 table-fixed w-full text-sm"> <table class="min-w-210 table-fixed 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]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th class="w-12"> <th class="w-12">
<button type="button" class="cursor-pointer" @click="toggleMasterSelection"> <button type="button" class="cursor-pointer" @click="toggleMasterSelection">
<UIcon <UIcon
@ -128,13 +131,17 @@
</button> </button>
</th> </th>
<th class="w-full text-left">Task</th> <th class="w-full text-left">Task</th>
<th class="w-100 whitespace-nowrap">Timer</th> <th class="w-50 whitespace-nowrap">Timer</th>
<th class="w-44 whitespace-nowrap">Actions</th> <th class="w-75 whitespace-nowrap">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-default"> <tbody class="divide-y divide-default">
<tr v-for="item in filteredTasks" :key="item.id" class="align-top hover:bg-muted/20"> <tr
v-for="item in filteredTasks"
:key="item.id"
class="align-top transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
>
<td class="px-3 py-3 text-center align-top"> <td class="px-3 py-3 text-center align-top">
<label class="inline-flex cursor-pointer items-center justify-center"> <label class="inline-flex cursor-pointer items-center justify-center">
<input <input
@ -224,9 +231,8 @@
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 }} Preset: {{ item.preset ?? config.app.default_preset }}
</span> </span>
</span> </span>
</div> </div>
@ -273,7 +279,7 @@
</div> </div>
</td> </td>
<td class="w-44 px-3 py-3 align-top whitespace-nowrap"> <td class="w-56 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="neutral" color="neutral"
@ -282,7 +288,7 @@
icon="i-lucide-pencil" icon="i-lucide-pencil"
@click="editItem(item)" @click="editItem(item)"
> >
<span v-if="!isMobile">Edit</span> Edit
</UButton> </UButton>
<UButton <UButton
@ -292,7 +298,7 @@
icon="i-lucide-trash" icon="i-lucide-trash"
@click="() => void deleteItem(item)" @click="() => void deleteItem(item)"
> >
<span v-if="!isMobile">Delete</span> Delete
</UButton> </UButton>
<UDropdownMenu :items="itemActionGroups(item)" :modal="false"> <UDropdownMenu :items="itemActionGroups(item)" :modal="false">
@ -303,7 +309,7 @@
icon="i-lucide-settings-2" icon="i-lucide-settings-2"
trailing-icon="i-lucide-chevron-down" trailing-icon="i-lucide-chevron-down"
> >
<span v-if="!isMobile">Actions</span> Actions
</UButton> </UButton>
</UDropdownMenu> </UDropdownMenu>
</div> </div>
@ -321,7 +327,11 @@
<div v-for="item in filteredTasks" :key="item.id" class="min-w-0 w-full max-w-full"> <div v-for="item in filteredTasks" :key="item.id" class="min-w-0 w-full max-w-full">
<UCard <UCard
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default" class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }" :ui="{
header: 'p-4 pb-3',
body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
footer: 'border-t border-default px-4 py-4',
}"
> >
<template #header> <template #header>
<div class="flex min-w-0 items-start justify-between gap-3"> <div class="flex min-w-0 items-start justify-between gap-3">
@ -377,7 +387,7 @@
square square
@click="() => void exportItem(item)" @click="() => void exportItem(item)"
> >
<span class="hidden sm:inline">Export Task</span> <span>Export Task</span>
</UButton> </UButton>
<label class="inline-flex cursor-pointer items-center justify-center"> <label class="inline-flex cursor-pointer items-center justify-center">
<input <input
@ -392,10 +402,10 @@
</template> </template>
<div class="space-y-2 text-sm text-default"> <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"> <div class="grid grid-cols-2 gap-2 text-xs text-toned sm:flex sm:flex-wrap">
<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="flex min-w-0 w-full items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default sm:w-auto sm:flex-none sm:shrink-0 sm:whitespace-nowrap"
@click="() => void toggleFlag(item, 'enabled')" @click="() => void toggleFlag(item, 'enabled')"
> >
<UIcon <UIcon
@ -408,7 +418,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="flex min-w-0 w-full items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default sm:w-auto sm:flex-none sm:shrink-0 sm:whitespace-nowrap"
@click="() => void toggleFlag(item, 'auto_start')" @click="() => void toggleFlag(item, 'auto_start')"
> >
<UIcon <UIcon
@ -421,7 +431,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="flex min-w-0 w-full items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default sm:w-auto sm:flex-none sm:shrink-0 sm:whitespace-nowrap"
@click="() => void toggleFlag(item, 'handler_enabled')" @click="() => void toggleFlag(item, 'handler_enabled')"
> >
<UIcon <UIcon
@ -432,12 +442,18 @@
<span>Handler: {{ item.handler_enabled !== false ? 'On' : 'Off' }}</span> <span>Handler: {{ item.handler_enabled !== false ? 'On' : 'Off' }}</span>
</button> </button>
<span <button
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1" type="button"
class="flex min-w-0 w-full items-start gap-1 rounded-md border border-default px-2 py-1 text-left transition hover:border-primary hover:text-default sm:flex-1"
@click="toggleExpand(item.id, 'preset')"
> >
<UIcon name="i-lucide-sliders-horizontal" class="size-3.5" /> <UIcon name="i-lucide-sliders-horizontal" class="size-3.5" />
<span>Preset: {{ item.preset ?? config.app.default_preset }}</span> <span class="min-w-0 flex-1">
</span> <span :class="['min-w-0 capitalize', expandClass(item.id, 'preset')]">
Preset: {{ item.preset ?? config.app.default_preset }}
</span>
</span>
</button>
</div> </div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
@ -541,39 +557,41 @@
</div> </div>
</div> </div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1"> <template #footer>
<UButton <div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
color="neutral"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@click="editItem(item)"
>
Edit
</UButton>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void deleteItem(item)"
>
Delete
</UButton>
<UDropdownMenu :items="itemActionGroups(item)" :modal="false">
<UButton <UButton
color="neutral" color="neutral"
variant="outline" variant="outline"
icon="i-lucide-settings-2" icon="i-lucide-pencil"
trailing-icon="i-lucide-chevron-down"
class="w-full justify-center" class="w-full justify-center"
@click="editItem(item)"
> >
Actions Edit
</UButton> </UButton>
</UDropdownMenu>
</div> <UButton
color="neutral"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void deleteItem(item)"
>
Delete
</UButton>
<UDropdownMenu :items="itemActionGroups(item)" :modal="false">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-settings-2"
trailing-icon="i-lucide-chevron-down"
class="w-full justify-center"
>
Actions
</UButton>
</UDropdownMenu>
</div>
</template>
</UCard> </UCard>
</div> </div>
</div> </div>
@ -702,7 +720,7 @@ const { confirmDialog } = useDialog();
const sessionCache = useSessionCache(); const sessionCache = useSessionCache();
const { toggleExpand, expandClass } = useExpandableMeta(); 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: 639 });
const tasksComposable = useTasks(); const tasksComposable = useTasks();
const { const {
@ -744,6 +762,9 @@ const taskHandlerSupport = ref<Record<string, boolean>>(sessionCache.get(CACHE_K
const displayStyle = computed<'list' | 'grid'>(() => const displayStyle = computed<'list' | 'grid'>(() =>
display_style.value === 'list' ? 'list' : 'grid', display_style.value === 'list' ? 'list' : 'grid',
); );
const contentStyle = computed<'list' | 'grid'>(() =>
isMobile.value ? 'grid' : displayStyle.value,
);
const editorSessionId = ref(0); const editorSessionId = ref(0);