Refactor: standardize the look and feel of the pages after recent nuxt ui migration

This commit is contained in:
arabcoders 2026-04-15 23:53:15 +03:00
parent bb246cc6f5
commit 70648c2253
14 changed files with 1222 additions and 942 deletions

View file

@ -486,22 +486,19 @@ import { ref, onMounted, readonly } from 'vue';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import moment from 'moment'; import moment from 'moment';
import type { toastPosition } from '~/composables/useNotification'; import type { toastPosition } from '~/composables/useNotification';
import { getDocsNavigationEntries } from '~/composables/useDocs';
import type { YTDLPOption } from '~/types/ytdlp'; import type { YTDLPOption } from '~/types/ytdlp';
import { useDialog } from '~/composables/useDialog'; import { useDialog } from '~/composables/useDialog';
import Dialog from '~/components/Dialog.vue'; import Dialog from '~/components/Dialog.vue';
import Simple from '~/components/Simple.vue'; import Simple from '~/components/Simple.vue';
import Shutdown from '~/components/shutdown.vue'; import Shutdown from '~/components/shutdown.vue';
import type { version_check } from '~/types'; import type { version_check } from '~/types';
import {
type NavEntry = { getActiveNavItem,
id: string; getNavItems,
label: string; getNavSections,
description?: string; isNavItemActive,
icon: string; type NavItem,
to?: string; } from '~/utils/topLevelNavigation';
children?: NavEntry[];
};
type SidebarSection = { type SidebarSection = {
id: string; id: string;
@ -552,166 +549,51 @@ const navigationUi = (collapsed: boolean) => ({
linkLabel: collapsed ? 'hidden' : 'truncate', linkLabel: collapsed ? 'hidden' : 'truncate',
}); });
const navEntryPath = (item: NavEntry): string => (item.to ? item.to.split(/[?#]/)[0] || '/' : ''); const makeNavigationItem = (item: NavItem): NavigationMenuItem => ({
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 => ({
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), active: isNavItemActive(item, route),
}); });
const docsNavigationEntries = getDocsNavigationEntries(); const navigationAvailability = computed(() => ({
fileLogging: Boolean(config.app?.file_logging),
consoleEnabled: Boolean(config.app?.console_enabled),
}));
const allNavItems = computed<NavEntry[]>(() => [ const navItems = computed(() => getNavItems(navigationAvailability.value));
{
id: 'downloads', const groupSectionEntries = (entries: Array<NavItem>): Array<Array<NavItem>> => {
label: 'Downloads', const order = [...new Set(entries.map((entry) => entry.group))];
description: 'Manage queued, active, and completed downloads in one workspace.', return order.map((group) => entries.filter((entry) => entry.group === group));
icon: 'i-lucide-download', };
to: '/',
}, const sidebarItems = computed<
{ Array<{
id: 'history', id: string;
label: 'History', label: string;
description: 'Jump to the history section on the downloads page.', items: Array<Array<NavItem>>;
icon: 'i-lucide-history', }>
to: '/#history', >(() => {
}, return getNavSections()
{ .map((section) => {
id: 'files', const sectionEntries = navItems.value.filter(
label: 'Files', (entry) => entry.section === section.id && entry.sidebarVisible !== false,
description: 'Browse downloaded files.', );
icon: 'i-lucide-folder-tree',
to: '/browser', return {
}, id: section.id,
{ label: section.label,
id: 'presets', items: groupSectionEntries(sectionEntries),
label: 'Presets', };
description: })
'Presets are pre-defined command options for yt-dlp that you want to apply to given download.', .filter((section) => section.items.some((group) => group.length > 0));
icon: 'i-lucide-sliders-horizontal', });
to: '/presets',
},
{
id: 'custom-fields',
label: 'Custom Fields',
description: 'Custom fields allow you to add new fields to the download form.',
icon: 'i-lucide-braces',
to: '/dl_fields',
},
{
id: 'conditions',
label: 'Conditions',
description: 'Run yt-dlp custom match filter on returned info and apply options.',
icon: 'i-lucide-filter',
to: '/conditions',
},
{
id: 'notifications',
label: 'Notifications',
description: 'Send notifications to your webhooks based on specified events or presets.',
icon: 'i-lucide-bell',
to: '/notifications',
},
{
id: 'tasks',
label: 'Tasks',
icon: 'i-lucide-list-todo',
children: [
{
id: 'tasks-list',
label: 'Tasks',
description: 'Queue playlist/channels for automatic download at specified intervals.',
icon: 'i-lucide-list-todo',
to: '/tasks',
},
{
id: 'task-definitions',
label: 'Task Definitions',
description: 'Create definitions to turn any website into a downloadable feed of links.',
icon: 'i-lucide-workflow',
to: '/task_definitions',
},
],
},
{
id: 'tools',
label: 'Tools',
icon: 'i-lucide-wrench',
children: [
...(config.app?.file_logging
? [
{
id: 'logs',
label: 'Logs',
description: 'Scroll near the top to load older logs.',
icon: 'i-lucide-file-text',
to: '/logs',
},
]
: []),
...(config.app.console_enabled
? [
{
id: 'console',
label: 'Console',
description: 'Run yt-dlp commands directly in a non-interactive session.',
icon: 'i-lucide-terminal',
to: '/console',
},
]
: []),
],
},
{
id: 'docs',
label: 'Docs',
icon: 'i-lucide-book-open',
children: [
...docsNavigationEntries,
{
id: 'changelog',
label: 'Changelog',
description:
'Latest project changes, loaded remotely when available and falling back to the bundled changelog file.',
icon: 'i-lucide-list',
to: '/changelog',
},
],
},
]);
const pageTitle = computed(() => { const pageTitle = computed(() => {
if (route.path === '/') { const match = getActiveNavItem(route, navigationAvailability.value);
return 'Downloads'; return match?.navbarTitle || match?.label || 'YTPTube';
}
const flat = allNavItems.value.flatMap((item) => [item, ...(item.children || [])]);
const match = flat
.filter((item) => isNavEntryActive(item))
.sort((left, right) => navEntryPath(right).length - navEntryPath(left).length)[0];
return match?.label || 'YTPTube';
}); });
const buildTooltip = computed( const buildTooltip = computed(
@ -735,81 +617,30 @@ const connectionBannerIcon = computed(() =>
socket.connectionStatus === 'connecting' ? 'i-lucide-loader-circle' : 'i-lucide-info', socket.connectionStatus === 'connecting' ? 'i-lucide-loader-circle' : 'i-lucide-info',
); );
const sidebarSections = computed<Array<SidebarSection>>(() => { const sidebarSections = computed<Array<SidebarSection>>(() =>
const topLevelItems = (ids: string[]) => sidebarItems.value.map((section) => ({
ids ...section,
.map((id) => allNavItems.value.find((item) => item.id === id)) items: section.items.map((group) => group.map((entry) => makeNavigationItem(entry))),
.filter((item): item is NavEntry => Boolean(item)) })),
.map((item) => makeNavigationItem(item)); );
const childItems = (id: string) =>
(allNavItems.value.find((item) => item.id === id)?.children || []).map((item) =>
makeNavigationItem(item),
);
return [
{
id: 'downloads',
label: 'Downloads',
items:
topLevelItems(['downloads', 'history', 'files']).length > 0
? [topLevelItems(['downloads', 'history', 'files'])]
: [],
},
{
id: 'automation',
label: 'Automation',
items: childItems('tasks').length > 0 ? [childItems('tasks')] : [],
},
{
id: 'configuration',
label: 'Configuration',
items:
topLevelItems(['presets', 'custom-fields', 'conditions', 'notifications']).length > 0
? [topLevelItems(['presets', 'custom-fields', 'conditions', 'notifications'])]
: [],
},
{
id: 'tools',
label: 'Tools',
items: childItems('tools').length > 0 ? [childItems('tools')] : [],
},
{
id: 'docs',
label: 'Docs',
items: childItems('docs').length > 0 ? [childItems('docs')] : [],
},
].filter((section) => section.items.some((group) => group.length > 0));
});
const routeSearchGroups = computed(() => [ const routeSearchGroups = computed(() => [
{ ...sidebarItems.value
id: 'routes', .map((section) => ({
label: 'Routes', id: section.id,
items: allNavItems.value.flatMap((item) => { label: section.label,
const self = item.to items: section.items
? [ .flat()
{ .filter((entry) => entry.searchable !== false)
label: item.label, .map((entry) => ({
description: item.description, label: entry.label,
icon: item.icon, description: entry.description,
suffix: item.to, icon: entry.icon,
onSelect: () => handleRouteSelect(item), suffix: entry.to,
}, onSelect: () => handleRouteSelect(entry),
] })),
: []; }))
.filter((section) => section.items.length > 0),
const children = (item.children || []).map((child) => ({
label: child.label,
description: child.description,
icon: child.icon,
suffix: child.to || '',
onSelect: () => handleRouteSelect(child),
}));
return [...self, ...children];
}),
},
{ {
id: 'downloads', id: 'downloads',
label: 'Downloads', label: 'Downloads',
@ -872,7 +703,7 @@ const syncShellModeClass = () => {
html.classList.toggle('simple-mode', simpleMode.value); html.classList.toggle('simple-mode', simpleMode.value);
}; };
const handleRouteSelect = async (item: NavEntry) => { const handleRouteSelect = async (item: NavItem) => {
await closeRouteSearch(); await closeRouteSearch();
if (item.to) { if (item.to) {

View file

@ -1,43 +1,109 @@
<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-6">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-2"> <div class="flex min-w-0 items-start gap-3">
<div class="flex flex-wrap items-center gap-2 text-sm text-toned"> <span
<nav aria-label="Breadcrumb" class="min-w-0"> class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
<ol class="flex flex-wrap items-center gap-1.5"> >
<template v-for="(item, index) in breadcrumbItems" :key="item.path"> <UIcon :name="pageShell.icon" class="size-5" />
<li v-if="index > 0" aria-hidden="true" class="text-muted">/</li> </span>
<li>
<a <div class="min-w-0 flex-1 space-y-3">
v-if="index !== breadcrumbItems.length - 1" <nav
:href="buildStateUrl(item.path)" aria-label="Breadcrumb"
class="rounded px-1 py-0.5 text-left text-default hover:text-highlighted" class="flex min-w-0 flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
@click="handleBreadcrumbClick($event, item.path)" >
> <span>{{ pageShell.sectionLabel }}</span>
{{ item.name }} <span>/</span>
</a> <span>{{ pageShell.pageLabel }}</span>
<span v-else class="rounded px-1 py-0.5 font-medium text-highlighted">
{{ item.name }} <template v-for="item in breadcrumbTrailItems" :key="item.path">
</span> <span>/</span>
</li>
</template> <button
</ol> type="button"
class="max-w-full truncate normal-case tracking-normal transition hover:text-highlighted"
@click="() => void reloadContent(item.path)"
>
{{ item.name }}
</button>
</template>
<UIcon
v-if="isLoading"
name="i-lucide-loader-circle"
class="size-4 animate-spin text-info"
/>
</nav> </nav>
<UIcon <div>
v-if="isLoading" <h1 class="truncate text-2xl font-semibold text-highlighted">
name="i-lucide-loader-circle" {{ currentDirectoryName }}
class="size-4 animate-spin text-info" </h1>
/> </div>
</div> </div>
<p class="text-sm text-toned">
{{ browserPath }}
</p>
</div> </div>
<div class="flex flex-wrap items-center justify-end gap-2"> <div class="flex flex-col gap-3 xl:items-end">
<div v-if="show_filter" class="relative w-full sm:w-72"> <div class="flex flex-wrap gap-2 xl:justify-end">
<UButton
color="neutral"
:variant="show_filter ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-filter"
@click="toggleFilter"
>
<span>Filter</span>
</UButton>
<UButton
v-if="controlEnabled"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-folder-plus"
@click="() => void handleCreateDirectory()"
>
<span>New Folder</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle"
>
<span class="hidden sm:inline">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
</UButton>
<UDropdownMenu v-if="hasItems" :items="sortGroups" :modal="false">
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-arrow-up-down"
trailing-icon="i-lucide-chevron-down"
>
<span>Sort</span>
</UButton>
</UDropdownMenu>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-refresh-cw"
:loading="isLoading"
:disabled="isLoading"
@click="() => void reloadContent(browserPath)"
>
<span>Reload</span>
</UButton>
</div>
<div v-if="show_filter" class="relative w-full xl:w-80">
<span <span
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned" class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
> >
@ -52,62 +118,6 @@
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary" class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
/> />
</div> </div>
<UButton
color="neutral"
:variant="show_filter ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-filter"
@click="toggleFilter"
>
<span>Filter</span>
</UButton>
<UButton
v-if="controlEnabled"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-folder-plus"
@click="() => void handleCreateDirectory()"
>
<span>New Folder</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle"
>
<span class="hidden sm:inline">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
</UButton>
<UDropdownMenu v-if="hasItems" :items="sortGroups" :modal="false">
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-arrow-up-down"
trailing-icon="i-lucide-chevron-down"
>
<span>Sort</span>
</UButton>
</UDropdownMenu>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-refresh-cw"
:loading="isLoading"
:disabled="isLoading"
@click="() => void reloadContent(browserPath)"
>
<span>Reload</span>
</UButton>
</div> </div>
</div> </div>
@ -532,6 +542,7 @@ import moment from 'moment';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import type { DropdownMenuItem } from '@nuxt/ui'; import type { DropdownMenuItem } from '@nuxt/ui';
import type { FileItem } from '~/types/filebrowser'; import type { FileItem } from '~/types/filebrowser';
import { requirePageShell } from '~/utils/topLevelNavigation';
const route = useRoute(); const route = useRoute();
const toast = useNotification(); const toast = useNotification();
@ -559,9 +570,14 @@ const controlEnabled = computed(() => Boolean(config.app.browser_control_enabled
const contentStyle = computed<'list' | 'grid'>(() => const contentStyle = computed<'list' | 'grid'>(() =>
isMobile.value ? 'grid' : 'list' === display_style.value ? 'list' : 'grid', isMobile.value ? 'grid' : 'list' === display_style.value ? 'list' : 'grid',
); );
const pageShell = requirePageShell('files');
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));
const currentDirectoryName = computed(
() => breadcrumbItems.value[breadcrumbItems.value.length - 1]?.name || 'Home',
);
const breadcrumbTrailItems = computed(() => breadcrumbItems.value.slice(0, -1));
const sortDirectionIcon = computed(() => const sortDirectionIcon = computed(() =>
sort_order.value === 'asc' ? 'i-lucide-arrow-down' : 'i-lucide-arrow-up', sort_order.value === 'asc' ? 'i-lucide-arrow-down' : 'i-lucide-arrow-up',
); );
@ -735,19 +751,6 @@ const clearFilter = (): void => {
show_filter.value = false; show_filter.value = false;
}; };
const isPlainLeftClick = (event: MouseEvent): boolean => {
return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
};
const handleBreadcrumbClick = (event: MouseEvent, path: string): void => {
if (!isPlainLeftClick(event)) {
return;
}
event.preventDefault();
void reloadContent(path);
};
const itemHref = (item: FileItem): string => { const itemHref = (item: FileItem): string => {
return item.content_type === 'dir' ? uri(`/browser/${item.path}`) : downloadHref(item); return item.content_type === 'dir' ? uri(`/browser/${item.path}`) : downloadHref(item);
}; };

View file

@ -1,20 +1,49 @@
<template> <template>
<main class="w-full min-w-0 max-w-full space-y-4"> <main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1"> <div class="flex min-w-0 items-center gap-3">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted"> <span
<UIcon name="i-lucide-git-commit-horizontal" class="size-5 text-toned" /> class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
<span>CHANGELOG</span> >
</div> <UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned"> <div class="min-w-0 space-y-2">
Latest project changes, loaded remotely when available and falling back to the bundled <div
changelog file. class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
</p> >
<span>{{ pageShell.sectionLabel }}</span>
<span>/</span>
<span>{{ pageShell.pageLabel }}</span>
</div>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div> </div>
<div class="flex flex-wrap items-center justify-end gap-2"> <div class="flex flex-col gap-3 xl:items-end">
<div v-if="toggleFilter && logs.length > 0" class="relative w-full sm:w-80"> <div class="flex flex-wrap gap-2 xl:justify-end">
<UButton
v-if="logs.length > 0"
color="neutral"
:variant="toggleFilter ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-filter"
@click="toggleFilter = !toggleFilter"
>
<span>Filter</span>
</UButton>
<USwitch
v-model="latestOnly"
color="primary"
size="sm"
:label="latestOnly ? 'Latest Only' : 'All Loaded'"
:ui="{ root: 'items-center gap-2', wrapper: 'ms-0 text-xs text-toned' }"
/>
</div>
<div v-if="toggleFilter && logs.length > 0" class="relative w-full xl:w-80">
<span <span
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned" class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
> >
@ -29,25 +58,6 @@
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary" class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
/> />
</div> </div>
<UButton
v-if="logs.length > 0"
color="neutral"
:variant="toggleFilter ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-filter"
@click="toggleFilter = !toggleFilter"
>
<span>Filter</span>
</UButton>
<USwitch
v-model="latestOnly"
color="primary"
size="sm"
:label="latestOnly ? 'Latest Only' : 'All Loaded'"
:ui="{ root: 'items-center gap-2', wrapper: 'ms-0 text-xs text-toned' }"
/>
</div> </div>
</div> </div>
@ -188,9 +198,11 @@ import moment from 'moment';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import type { changelogs, changeset } from '~/types/changelogs'; import type { changelogs, changeset } from '~/types/changelogs';
import { request, ucFirst, uri } from '~/utils'; import { request, ucFirst, uri } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
const toast = useNotification(); const toast = useNotification();
const config = useConfigStore(); const config = useConfigStore();
const pageShell = requirePageShell('changelog');
useHead({ title: 'CHANGELOG' }); useHead({ title: 'CHANGELOG' });

View file

@ -1,19 +1,76 @@
<template> <template>
<main class="w-full min-w-0 max-w-full space-y-4"> <main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1"> <div class="flex min-w-0 items-center gap-3">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted"> <span
<UIcon name="i-lucide-filter" class="size-5 text-toned" /> class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
<span>Conditions</span> >
</div> <UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned"> <div class="min-w-0 space-y-2">
Run yt-dlp custom match filter on returned info. and apply options. <div
</p> class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
>
<span>{{ pageShell.sectionLabel }}</span>
<span>/</span>
<span>{{ pageShell.pageLabel }}</span>
</div>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div> </div>
<div class="flex flex-wrap items-center justify-end gap-2"> <div class="flex flex-col gap-3 xl:items-end">
<div v-if="showFilter && items.length > 0" class="relative w-full sm:w-80"> <div class="flex flex-wrap gap-2 xl:justify-end">
<UButton
v-if="items.length > 0"
color="neutral"
:variant="showFilter ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-filter"
@click="toggleFilterPanel"
>
<span>Filter</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-plus"
@click="openCreate"
>
<span>New Condition</span>
</UButton>
<UButton
v-if="items.length > 0"
color="neutral"
variant="outline"
size="sm"
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle"
>
<span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
</UButton>
<UButton
v-if="items.length > 0"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-refresh-cw"
:loading="isLoading"
:disabled="isLoading"
@click="() => void reloadContent()"
>
<span>Reload</span>
</UButton>
</div>
<div v-if="showFilter && items.length > 0" class="relative w-full xl:w-80">
<span <span
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned" class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
> >
@ -28,52 +85,6 @@
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary" class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
/> />
</div> </div>
<UButton
v-if="items.length > 0"
color="neutral"
:variant="showFilter ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-filter"
@click="toggleFilterPanel"
>
<span>Filter</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-plus"
@click="openCreate"
>
<span>New Condition</span>
</UButton>
<UButton
v-if="items.length > 0"
color="neutral"
variant="outline"
size="sm"
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle"
>
<span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
</UButton>
<UButton
v-if="items.length > 0"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-refresh-cw"
:loading="isLoading"
:disabled="isLoading"
@click="() => void reloadContent()"
>
<span>Reload</span>
</UButton>
</div> </div>
</div> </div>
@ -499,10 +510,12 @@ import { useConditions } from '~/composables/useConditions';
import type { Condition } from '~/types/conditions'; import type { Condition } from '~/types/conditions';
import type { APIResponse } from '~/types/responses'; import type { APIResponse } from '~/types/responses';
import { cleanObject, copyText, encode } from '~/utils'; import { cleanObject, copyText, encode } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
type ConditionItemWithUI = Condition & { raw?: boolean }; type ConditionItemWithUI = Condition & { raw?: boolean };
const box = useConfirm(); const box = useConfirm();
const pageShell = requirePageShell('conditions');
const displayStyle = useStorage<'list' | 'grid'>('conditions_display_style', 'grid'); const displayStyle = useStorage<'list' | 'grid'>('conditions_display_style', 'grid');
const isMobile = useMediaQuery({ maxWidth: 639 }); const isMobile = useMediaQuery({ maxWidth: 639 });
const { toggleExpand, expandClass } = useExpandableMeta(); const { toggleExpand, expandClass } = useExpandableMeta();

View file

@ -1,44 +1,57 @@
<template> <template>
<main class="w-full min-w-0 max-w-full space-y-4"> <main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1"> <div class="flex min-w-0 items-start gap-3">
<div class="flex flex-wrap items-center gap-2 text-lg font-semibold text-highlighted"> <span
<UIcon name="i-lucide-terminal" class="size-5 text-toned" /> class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
<span>Console</span> >
<UIcon :name="pageShell.icon" class="size-5" />
</span>
<UBadge :color="sessionStatusColor" variant="soft" size="sm"> <div class="min-w-0 space-y-2">
<span class="inline-flex items-center gap-1.5"> <div
<UIcon class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
:name="sessionStatusIcon"
class="size-3.5"
:class="sessionStatusSpinning ? 'animate-spin' : ''"
/>
<span>{{ sessionStatusLabel }}</span>
</span>
</UBadge>
<UBadge v-if="hasActiveSession" color="neutral" variant="outline" size="sm">
Session {{ shortSessionId }}
</UBadge>
<UBadge
v-if="sessionExitCode !== null && !isLoading"
:color="exitCodeBadgeColor"
variant="outline"
size="sm"
> >
Exit {{ sessionExitCode }} <span>{{ pageShell.sectionLabel }}</span>
</UBadge> <span>/</span>
<span>{{ pageShell.pageLabel }}</span>
</div>
<UBadge v-if="commandHistory.length > 0" color="neutral" variant="outline" size="sm"> <div class="flex flex-wrap items-center gap-2">
{{ commandHistory.length }} saved <UBadge :color="sessionStatusColor" variant="soft" size="sm">
</UBadge> <span class="inline-flex items-center gap-1.5">
<UIcon
:name="sessionStatusIcon"
class="size-3.5"
:class="sessionStatusSpinning ? 'animate-spin' : ''"
/>
<span>{{ sessionStatusLabel }}</span>
</span>
</UBadge>
<UBadge v-if="hasActiveSession" color="neutral" variant="outline" size="sm">
Session {{ shortSessionId }}
</UBadge>
<UBadge
v-if="sessionExitCode !== null && !isLoading"
:color="exitCodeBadgeColor"
variant="outline"
size="sm"
>
Exit {{ sessionExitCode }}
</UBadge>
<UBadge v-if="commandHistory.length > 0" color="neutral" variant="outline" size="sm">
{{ commandHistory.length }} saved
</UBadge>
</div>
<p class="max-w-3xl text-sm text-toned">{{ sessionStatusDescription }}</p>
</div> </div>
<p class="text-sm text-toned">{{ sessionStatusDescription }}</p>
</div> </div>
<div class="flex flex-wrap items-center justify-end gap-2"> <div class="flex flex-wrap gap-2 xl:justify-end">
<UButton <UButton
color="neutral" color="neutral"
variant="outline" variant="outline"
@ -354,6 +367,7 @@ import { useConsoleSession } from '~/composables/useConsoleSession';
import { useDialog } from '~/composables/useDialog'; import { useDialog } from '~/composables/useDialog';
import type { AutoCompleteOptions } from '~/types/autocomplete'; import type { AutoCompleteOptions } from '~/types/autocomplete';
import { disableOpacity, enableOpacity } from '~/utils'; import { disableOpacity, enableOpacity } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
useHead({ title: 'Console' }); useHead({ title: 'Console' });
@ -370,6 +384,7 @@ const config = useConfigStore();
const toast = useNotification(); const toast = useNotification();
const dialog = useDialog(); const dialog = useDialog();
const consoleSession = useConsoleSession(); const consoleSession = useConsoleSession();
const pageShell = requirePageShell('console');
const terminal = ref<Terminal | null>(null); const terminal = ref<Terminal | null>(null);
const terminalFit = ref<FitAddon | null>(null); const terminalFit = ref<FitAddon | null>(null);

View file

@ -1,19 +1,76 @@
<template> <template>
<main class="w-full min-w-0 max-w-full space-y-4"> <main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1"> <div class="flex min-w-0 items-center gap-3">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted"> <span
<UIcon name="i-lucide-braces" class="size-5 text-toned" /> class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
<span>Custom Fields</span> >
</div> <UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned"> <div class="min-w-0 space-y-2">
Custom fields allow you to add new fields to the download form. <div
</p> class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
>
<span>{{ pageShell.sectionLabel }}</span>
<span>/</span>
<span>{{ pageShell.pageLabel }}</span>
</div>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div> </div>
<div class="flex flex-wrap items-center justify-end gap-2"> <div class="flex flex-col gap-3 xl:items-end">
<div v-if="showFilter && items.length > 0" class="relative w-full sm:w-80"> <div class="flex flex-wrap gap-2 xl:justify-end">
<UButton
v-if="items.length > 0"
color="neutral"
:variant="showFilter ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-filter"
@click="toggleFilterPanel"
>
<span>Filter</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-plus"
@click="openCreate"
>
<span>New Field</span>
</UButton>
<UButton
v-if="items.length > 0"
color="neutral"
variant="outline"
size="sm"
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle"
>
<span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
</UButton>
<UButton
v-if="items.length > 0"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-refresh-cw"
:loading="isLoading"
:disabled="isLoading"
@click="() => void reloadContent()"
>
<span>Reload</span>
</UButton>
</div>
<div v-if="showFilter && items.length > 0" class="relative w-full xl:w-80">
<span <span
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned" class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
> >
@ -28,52 +85,6 @@
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary" class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
/> />
</div> </div>
<UButton
v-if="items.length > 0"
color="neutral"
:variant="showFilter ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-filter"
@click="toggleFilterPanel"
>
<span>Filter</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-plus"
@click="openCreate"
>
<span>New Field</span>
</UButton>
<UButton
v-if="items.length > 0"
color="neutral"
variant="outline"
size="sm"
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle"
>
<span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
</UButton>
<UButton
v-if="items.length > 0"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-refresh-cw"
:loading="isLoading"
:disabled="isLoading"
@click="() => void reloadContent()"
>
<span>Reload</span>
</UButton>
</div> </div>
</div> </div>
@ -422,9 +433,11 @@ import { useDlFields } from '~/composables/useDlFields';
import type { DLField } from '~/types/dl_fields'; import type { DLField } from '~/types/dl_fields';
import type { APIResponse } from '~/types/responses'; import type { APIResponse } from '~/types/responses';
import { copyText, encode } from '~/utils'; import { copyText, encode } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
const box = useConfirm(); const box = useConfirm();
const { toggleExpand, expandClass } = useExpandableMeta(); const { toggleExpand, expandClass } = useExpandableMeta();
const pageShell = requirePageShell('custom-fields');
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 isMobile = useMediaQuery({ maxWidth: 639 });
const dlFields = useDlFields(); const dlFields = useDlFields();

View file

@ -1,22 +1,40 @@
<template> <template>
<main class="space-y-6"> <main class="space-y-6">
<UPageHeader :title="docEntry.title" :description="docEntry.description" :ui="pageHeaderUi"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<template #links> <div class="flex min-w-0 items-center gap-3">
<div class="flex flex-wrap items-center gap-2"> <span
<UButton class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
v-for="entry in docsEntries" >
:key="entry.id" <UIcon :name="pageShell.icon" class="size-5" />
:to="entry.route" </span>
:color="entry.file === docEntry.file ? 'primary' : 'neutral'"
:variant="entry.file === docEntry.file ? 'solid' : 'outline'" <div class="min-w-0 space-y-2">
size="sm" <div
:icon="entry.icon" class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
> >
{{ entry.navLabel }} <span>{{ pageShell.sectionLabel }}</span>
</UButton> <span>/</span>
<span>{{ pageShell.pageLabel }}</span>
</div>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div> </div>
</template> </div>
</UPageHeader>
<div class="flex flex-wrap gap-2 xl:justify-end">
<UButton
v-for="entry in docsEntries"
:key="entry.id"
:to="entry.route"
:color="entry.file === docEntry.file ? 'primary' : 'neutral'"
:variant="entry.file === docEntry.file ? 'solid' : 'outline'"
size="sm"
:icon="entry.icon"
>
{{ entry.navLabel }}
</UButton>
</div>
</div>
<UPageCard variant="outline" :ui="pageCardUi"> <UPageCard variant="outline" :ui="pageCardUi">
<template #body> <template #body>
@ -31,6 +49,7 @@
<script setup lang="ts"> <script setup lang="ts">
import Markdown from '~/components/Markdown.vue'; import Markdown from '~/components/Markdown.vue';
import { DOCS_ENTRIES, getDocsEntryBySlug } from '~/composables/useDocs'; import { DOCS_ENTRIES, getDocsEntryBySlug } from '~/composables/useDocs';
import { requirePageShell } from '~/utils/topLevelNavigation';
const route = useRoute(); const route = useRoute();
@ -49,19 +68,12 @@ const docEntry = computed(() => {
return entry; return entry;
}); });
const pageShell = computed(() => requirePageShell(docEntry.value.id));
useHead(() => ({ useHead(() => ({
title: docEntry.value.title, title: docEntry.value.title,
})); }));
const pageHeaderUi = {
root: 'border-b border-default py-4',
headline: 'hidden',
title: 'text-2xl font-semibold text-highlighted',
description: 'text-sm text-toned',
wrapper: 'flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between',
links: 'flex flex-wrap items-center gap-2',
};
const pageCardUi = { const pageCardUi = {
root: 'w-full bg-default', root: 'w-full bg-default',
container: 'w-full p-0', container: 'w-full p-0',

View file

@ -1,95 +1,94 @@
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<UPageHeader <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
title="Downloads" <div class="flex min-w-0 items-center gap-3">
description="Manage queued, active, and completed downloads in one workspace." <span
:ui="{ class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
root: 'border-b border-default py-4',
headline: 'hidden',
title: 'text-2xl font-semibold text-highlighted',
description: 'text-sm text-toned',
wrapper: 'flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between',
links: 'flex flex-wrap items-center gap-2',
}"
>
<template #links>
<UDashboardToolbar
:ui="{
root: 'w-full border-0 p-0',
left: 'flex flex-wrap items-center gap-2',
right: 'flex flex-wrap items-center gap-2',
}"
> >
<template #left> <UIcon :name="pageShell.icon" class="size-5" />
<UInput </span>
v-if="toggleFilter"
id="filter"
v-model.lazy="query"
type="search"
placeholder="Filter displayed content"
icon="i-lucide-filter"
size="sm"
class="w-full sm:w-72"
/>
<UButton <div class="min-w-0 space-y-2">
color="neutral" <div
variant="outline" class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
size="sm" >
icon="i-lucide-filter" <span>{{ pageShell.sectionLabel }}</span>
@click="toggleFilter = !toggleFilter" <span>/</span>
> <span>{{ pageShell.pageLabel }}</span>
<span>Filter</span> </div>
</UButton>
<UButton <p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
v-if="false === config.paused" </div>
color="neutral" </div>
variant="outline"
size="sm"
icon="i-lucide-pause"
@click="() => void pauseDownload()"
>
<span>Pause</span>
</UButton>
<UButton <div class="flex flex-col gap-3 xl:items-end">
v-else <div class="flex flex-wrap gap-2 xl:justify-end">
color="neutral" <UButton
variant="outline" color="neutral"
size="sm" :variant="toggleFilter ? 'soft' : 'outline'"
icon="i-lucide-play" size="sm"
@click="() => void resumeDownload()" icon="i-lucide-filter"
> @click="toggleFilter = !toggleFilter"
<span>Resume</span> >
</UButton> <span>Filter</span>
</UButton>
<UButton <UButton
color="neutral" v-if="false === config.paused"
variant="outline" color="neutral"
size="sm" variant="outline"
icon="i-lucide-plus" size="sm"
@click="config.showForm = !config.showForm" icon="i-lucide-pause"
> @click="() => void pauseDownload()"
<span>Add</span> >
</UButton> <span>Pause</span>
</template> </UButton>
<template #right> <UButton
<UButton v-else
color="neutral" color="neutral"
variant="outline" variant="outline"
size="sm" size="sm"
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'" icon="i-lucide-play"
class="hidden sm:inline-flex" @click="() => void resumeDownload()"
@click="changeDisplay" >
> <span>Resume</span>
<span class="hidden sm:inline">{{ display_style === 'list' ? 'List' : 'Grid' }}</span> </UButton>
</UButton>
</template> <UButton
</UDashboardToolbar> color="neutral"
</template> variant="outline"
</UPageHeader> size="sm"
icon="i-lucide-plus"
@click="config.showForm = !config.showForm"
>
<span>Add</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="changeDisplay"
>
<span class="hidden sm:inline">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
</UButton>
</div>
<UInput
v-if="toggleFilter"
id="filter"
v-model.lazy="query"
type="search"
placeholder="Filter displayed content"
icon="i-lucide-filter"
size="sm"
class="w-full xl:w-80"
/>
</div>
</div>
<div v-if="config.showForm" ref="formSection" class="page-form-wrap scroll-mt-24"> <div v-if="config.showForm" ref="formSection" class="page-form-wrap scroll-mt-24">
<NewDownload <NewDownload
@ -111,19 +110,6 @@
<div v-else class="space-y-8"> <div v-else class="space-y-8">
<section id="queue" class="scroll-mt-24 space-y-4"> <section id="queue" 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-download" class="size-4 text-toned" />
<h2>Queue</h2>
</div>
<p class="text-sm text-toned">Active and queued downloads.</p>
</div>
<UBadge color="info" variant="soft" size="sm">{{ queueCount }}</UBadge>
</div>
<Queue <Queue
:thumbnails="show_thumbnail" :thumbnails="show_thumbnail"
:query="query" :query="query"
@ -137,14 +123,14 @@
</section> </section>
<section id="history" class="scroll-mt-24 space-y-4"> <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="flex flex-wrap items-start justify-between gap-3 border-b border-default pb-3">
<div class="space-y-1"> <div class="space-y-1.5">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted"> <div class="flex items-center gap-2 text-base font-semibold text-highlighted">
<UIcon name="i-lucide-history" class="size-4 text-toned" /> <UIcon :name="historyShell.icon" class="size-4 text-toned" />
<h2>History</h2> <h2>{{ historyShell.pageLabel }}</h2>
</div> </div>
<p class="text-sm text-toned">Completed, skipped, and failed downloads.</p> <p class="text-sm text-toned">{{ historyShell.description }}</p>
</div> </div>
<UBadge color="neutral" variant="soft" size="sm">{{ historyCount }}</UBadge> <UBadge color="neutral" variant="soft" size="sm">{{ historyCount }}</UBadge>
@ -180,6 +166,7 @@ import { useStorage } from '@vueuse/core';
import { useDialog } from '~/composables/useDialog'; import { useDialog } from '~/composables/useDialog';
import type { item_request } from '~/types/item'; import type { item_request } from '~/types/item';
import type { StoreItem } from '~/types/store'; import type { StoreItem } from '~/types/store';
import { requirePageShell } from '~/utils/topLevelNavigation';
const config = useConfigStore(); const config = useConfigStore();
const stateStore = useStateStore(); const stateStore = useStateStore();
@ -190,6 +177,8 @@ 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 pageShell = requirePageShell('downloads');
const historyShell = requirePageShell('history');
const formSection = ref<HTMLElement | null>(null); const formSection = ref<HTMLElement | null>(null);
const info_view = ref({ const info_view = ref({

View file

@ -1,8 +1,10 @@
<template> <template>
<main class="w-full min-w-0 max-w-full space-y-4"> <main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1"> <div class="flex min-w-0 items-start gap-3">
<div class="flex flex-wrap items-center gap-2 text-lg font-semibold text-highlighted"> <span
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
>
<UIcon <UIcon
name="i-lucide-file-text" name="i-lucide-file-text"
:class="[ :class="[
@ -12,7 +14,16 @@
:title="loading ? 'Loading history' : 'Live stream active'" :title="loading ? 'Loading history' : 'Live stream active'"
:aria-label="loading ? 'Loading history' : 'Live stream active'" :aria-label="loading ? 'Loading history' : 'Live stream active'"
/> />
<span>Logs</span> </span>
<div class="min-w-0 space-y-2">
<div
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
>
<span>{{ pageShell.sectionLabel }}</span>
<span>/</span>
<span>{{ pageShell.pageLabel }}</span>
</div>
<UBadge v-if="loading" color="info" variant="soft" size="sm">Loading history</UBadge> <UBadge v-if="loading" color="info" variant="soft" size="sm">Loading history</UBadge>
@ -36,24 +47,52 @@
<UBadge v-if="reachedEnd && !hasActiveFilter" color="neutral" variant="soft" size="sm"> <UBadge v-if="reachedEnd && !hasActiveFilter" color="neutral" variant="soft" size="sm">
Start of file loaded Start of file loaded
</UBadge> </UBadge>
</div>
<p class="text-sm text-toned">Scroll near the top to load older logs.</p> <p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div> </div>
<div class="flex flex-wrap items-center justify-end gap-2"> <div class="flex flex-col gap-3 xl:items-end">
<UButton <div class="flex flex-wrap gap-2 xl:justify-end">
v-if="!autoScroll" <UButton
color="neutral" v-if="!autoScroll"
variant="outline" color="neutral"
size="sm" variant="outline"
icon="i-lucide-arrow-down" size="sm"
@click="scrollToBottom(false)" icon="i-lucide-arrow-down"
> @click="scrollToBottom(false)"
Jump to Live Tail >
</UButton> Jump to Live Tail
</UButton>
<div v-if="toggleFilter || query" class="relative w-full sm:w-72"> <UButton
color="neutral"
:variant="toggleFilter ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-filter"
@click="toggleFilter = !toggleFilter"
>
Filter
</UButton>
<UButton
color="neutral"
:variant="textWrap ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-wrap-text"
:aria-pressed="textWrap"
:title="textWrap ? 'Wrap lines enabled' : 'Wrap lines disabled'"
:class="[
'transition-all',
textWrap ? '-translate-y-px ring ring-default shadow-xs' : '',
]"
@click="textWrap = !textWrap"
>
Wrap lines
</UButton>
</div>
<div v-if="toggleFilter || query" class="relative w-full xl:w-80">
<span <span
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned" class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
> >
@ -68,29 +107,6 @@
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary" class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
/> />
</div> </div>
<UButton
color="neutral"
:variant="toggleFilter ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-filter"
@click="toggleFilter = !toggleFilter"
>
Filter
</UButton>
<UButton
color="neutral"
:variant="textWrap ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-wrap-text"
:aria-pressed="textWrap"
:title="textWrap ? 'Wrap lines enabled' : 'Wrap lines disabled'"
:class="['transition-all', textWrap ? '-translate-y-px ring ring-default shadow-xs' : '']"
@click="textWrap = !textWrap"
>
Wrap lines
</UButton>
</div> </div>
</div> </div>
@ -185,6 +201,7 @@ import moment from 'moment';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import type { log_line } from '~/types/logs'; import type { log_line } from '~/types/logs';
import { disableOpacity, enableOpacity, parse_api_error, request, uri } from '~/utils'; import { disableOpacity, enableOpacity, parse_api_error, request, uri } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
type FilteredLogEntry = { type FilteredLogEntry = {
log: log_line; log: log_line;
@ -201,6 +218,7 @@ let scrollTimeout: NodeJS.Timeout | null = null;
const toast = useNotification(); const toast = useNotification();
const config = useConfigStore(); const config = useConfigStore();
const route = useRoute(); const route = useRoute();
const pageShell = requirePageShell('logs');
const logContainer = useTemplateRef<HTMLDivElement>('logContainer'); const logContainer = useTemplateRef<HTMLDivElement>('logContainer');
const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker'); const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker');

View file

@ -1,19 +1,89 @@
<template> <template>
<main class="w-full min-w-0 max-w-full space-y-4"> <main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1"> <div class="flex min-w-0 items-center gap-3">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted"> <span
<UIcon name="i-lucide-bell" class="size-5 text-toned" /> class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
<span>Notifications</span> >
</div> <UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned"> <div class="min-w-0 space-y-2">
Send notifications to your webhooks based on specified events or presets. <div
</p> class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
>
<span>{{ pageShell.sectionLabel }}</span>
<span>/</span>
<span>{{ pageShell.pageLabel }}</span>
</div>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div> </div>
<div class="flex flex-wrap items-center justify-end gap-2"> <div class="flex flex-col gap-3 xl:items-end">
<div v-if="showFilter && notifications.length > 0" class="relative w-full sm:w-80"> <div class="flex flex-wrap gap-2 xl:justify-end">
<UButton
v-if="notifications.length > 0"
color="neutral"
:variant="showFilter ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-filter"
@click="toggleFilterPanel"
>
<span>Filter</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-plus"
@click="openCreate"
>
<span>New Notification</span>
</UButton>
<UButton
v-if="notifications.length > 0"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-send"
:loading="sendingTest"
:disabled="sendingTest"
@click="() => void sendTest()"
>
<span>Send Test</span>
</UButton>
<UButton
v-if="notifications.length > 0"
color="neutral"
variant="outline"
size="sm"
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle"
>
<span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
</UButton>
<UButton
v-if="notifications.length > 0"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-refresh-cw"
:loading="isLoading"
:disabled="isLoading"
@click="() => void reloadContent()"
>
<span>Reload</span>
</UButton>
</div>
<div v-if="showFilter && notifications.length > 0" class="relative w-full xl:w-80">
<span <span
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned" class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
> >
@ -28,65 +98,6 @@
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary" class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
/> />
</div> </div>
<UButton
v-if="notifications.length > 0"
color="neutral"
:variant="showFilter ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-filter"
@click="toggleFilterPanel"
>
<span>Filter</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-plus"
@click="openCreate"
>
<span>New Notification</span>
</UButton>
<UButton
v-if="notifications.length > 0"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-send"
:loading="sendingTest"
:disabled="sendingTest"
@click="() => void sendTest()"
>
<span>Send Test</span>
</UButton>
<UButton
v-if="notifications.length > 0"
color="neutral"
variant="outline"
size="sm"
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle"
>
<span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
</UButton>
<UButton
v-if="notifications.length > 0"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-refresh-cw"
:loading="isLoading"
:disabled="isLoading"
@click="() => void reloadContent()"
>
<span>Reload</span>
</UButton>
</div> </div>
</div> </div>
@ -553,11 +564,13 @@ import { useNotifications } from '~/composables/useNotifications';
import { copyText, encode, parse_api_error, request, ucFirst } from '~/utils'; import { copyText, encode, parse_api_error, request, ucFirst } from '~/utils';
import type { ImportedItem } from '~/types'; import type { ImportedItem } from '~/types';
import type { notification } from '~/types/notification'; import type { notification } from '~/types/notification';
import { requirePageShell } from '~/utils/topLevelNavigation';
const toast = useNotification(); const toast = useNotification();
const box = useConfirm(); const box = useConfirm();
const { confirmDialog } = useDialog(); const { confirmDialog } = useDialog();
const { toggleExpand, expandClass } = useExpandableMeta(); const { toggleExpand, expandClass } = useExpandableMeta();
const pageShell = requirePageShell('notifications');
const displayStyleState = useStorage<'list' | 'grid' | 'cards'>( const displayStyleState = useStorage<'list' | 'grid' | 'cards'>(
'notification_display_style', 'notification_display_style',
'cards', 'cards',

View file

@ -1,20 +1,75 @@
<template> <template>
<main class="w-full min-w-0 max-w-full space-y-4"> <main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1"> <div class="flex min-w-0 items-center gap-3">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted"> <span
<UIcon name="i-lucide-sliders-horizontal" class="size-5 text-toned" /> class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
<span>Presets</span> >
</div> <UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned"> <div class="min-w-0 space-y-2">
Presets are pre-defined command options for yt-dlp that you want to apply to given <div
download. class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
</p> >
<span>{{ pageShell.sectionLabel }}</span>
<span>/</span>
<span>{{ pageShell.pageLabel }}</span>
</div>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div> </div>
<div class="flex flex-wrap items-center justify-end gap-2"> <div class="flex flex-col gap-3 xl:items-end">
<div v-if="showFilter && presetsNoDefault.length > 0" class="relative w-full sm:w-80"> <div class="flex flex-wrap gap-2 xl:justify-end">
<UButton
v-if="presetsNoDefault.length > 0"
color="neutral"
:variant="showFilter ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-filter"
@click="toggleFilterPanel"
>
<span>Filter</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-plus"
@click="editor.openCreate()"
>
<span>New Preset</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle"
>
<span class="hidden sm:inline">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
</UButton>
<UButton
v-if="presets.length > 0"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-refresh-cw"
:loading="isLoading"
:disabled="isLoading"
@click="() => void reloadContent()"
>
<span>Reload</span>
</UButton>
</div>
<div v-if="showFilter && presetsNoDefault.length > 0" class="relative w-full xl:w-80">
<span <span
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned" class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
> >
@ -29,51 +84,6 @@
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary" class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
/> />
</div> </div>
<UButton
v-if="presetsNoDefault.length > 0"
color="neutral"
:variant="showFilter ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-filter"
@click="toggleFilterPanel"
>
<span>Filter</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-plus"
@click="editor.openCreate()"
>
<span>New Preset</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle"
>
<span class="hidden sm:inline">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
</UButton>
<UButton
v-if="presets.length > 0"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-refresh-cw"
:loading="isLoading"
:disabled="isLoading"
@click="() => void reloadContent()"
>
<span>Reload</span>
</UButton>
</div> </div>
</div> </div>
@ -463,6 +473,7 @@ import { useDialog } from '~/composables/useDialog';
import { useExpandableMeta } from '~/composables/useExpandableMeta'; import { useExpandableMeta } from '~/composables/useExpandableMeta';
import { useConfirm } from '~/composables/useConfirm'; import { useConfirm } from '~/composables/useConfirm';
import { prettyName } from '~/utils'; import { prettyName } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
type PresetWithUI = Preset & { raw?: boolean; toggle_description?: boolean }; type PresetWithUI = Preset & { raw?: boolean; toggle_description?: boolean };
@ -470,6 +481,7 @@ const presetsStore = usePresets();
const config = useConfigStore(); const config = useConfigStore();
const box = useConfirm(); const box = useConfirm();
const editor = usePresetEditor(); const editor = usePresetEditor();
const pageShell = requirePageShell('presets');
const { confirmDialog } = useDialog(); const { confirmDialog } = useDialog();
const { toggleExpand, expandClass } = useExpandableMeta(); const { toggleExpand, expandClass } = useExpandableMeta();

View file

@ -1,19 +1,84 @@
<template> <template>
<main class="w-full min-w-0 max-w-full space-y-4"> <main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1"> <div class="flex min-w-0 items-center gap-3">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted"> <span
<UIcon name="i-lucide-workflow" class="size-5 text-toned" /> class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
<span>Task Definitions</span> >
</div> <UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned"> <div class="min-w-0 space-y-2">
Create definitions to turn any website into a downloadable feed of links. <div
</p> class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
>
<span>{{ pageShell.sectionLabel }}</span>
<span>/</span>
<span>{{ pageShell.pageLabel }}</span>
</div>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div> </div>
<div class="flex flex-wrap items-center justify-end gap-2"> <div class="flex flex-col gap-3 xl:items-end">
<div v-if="showFilter && definitions.length > 0" class="relative w-full sm:w-80"> <div class="flex flex-wrap gap-2 xl:justify-end">
<UButton
v-if="definitions.length > 0"
color="neutral"
:variant="showFilter ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-filter"
@click="toggleFilterPanel"
>
<span>Filter</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-search"
@click="inspect = true"
>
<span>Inspect</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-plus"
@click="openCreate"
>
<span>New Definition</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle"
>
<span class="hidden sm:inline">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-refresh-cw"
:loading="isLoading"
:disabled="isLoading"
@click="() => void reloadContent()"
>
<span>Reload</span>
</UButton>
</div>
<div v-if="showFilter && definitions.length > 0" class="relative w-full xl:w-80">
<span <span
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned" class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
> >
@ -28,60 +93,6 @@
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary" class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
/> />
</div> </div>
<UButton
v-if="definitions.length > 0"
color="neutral"
:variant="showFilter ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-filter"
@click="toggleFilterPanel"
>
<span>Filter</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-search"
@click="inspect = true"
>
<span>Inspect</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-plus"
@click="openCreate"
>
<span>New Definition</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle"
>
<span class="hidden sm:inline">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-refresh-cw"
:loading="isLoading"
:disabled="isLoading"
@click="() => void reloadContent()"
>
<span>Reload</span>
</UButton>
</div> </div>
</div> </div>
@ -458,6 +469,7 @@ import useTaskDefinitionsComposable from '~/composables/useTaskDefinitions';
import { useDialog } from '~/composables/useDialog'; import { useDialog } from '~/composables/useDialog';
import { useMediaQuery } from '~/composables/useMediaQuery'; import { useMediaQuery } from '~/composables/useMediaQuery';
import { copyText, encode } from '~/utils'; import { copyText, encode } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
import type { import type {
TaskDefinitionDetailed, TaskDefinitionDetailed,
@ -484,6 +496,8 @@ const DEFAULT_DEFINITION: TaskDefinitionDocument = {
}, },
}; };
const pageShell = requirePageShell('task-definitions');
const { toggleExpand, expandClass } = useExpandableMeta(); const { toggleExpand, expandClass } = useExpandableMeta();
const taskDefs = useTaskDefinitionsComposable(); const taskDefs = useTaskDefinitionsComposable();

View file

@ -1,20 +1,75 @@
<template> <template>
<main class="w-full min-w-0 max-w-full space-y-4"> <main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1"> <div class="flex min-w-0 items-center gap-3">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted"> <span
<UIcon name="i-lucide-list-todo" class="size-5 text-toned" /> class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
<span>Tasks</span> >
</div> <UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned"> <div class="min-w-0 space-y-2">
The task runner is simple queue system that allows you to poll channels or playlists for <div
new content at specified intervals. class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
</p> >
<span>{{ pageShell.sectionLabel }}</span>
<span>/</span>
<span>{{ pageShell.pageLabel }}</span>
</div>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div> </div>
<div class="flex flex-wrap items-center justify-end gap-2"> <div class="flex flex-col gap-3 xl:items-end">
<div v-if="showFilter && tasks.length > 0" class="relative w-full sm:w-80"> <div class="flex flex-wrap gap-2 xl:justify-end">
<UButton
v-if="tasks.length > 0"
color="neutral"
:variant="showFilter ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-filter"
@click="toggleFilterPanel"
>
<span>Filter</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-plus"
@click="openCreateForm"
>
<span>New Task</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle"
>
<span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
</UButton>
<UButton
v-if="tasks.length > 0"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-refresh-cw"
:loading="isLoading"
:disabled="isLoading"
@click="() => void reloadContent()"
>
<span>Reload</span>
</UButton>
</div>
<div v-if="showFilter && tasks.length > 0" class="relative w-full xl:w-80">
<span <span
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned" class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
> >
@ -29,51 +84,6 @@
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary" class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
/> />
</div> </div>
<UButton
v-if="tasks.length > 0"
color="neutral"
:variant="showFilter ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-filter"
@click="toggleFilterPanel"
>
<span>Filter</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-plus"
@click="openCreateForm"
>
<span>New Task</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle"
>
<span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
</UButton>
<UButton
v-if="tasks.length > 0"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-refresh-cw"
:loading="isLoading"
:disabled="isLoading"
@click="() => void reloadContent()"
>
<span>Reload</span>
</UButton>
</div> </div>
</div> </div>
@ -710,12 +720,14 @@ import type { WSEP } from '~/types/sockets';
import { sleep } from '~/utils'; import { sleep } from '~/utils';
import { useSessionCache } from '~/utils/cache'; import { useSessionCache } from '~/utils/cache';
import type { item_request } from '~/types/item'; import type { item_request } from '~/types/item';
import { requirePageShell } from '~/utils/topLevelNavigation';
const box = useConfirm(); const box = useConfirm();
const toast = useNotification(); const toast = useNotification();
const config = useConfigStore(); const config = useConfigStore();
const socket = useSocketStore(); const socket = useSocketStore();
const stateStore = useStateStore(); const stateStore = useStateStore();
const pageShell = requirePageShell('tasks');
const { confirmDialog } = useDialog(); const { confirmDialog } = useDialog();
const sessionCache = useSessionCache(); const sessionCache = useSessionCache();
const { toggleExpand, expandClass } = useExpandableMeta(); const { toggleExpand, expandClass } = useExpandableMeta();

View file

@ -0,0 +1,323 @@
import { DOCS_ENTRIES } from '~/composables/useDocs';
export type SectionId = 'downloads' | 'automation' | 'configuration' | 'tools' | 'docs';
type NavSection = {
id: SectionId;
label: string;
};
type NavDefinition = {
id: string;
section: SectionId;
group: string;
label: string;
pageLabel?: string;
breadcrumbSectionLabel?: string;
description: string;
icon: string;
to: string;
matchPath?: string;
sidebarVisible?: boolean;
searchable?: boolean;
activeMode?: 'path' | 'downloads' | 'history';
navbarTitle?: string;
requires?: 'file_logging' | 'console_enabled';
};
export type NavItem = NavDefinition & {
sectionLabel: string;
pageLabel: string;
matchPath: string;
sidebarVisible: boolean;
searchable: boolean;
};
export type PageShell = {
icon: string;
sectionLabel: string;
pageLabel: string;
description: string;
};
type LocationPath = {
path: string;
hash?: string;
};
type NavAvailability = {
fileLogging?: boolean;
consoleEnabled?: boolean;
};
const SECTIONS: Array<NavSection> = [
{ id: 'downloads', label: 'Downloads' },
{ id: 'automation', label: 'Automation' },
{ id: 'configuration', label: 'Configuration' },
{ id: 'tools', label: 'Tools' },
{ id: 'docs', label: 'Docs' },
];
const NavItems: Array<NavDefinition> = [
{
id: 'downloads',
section: 'downloads',
group: 'workspace',
label: 'Queue',
pageLabel: 'Queue',
breadcrumbSectionLabel: 'Workspace',
description: 'Active and queued downloads.',
icon: 'i-lucide-download',
to: '/',
matchPath: '/',
activeMode: 'downloads',
},
{
id: 'history',
section: 'downloads',
group: 'workspace',
label: 'History',
pageLabel: 'History',
breadcrumbSectionLabel: 'Workspace',
description: 'Completed, skipped, and failed downloads.',
icon: 'i-lucide-history',
to: '/#history',
matchPath: '/',
activeMode: 'history',
navbarTitle: 'Downloads',
},
{
id: 'files',
section: 'downloads',
group: 'workspace',
label: 'Files',
pageLabel: 'Files',
breadcrumbSectionLabel: 'Workspace',
description: 'Browse downloaded files.',
icon: 'i-lucide-folder-tree',
to: '/browser',
matchPath: '/browser',
},
{
id: 'tasks',
section: 'automation',
group: 'automation',
label: 'Tasks',
pageLabel: 'Tasks',
description: 'Queue playlist/channels for automatic download at specified intervals.',
icon: 'i-lucide-list-todo',
to: '/tasks',
matchPath: '/tasks',
},
{
id: 'task-definitions',
section: 'automation',
group: 'automation',
label: 'Task Definitions',
pageLabel: 'Task Definitions',
description: 'Create definitions to turn any website into a downloadable feed of links.',
icon: 'i-lucide-workflow',
to: '/task_definitions',
matchPath: '/task_definitions',
},
{
id: 'presets',
section: 'configuration',
group: 'configuration',
label: 'Presets',
pageLabel: 'Presets',
description:
'Presets are pre-defined command options for yt-dlp that you want to apply to given download.',
icon: 'i-lucide-sliders-horizontal',
to: '/presets',
matchPath: '/presets',
},
{
id: 'custom-fields',
section: 'configuration',
group: 'configuration',
label: 'Custom Fields',
pageLabel: 'Custom Fields',
description: 'Custom fields allow you to add new fields to the download form.',
icon: 'i-lucide-braces',
to: '/dl_fields',
matchPath: '/dl_fields',
},
{
id: 'conditions',
section: 'configuration',
group: 'configuration',
label: 'Conditions',
pageLabel: 'Conditions',
description: 'Run yt-dlp custom match filter on returned info and apply options.',
icon: 'i-lucide-filter',
to: '/conditions',
matchPath: '/conditions',
},
{
id: 'notifications',
section: 'configuration',
group: 'configuration',
label: 'Notifications',
pageLabel: 'Notifications',
description: 'Send notifications to your webhooks based on specified events or presets.',
icon: 'i-lucide-bell',
to: '/notifications',
matchPath: '/notifications',
},
{
id: 'logs',
section: 'tools',
group: 'tools',
label: 'Logs',
pageLabel: 'Logs',
description: 'Scroll near the top to load older logs.',
icon: 'i-lucide-file-text',
to: '/logs',
matchPath: '/logs',
requires: 'file_logging',
},
{
id: 'console',
section: 'tools',
group: 'tools',
label: 'Console',
pageLabel: 'Console',
description: 'Run yt-dlp commands directly in a non-interactive session.',
icon: 'i-lucide-terminal',
to: '/console',
matchPath: '/console',
requires: 'console_enabled',
},
...DOCS_ENTRIES.map<NavDefinition>((entry) => ({
id: entry.id,
section: 'docs',
group: 'docs',
label: entry.navLabel,
pageLabel: entry.title,
description: entry.description,
icon: entry.icon,
to: entry.route,
matchPath: entry.route,
})),
{
id: 'changelog',
section: 'docs',
group: 'docs',
label: 'Changelog',
pageLabel: 'Changelog',
description:
'Latest project changes, loaded remotely when available and falling back to the bundled changelog file.',
icon: 'i-lucide-git-commit-horizontal',
to: '/changelog',
matchPath: '/changelog',
},
];
const normalizePath = (value?: string | null): string => {
if (!value || value === '/') {
return '/';
}
const trimmed = value.replace(/\/+$/, '');
return trimmed === '' ? '/' : trimmed;
};
const getSectionLabel = (sectionId: SectionId): string => {
const section = SECTIONS.find((item) => item.id === sectionId);
return section?.label ?? sectionId;
};
const resolveEntry = (entry: NavDefinition): NavItem => ({
...entry,
sectionLabel: getSectionLabel(entry.section),
pageLabel: entry.pageLabel ?? entry.label,
matchPath: normalizePath(entry.matchPath ?? (entry.to.split(/[?#]/)[0] || '/')),
sidebarVisible: entry.sidebarVisible !== false,
searchable: entry.searchable !== false,
});
const resolvedNavigation = NavItems.map((entry) => resolveEntry(entry));
const matchesAvailability = (entry: NavItem, options: NavAvailability): boolean => {
switch (entry.requires) {
case 'file_logging':
return options.fileLogging === true;
case 'console_enabled':
return options.consoleEnabled === true;
default:
return true;
}
};
export const getNavItems = (options?: NavAvailability): Array<NavItem> => {
if (!options) {
return resolvedNavigation;
}
return resolvedNavigation.filter((entry) => matchesAvailability(entry, options));
};
export const getNavSections = (): Array<NavSection> => {
return SECTIONS;
};
export const getNavItemById = (id: string): NavItem | undefined => {
return resolvedNavigation.find((entry) => entry.id === id);
};
export const isNavItemActive = (entry: NavItem, route: LocationPath): boolean => {
switch (entry.activeMode) {
case 'downloads':
return route.path === '/' && route.hash !== '#history';
case 'history':
return route.path === '/' && route.hash === '#history';
default: {
const current = normalizePath(route.path);
const target = normalizePath(entry.matchPath);
if (target === '/') {
return current === '/';
}
return current === target || current.startsWith(`${target}/`);
}
}
};
export const getActiveNavItem = (
route: LocationPath,
options?: NavAvailability,
): NavItem | undefined => {
return getNavItems(options)
.filter((entry) => isNavItemActive(entry, route))
.sort((left, right) => right.matchPath.length - left.matchPath.length)[0];
};
export const getPageShell = (id: string): PageShell | undefined => {
const entry = getNavItemById(id);
if (!entry) {
return undefined;
}
return {
icon: entry.icon,
sectionLabel: entry.breadcrumbSectionLabel ?? entry.sectionLabel,
pageLabel: entry.pageLabel,
description: entry.description,
};
};
export const requirePageShell = (id: string): PageShell => {
const shell = getPageShell(id);
if (!shell) {
throw new Error(`Missing top-level navigation shell for '${id}'`);
}
return shell;
};