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 moment from 'moment';
import type { toastPosition } from '~/composables/useNotification';
import { getDocsNavigationEntries } from '~/composables/useDocs';
import type { YTDLPOption } from '~/types/ytdlp';
import { useDialog } from '~/composables/useDialog';
import Dialog from '~/components/Dialog.vue';
import Simple from '~/components/Simple.vue';
import Shutdown from '~/components/shutdown.vue';
import type { version_check } from '~/types';
type NavEntry = {
id: string;
label: string;
description?: string;
icon: string;
to?: string;
children?: NavEntry[];
};
import {
getActiveNavItem,
getNavItems,
getNavSections,
isNavItemActive,
type NavItem,
} from '~/utils/topLevelNavigation';
type SidebarSection = {
id: string;
@ -552,166 +549,51 @@ const navigationUi = (collapsed: boolean) => ({
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: NavItem): NavigationMenuItem => ({
label: item.label,
icon: item.icon,
to: item.to,
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[]>(() => [
{
id: 'downloads',
label: 'Downloads',
description: 'Manage queued, active, and completed downloads in one workspace.',
icon: 'i-lucide-download',
to: '/',
},
{
id: 'history',
label: 'History',
description: 'Jump to the history section on the downloads page.',
icon: 'i-lucide-history',
to: '/#history',
},
{
id: 'files',
label: 'Files',
description: 'Browse downloaded files.',
icon: 'i-lucide-folder-tree',
to: '/browser',
},
{
id: 'presets',
label: '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',
},
{
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 navItems = computed(() => getNavItems(navigationAvailability.value));
const groupSectionEntries = (entries: Array<NavItem>): Array<Array<NavItem>> => {
const order = [...new Set(entries.map((entry) => entry.group))];
return order.map((group) => entries.filter((entry) => entry.group === group));
};
const sidebarItems = computed<
Array<{
id: string;
label: string;
items: Array<Array<NavItem>>;
}>
>(() => {
return getNavSections()
.map((section) => {
const sectionEntries = navItems.value.filter(
(entry) => entry.section === section.id && entry.sidebarVisible !== false,
);
return {
id: section.id,
label: section.label,
items: groupSectionEntries(sectionEntries),
};
})
.filter((section) => section.items.some((group) => group.length > 0));
});
const pageTitle = computed(() => {
if (route.path === '/') {
return 'Downloads';
}
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 match = getActiveNavItem(route, navigationAvailability.value);
return match?.navbarTitle || match?.label || 'YTPTube';
});
const buildTooltip = computed(
@ -735,81 +617,30 @@ const connectionBannerIcon = computed(() =>
socket.connectionStatus === 'connecting' ? 'i-lucide-loader-circle' : 'i-lucide-info',
);
const sidebarSections = computed<Array<SidebarSection>>(() => {
const topLevelItems = (ids: string[]) =>
ids
.map((id) => allNavItems.value.find((item) => item.id === id))
.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 sidebarSections = computed<Array<SidebarSection>>(() =>
sidebarItems.value.map((section) => ({
...section,
items: section.items.map((group) => group.map((entry) => makeNavigationItem(entry))),
})),
);
const routeSearchGroups = computed(() => [
{
id: 'routes',
label: 'Routes',
items: allNavItems.value.flatMap((item) => {
const self = item.to
? [
{
label: item.label,
description: item.description,
icon: item.icon,
suffix: item.to,
onSelect: () => handleRouteSelect(item),
},
]
: [];
const children = (item.children || []).map((child) => ({
label: child.label,
description: child.description,
icon: child.icon,
suffix: child.to || '',
onSelect: () => handleRouteSelect(child),
}));
return [...self, ...children];
}),
},
...sidebarItems.value
.map((section) => ({
id: section.id,
label: section.label,
items: section.items
.flat()
.filter((entry) => entry.searchable !== false)
.map((entry) => ({
label: entry.label,
description: entry.description,
icon: entry.icon,
suffix: entry.to,
onSelect: () => handleRouteSelect(entry),
})),
}))
.filter((section) => section.items.length > 0),
{
id: 'downloads',
label: 'Downloads',
@ -872,7 +703,7 @@ const syncShellModeClass = () => {
html.classList.toggle('simple-mode', simpleMode.value);
};
const handleRouteSelect = async (item: NavEntry) => {
const handleRouteSelect = async (item: NavItem) => {
await closeRouteSearch();
if (item.to) {

View file

@ -1,43 +1,109 @@
<template>
<div class="w-full min-w-0 max-w-full space-y-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2 text-sm text-toned">
<nav aria-label="Breadcrumb" class="min-w-0">
<ol class="flex flex-wrap items-center gap-1.5">
<template v-for="(item, index) in breadcrumbItems" :key="item.path">
<li v-if="index > 0" aria-hidden="true" class="text-muted">/</li>
<li>
<a
v-if="index !== breadcrumbItems.length - 1"
:href="buildStateUrl(item.path)"
class="rounded px-1 py-0.5 text-left text-default hover:text-highlighted"
@click="handleBreadcrumbClick($event, item.path)"
>
{{ item.name }}
</a>
<span v-else class="rounded px-1 py-0.5 font-medium text-highlighted">
{{ item.name }}
</span>
</li>
</template>
</ol>
<div class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="flex min-w-0 items-start gap-3">
<span
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
>
<UIcon :name="pageShell.icon" class="size-5" />
</span>
<div class="min-w-0 flex-1 space-y-3">
<nav
aria-label="Breadcrumb"
class="flex min-w-0 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>
<template v-for="item in breadcrumbTrailItems" :key="item.path">
<span>/</span>
<button
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>
<UIcon
v-if="isLoading"
name="i-lucide-loader-circle"
class="size-4 animate-spin text-info"
/>
<div>
<h1 class="truncate text-2xl font-semibold text-highlighted">
{{ currentDirectoryName }}
</h1>
</div>
</div>
<p class="text-sm text-toned">
{{ browserPath }}
</p>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<div v-if="show_filter" class="relative w-full sm:w-72">
<div class="flex flex-col gap-3 xl:items-end">
<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
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"
/>
</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>
@ -532,6 +542,7 @@ import moment from 'moment';
import { useStorage } from '@vueuse/core';
import type { DropdownMenuItem } from '@nuxt/ui';
import type { FileItem } from '~/types/filebrowser';
import { requirePageShell } from '~/utils/topLevelNavigation';
const route = useRoute();
const toast = useNotification();
@ -559,9 +570,14 @@ const controlEnabled = computed(() => Boolean(config.app.browser_control_enabled
const contentStyle = computed<'list' | 'grid'>(() =>
isMobile.value ? 'grid' : 'list' === display_style.value ? 'list' : 'grid',
);
const pageShell = requirePageShell('files');
const hasItems = computed(() => filteredItems.value.length > 0);
const hasSelected = computed(() => selectedElms.value.length > 0);
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(() =>
sort_order.value === 'asc' ? 'i-lucide-arrow-down' : 'i-lucide-arrow-up',
);
@ -735,19 +751,6 @@ const clearFilter = (): void => {
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 => {
return item.content_type === 'dir' ? uri(`/browser/${item.path}`) : downloadHref(item);
};

View file

@ -1,20 +1,49 @@
<template>
<main class="w-full min-w-0 max-w-full space-y-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
<UIcon name="i-lucide-git-commit-horizontal" class="size-5 text-toned" />
<span>CHANGELOG</span>
</div>
<main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
>
<UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned">
Latest project changes, loaded remotely when available and falling back to the bundled
changelog file.
</p>
<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>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<div v-if="toggleFilter && logs.length > 0" class="relative w-full sm:w-80">
<div class="flex flex-col gap-3 xl:items-end">
<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
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"
/>
</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>
@ -188,9 +198,11 @@ import moment from 'moment';
import { useStorage } from '@vueuse/core';
import type { changelogs, changeset } from '~/types/changelogs';
import { request, ucFirst, uri } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
const toast = useNotification();
const config = useConfigStore();
const pageShell = requirePageShell('changelog');
useHead({ title: 'CHANGELOG' });

View file

@ -1,19 +1,76 @@
<template>
<main class="w-full min-w-0 max-w-full space-y-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
<UIcon name="i-lucide-filter" class="size-5 text-toned" />
<span>Conditions</span>
</div>
<main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
>
<UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned">
Run yt-dlp custom match filter on returned info. and apply options.
</p>
<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>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<div v-if="showFilter && items.length > 0" class="relative w-full sm:w-80">
<div class="flex flex-col gap-3 xl:items-end">
<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
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"
/>
</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>
@ -499,10 +510,12 @@ import { useConditions } from '~/composables/useConditions';
import type { Condition } from '~/types/conditions';
import type { APIResponse } from '~/types/responses';
import { cleanObject, copyText, encode } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
type ConditionItemWithUI = Condition & { raw?: boolean };
const box = useConfirm();
const pageShell = requirePageShell('conditions');
const displayStyle = useStorage<'list' | 'grid'>('conditions_display_style', 'grid');
const isMobile = useMediaQuery({ maxWidth: 639 });
const { toggleExpand, expandClass } = useExpandableMeta();

View file

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

View file

@ -1,19 +1,76 @@
<template>
<main class="w-full min-w-0 max-w-full space-y-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
<UIcon name="i-lucide-braces" class="size-5 text-toned" />
<span>Custom Fields</span>
</div>
<main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
>
<UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned">
Custom fields allow you to add new fields to the download form.
</p>
<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>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<div v-if="showFilter && items.length > 0" class="relative w-full sm:w-80">
<div class="flex flex-col gap-3 xl:items-end">
<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
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"
/>
</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>
@ -422,9 +433,11 @@ import { useDlFields } from '~/composables/useDlFields';
import type { DLField } from '~/types/dl_fields';
import type { APIResponse } from '~/types/responses';
import { copyText, encode } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
const box = useConfirm();
const { toggleExpand, expandClass } = useExpandableMeta();
const pageShell = requirePageShell('custom-fields');
const displayStyle = useStorage<'list' | 'grid'>('dl_fields_display_style', 'grid');
const isMobile = useMediaQuery({ maxWidth: 639 });
const dlFields = useDlFields();

View file

@ -1,22 +1,40 @@
<template>
<main class="space-y-6">
<UPageHeader :title="docEntry.title" :description="docEntry.description" :ui="pageHeaderUi">
<template #links>
<div class="flex flex-wrap items-center gap-2">
<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"
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
>
<UIcon :name="pageShell.icon" class="size-5" />
</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"
>
{{ entry.navLabel }}
</UButton>
<span>{{ pageShell.sectionLabel }}</span>
<span>/</span>
<span>{{ pageShell.pageLabel }}</span>
</div>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</template>
</UPageHeader>
</div>
<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">
<template #body>
@ -31,6 +49,7 @@
<script setup lang="ts">
import Markdown from '~/components/Markdown.vue';
import { DOCS_ENTRIES, getDocsEntryBySlug } from '~/composables/useDocs';
import { requirePageShell } from '~/utils/topLevelNavigation';
const route = useRoute();
@ -49,19 +68,12 @@ const docEntry = computed(() => {
return entry;
});
const pageShell = computed(() => requirePageShell(docEntry.value.id));
useHead(() => ({
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 = {
root: 'w-full bg-default',
container: 'w-full p-0',

View file

@ -1,95 +1,94 @@
<template>
<div class="space-y-6">
<UPageHeader
title="Downloads"
description="Manage queued, active, and completed downloads in one workspace."
:ui="{
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',
}"
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
>
<template #left>
<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 sm:w-72"
/>
<UIcon :name="pageShell.icon" class="size-5" />
</span>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-filter"
@click="toggleFilter = !toggleFilter"
>
<span>Filter</span>
</UButton>
<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>
<UButton
v-if="false === config.paused"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-pause"
@click="() => void pauseDownload()"
>
<span>Pause</span>
</UButton>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div>
<UButton
v-else
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-play"
@click="() => void resumeDownload()"
>
<span>Resume</span>
</UButton>
<div class="flex flex-col gap-3 xl:items-end">
<div class="flex flex-wrap gap-2 xl:justify-end">
<UButton
color="neutral"
:variant="toggleFilter ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-filter"
@click="toggleFilter = !toggleFilter"
>
<span>Filter</span>
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-plus"
@click="config.showForm = !config.showForm"
>
<span>Add</span>
</UButton>
</template>
<UButton
v-if="false === config.paused"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-pause"
@click="() => void pauseDownload()"
>
<span>Pause</span>
</UButton>
<template #right>
<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>
</template>
</UDashboardToolbar>
</template>
</UPageHeader>
<UButton
v-else
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-play"
@click="() => void resumeDownload()"
>
<span>Resume</span>
</UButton>
<UButton
color="neutral"
variant="outline"
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">
<NewDownload
@ -111,19 +110,6 @@
<div v-else class="space-y-8">
<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
:thumbnails="show_thumbnail"
:query="query"
@ -137,14 +123,14 @@
</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 class="flex flex-wrap items-start justify-between gap-3 border-b border-default pb-3">
<div class="space-y-1.5">
<div class="flex items-center gap-2 text-base font-semibold text-highlighted">
<UIcon :name="historyShell.icon" class="size-4 text-toned" />
<h2>{{ historyShell.pageLabel }}</h2>
</div>
<p class="text-sm text-toned">Completed, skipped, and failed downloads.</p>
<p class="text-sm text-toned">{{ historyShell.description }}</p>
</div>
<UBadge color="neutral" variant="soft" size="sm">{{ historyCount }}</UBadge>
@ -180,6 +166,7 @@ import { useStorage } from '@vueuse/core';
import { useDialog } from '~/composables/useDialog';
import type { item_request } from '~/types/item';
import type { StoreItem } from '~/types/store';
import { requirePageShell } from '~/utils/topLevelNavigation';
const config = useConfigStore();
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 display_style = useStorage<string>('display_style', 'grid');
const show_thumbnail = useStorage<boolean>('show_thumbnail', true);
const pageShell = requirePageShell('downloads');
const historyShell = requirePageShell('history');
const formSection = ref<HTMLElement | null>(null);
const info_view = ref({

View file

@ -1,8 +1,10 @@
<template>
<main class="w-full min-w-0 max-w-full space-y-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1">
<div class="flex flex-wrap items-center gap-2 text-lg font-semibold text-highlighted">
<main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="flex min-w-0 items-start gap-3">
<span
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
>
<UIcon
name="i-lucide-file-text"
:class="[
@ -12,7 +14,16 @@
:title="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>
@ -36,24 +47,52 @@
<UBadge v-if="reachedEnd && !hasActiveFilter" color="neutral" variant="soft" size="sm">
Start of file loaded
</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 class="flex flex-wrap items-center justify-end gap-2">
<UButton
v-if="!autoScroll"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-arrow-down"
@click="scrollToBottom(false)"
>
Jump to Live Tail
</UButton>
<div class="flex flex-col gap-3 xl:items-end">
<div class="flex flex-wrap gap-2 xl:justify-end">
<UButton
v-if="!autoScroll"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-arrow-down"
@click="scrollToBottom(false)"
>
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
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"
/>
</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>
@ -185,6 +201,7 @@ import moment from 'moment';
import { useStorage } from '@vueuse/core';
import type { log_line } from '~/types/logs';
import { disableOpacity, enableOpacity, parse_api_error, request, uri } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
type FilteredLogEntry = {
log: log_line;
@ -201,6 +218,7 @@ let scrollTimeout: NodeJS.Timeout | null = null;
const toast = useNotification();
const config = useConfigStore();
const route = useRoute();
const pageShell = requirePageShell('logs');
const logContainer = useTemplateRef<HTMLDivElement>('logContainer');
const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker');

View file

@ -1,19 +1,89 @@
<template>
<main class="w-full min-w-0 max-w-full space-y-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
<UIcon name="i-lucide-bell" class="size-5 text-toned" />
<span>Notifications</span>
</div>
<main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
>
<UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned">
Send notifications to your webhooks based on specified events or presets.
</p>
<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>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<div v-if="showFilter && notifications.length > 0" class="relative w-full sm:w-80">
<div class="flex flex-col gap-3 xl:items-end">
<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
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"
/>
</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>
@ -553,11 +564,13 @@ import { useNotifications } from '~/composables/useNotifications';
import { copyText, encode, parse_api_error, request, ucFirst } from '~/utils';
import type { ImportedItem } from '~/types';
import type { notification } from '~/types/notification';
import { requirePageShell } from '~/utils/topLevelNavigation';
const toast = useNotification();
const box = useConfirm();
const { confirmDialog } = useDialog();
const { toggleExpand, expandClass } = useExpandableMeta();
const pageShell = requirePageShell('notifications');
const displayStyleState = useStorage<'list' | 'grid' | 'cards'>(
'notification_display_style',
'cards',

View file

@ -1,20 +1,75 @@
<template>
<main class="w-full min-w-0 max-w-full space-y-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
<UIcon name="i-lucide-sliders-horizontal" class="size-5 text-toned" />
<span>Presets</span>
</div>
<main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
>
<UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned">
Presets are pre-defined command options for yt-dlp that you want to apply to given
download.
</p>
<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>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<div v-if="showFilter && presetsNoDefault.length > 0" class="relative w-full sm:w-80">
<div class="flex flex-col gap-3 xl:items-end">
<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
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"
/>
</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>
@ -463,6 +473,7 @@ import { useDialog } from '~/composables/useDialog';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import { useConfirm } from '~/composables/useConfirm';
import { prettyName } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
type PresetWithUI = Preset & { raw?: boolean; toggle_description?: boolean };
@ -470,6 +481,7 @@ const presetsStore = usePresets();
const config = useConfigStore();
const box = useConfirm();
const editor = usePresetEditor();
const pageShell = requirePageShell('presets');
const { confirmDialog } = useDialog();
const { toggleExpand, expandClass } = useExpandableMeta();

View file

@ -1,19 +1,84 @@
<template>
<main class="w-full min-w-0 max-w-full space-y-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
<UIcon name="i-lucide-workflow" class="size-5 text-toned" />
<span>Task Definitions</span>
</div>
<main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
>
<UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned">
Create definitions to turn any website into a downloadable feed of links.
</p>
<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>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<div v-if="showFilter && definitions.length > 0" class="relative w-full sm:w-80">
<div class="flex flex-col gap-3 xl:items-end">
<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
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"
/>
</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>
@ -458,6 +469,7 @@ import useTaskDefinitionsComposable from '~/composables/useTaskDefinitions';
import { useDialog } from '~/composables/useDialog';
import { useMediaQuery } from '~/composables/useMediaQuery';
import { copyText, encode } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
import type {
TaskDefinitionDetailed,
@ -484,6 +496,8 @@ const DEFAULT_DEFINITION: TaskDefinitionDocument = {
},
};
const pageShell = requirePageShell('task-definitions');
const { toggleExpand, expandClass } = useExpandableMeta();
const taskDefs = useTaskDefinitionsComposable();

View file

@ -1,20 +1,75 @@
<template>
<main class="w-full min-w-0 max-w-full space-y-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
<UIcon name="i-lucide-list-todo" class="size-5 text-toned" />
<span>Tasks</span>
</div>
<main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
>
<UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned">
The task runner is simple queue system that allows you to poll channels or playlists for
new content at specified intervals.
</p>
<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>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<div v-if="showFilter && tasks.length > 0" class="relative w-full sm:w-80">
<div class="flex flex-col gap-3 xl:items-end">
<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
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"
/>
</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>
@ -710,12 +720,14 @@ import type { WSEP } from '~/types/sockets';
import { sleep } from '~/utils';
import { useSessionCache } from '~/utils/cache';
import type { item_request } from '~/types/item';
import { requirePageShell } from '~/utils/topLevelNavigation';
const box = useConfirm();
const toast = useNotification();
const config = useConfigStore();
const socket = useSocketStore();
const stateStore = useStateStore();
const pageShell = requirePageShell('tasks');
const { confirmDialog } = useDialog();
const sessionCache = useSessionCache();
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;
};