1150 lines
37 KiB
Vue
1150 lines
37 KiB
Vue
<template>
|
|
<UApp :toaster="toasterConfig">
|
|
<Transition name="shell-mode" mode="out-in">
|
|
<div v-if="simpleMode" key="simple" class="shell-stage flex flex-col">
|
|
<UAlert
|
|
v-if="showConnectionBanner"
|
|
color="warning"
|
|
variant="soft"
|
|
orientation="horizontal"
|
|
:title="connectionBannerTitle"
|
|
>
|
|
<template #leading>
|
|
<UIcon
|
|
:name="connectionBannerIcon"
|
|
:class="[
|
|
'size-4 shrink-0 text-warning',
|
|
socket.connectionStatus === 'connecting' ? 'animate-spin' : '',
|
|
]"
|
|
/>
|
|
</template>
|
|
|
|
<template v-if="socket.connectionStatus === 'disconnected'" #actions>
|
|
<UButton
|
|
color="neutral"
|
|
variant="link"
|
|
size="sm"
|
|
class="px-0"
|
|
@click="() => socket.reconnect()"
|
|
>
|
|
Reconnect
|
|
</UButton>
|
|
</template>
|
|
</UAlert>
|
|
|
|
<Simple @show_settings="() => (show_settings = true)" />
|
|
</div>
|
|
|
|
<div v-else key="regular" class="shell-stage flex flex-col">
|
|
<Shutdown v-if="app_shutdown" />
|
|
|
|
<div id="main_container" class="shell-root flex flex-col" v-else>
|
|
<UAlert
|
|
v-if="newVersionIsAvailable"
|
|
color="success"
|
|
variant="soft"
|
|
orientation="horizontal"
|
|
title="A new WebUI version is installed."
|
|
>
|
|
<template #leading>
|
|
<UIcon name="i-lucide-info" class="size-4 shrink-0 text-success" />
|
|
</template>
|
|
|
|
<template #actions>
|
|
<UButton color="neutral" variant="link" size="sm" class="px-0" @click="reloadPage">
|
|
Reload app
|
|
</UButton>
|
|
</template>
|
|
</UAlert>
|
|
|
|
<UDashboardGroup
|
|
storage="local"
|
|
storage-key="ytptube-shell"
|
|
class="shell-dashboard flex-1"
|
|
:ui="{ base: 'relative flex min-h-full overflow-visible' }"
|
|
>
|
|
<UDashboardSidebar
|
|
side="left"
|
|
collapsible
|
|
resizable
|
|
:default-size="15"
|
|
:min-size="10"
|
|
:max-size="20"
|
|
:collapsed-size="4"
|
|
:ui="dashboardSidebarUi"
|
|
>
|
|
<template #header="{ collapsed }">
|
|
<UTooltip :text="connectionStatusLabel">
|
|
<NuxtLink
|
|
to="/"
|
|
class="flex w-full min-w-0 items-center gap-2 rounded-xl transition-colors hover:bg-elevated/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
|
:class="collapsed ? 'justify-center p-1' : 'px-1.5 py-1'"
|
|
aria-label="Go to home"
|
|
>
|
|
<span
|
|
class="relative inline-flex shrink-0 items-center justify-center transition-all duration-200"
|
|
:class="
|
|
collapsed
|
|
? 'size-10 rounded-xl bg-elevated/80 ring ring-default shadow-xs'
|
|
: 'size-9 rounded-lg'
|
|
"
|
|
>
|
|
<img
|
|
:src="uri('/images/favicon.png')"
|
|
alt="YTPTube"
|
|
class="rounded-lg object-contain"
|
|
:class="collapsed ? 'size-6' : 'size-5'"
|
|
/>
|
|
<span
|
|
aria-hidden="true"
|
|
class="absolute right-0 bottom-0 size-2.5 rounded-full ring-2 ring-default"
|
|
:class="connectionStatusDotClass"
|
|
/>
|
|
</span>
|
|
|
|
<div v-if="false === collapsed" class="min-w-0">
|
|
<p class="truncate text-sm font-semibold" :class="connectionStatusColor">
|
|
YTPTube
|
|
</p>
|
|
<p v-if="config?.app?.instance_title" class="truncate text-xs text-toned">
|
|
{{ config.app.instance_title }}
|
|
</p>
|
|
</div>
|
|
</NuxtLink>
|
|
</UTooltip>
|
|
</template>
|
|
|
|
<template #default="{ collapsed }">
|
|
<div class="flex h-full flex-col gap-6 px-1 py-1">
|
|
<div v-for="section in sidebarSections" :key="section.id" class="space-y-2">
|
|
<p
|
|
v-if="false === collapsed && section.label"
|
|
class="px-2 text-[11px] font-semibold tracking-[0.22em] text-toned uppercase"
|
|
>
|
|
{{ section.label }}
|
|
</p>
|
|
|
|
<UNavigationMenu
|
|
orientation="vertical"
|
|
:collapsed="collapsed"
|
|
:items="section.items"
|
|
:tooltip="true"
|
|
:ui="navigationUi(collapsed)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #footer="{ collapsed }">
|
|
<div v-if="false === collapsed" class="w-full"></div>
|
|
</template>
|
|
</UDashboardSidebar>
|
|
|
|
<UDashboardPanel class="min-w-0 bg-transparent" :ui="dashboardPanelUi">
|
|
<template #header>
|
|
<UDashboardNavbar :toggle="false" :title="pageTitle" :ui="dashboardNavbarUi">
|
|
<template #left>
|
|
<div class="flex items-center gap-2">
|
|
<UDashboardSidebarToggle class="lg:hidden" />
|
|
<UButton
|
|
to="/"
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="sm"
|
|
icon="i-lucide-house"
|
|
class="lg:hidden"
|
|
>
|
|
Home
|
|
</UButton>
|
|
<UDashboardSidebarCollapse class="hidden lg:inline-flex" />
|
|
</div>
|
|
</template>
|
|
|
|
<template #right>
|
|
<div class="flex items-center gap-1 sm:gap-2">
|
|
<NotifyDropdown />
|
|
|
|
<UButton
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="sm"
|
|
icon="i-lucide-refresh-cw"
|
|
@click="reloadPage"
|
|
>
|
|
<span class="hidden xl:inline">Reload</span>
|
|
</UButton>
|
|
|
|
<UDashboardSearchButton class="shrink-0" />
|
|
|
|
<UColorModeButton
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="sm"
|
|
aria-label="Toggle color mode"
|
|
/>
|
|
|
|
<UButton
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="sm"
|
|
icon="i-lucide-settings-2"
|
|
@click="show_settings = !show_settings"
|
|
>
|
|
<span class="hidden xl:inline">WebUI Settings</span>
|
|
</UButton>
|
|
|
|
<UButton
|
|
v-if="true === config.app.is_native"
|
|
color="error"
|
|
variant="ghost"
|
|
size="sm"
|
|
icon="i-lucide-power"
|
|
@click="shutdownApp"
|
|
>
|
|
<span class="hidden xl:inline">Shutdown</span>
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
</UDashboardNavbar>
|
|
</template>
|
|
|
|
<template #body>
|
|
<div class="relative flex min-h-full min-w-0 max-w-full flex-1 flex-col">
|
|
<NuxtLoadingIndicator />
|
|
|
|
<div
|
|
class="flex min-h-0 min-w-0 max-w-full flex-1 flex-col px-4 py-4 sm:px-5 sm:py-5 lg:px-6"
|
|
>
|
|
<div class="flex min-h-0 min-w-0 max-w-full flex-1 flex-col gap-4">
|
|
<UAlert
|
|
v-if="!config.is_loaded"
|
|
:color="config.is_loading ? 'info' : 'error'"
|
|
variant="soft"
|
|
:title="
|
|
config.is_loading
|
|
? 'Loading configuration...'
|
|
: 'Failed to load configuration'
|
|
"
|
|
>
|
|
<template #leading>
|
|
<UIcon
|
|
:name="
|
|
config.is_loading
|
|
? 'i-lucide-loader-circle'
|
|
: 'i-lucide-triangle-alert'
|
|
"
|
|
:class="[
|
|
'size-4 shrink-0',
|
|
config.is_loading ? 'animate-spin text-info' : 'text-error',
|
|
]"
|
|
/>
|
|
</template>
|
|
|
|
<template #description>
|
|
<div class="space-y-3">
|
|
<p v-if="config.is_loading" class="text-sm text-default">
|
|
This usually takes less than a few seconds. If this is taking too
|
|
long,
|
|
<button
|
|
type="button"
|
|
class="font-semibold text-highlighted underline-offset-2 hover:underline"
|
|
@click="reloadPage"
|
|
>
|
|
click here
|
|
</button>
|
|
to reload the page.
|
|
</p>
|
|
|
|
<p v-else class="text-sm text-default">
|
|
Failed to load the application configuration. This likely indicates a
|
|
problem with the backend. Try to
|
|
<button
|
|
type="button"
|
|
class="font-semibold text-highlighted underline-offset-2 hover:underline"
|
|
@click="() => config.loadConfig(true)"
|
|
>
|
|
reload configuration
|
|
</button>
|
|
or
|
|
<button
|
|
type="button"
|
|
class="font-semibold text-highlighted underline-offset-2 hover:underline"
|
|
@click="reloadPage"
|
|
>
|
|
reload the page
|
|
</button>
|
|
.
|
|
</p>
|
|
|
|
<div
|
|
v-if="socket.error"
|
|
class="flex flex-wrap items-center gap-2 border-t border-default pt-3 text-error"
|
|
>
|
|
<UIcon name="i-lucide-triangle-alert" class="size-4" />
|
|
<span
|
|
class="inline-flex min-w-6 items-center justify-center rounded-full bg-error px-2 py-0.5 text-xs font-semibold text-white"
|
|
>
|
|
{{ socket.error_count }}
|
|
</span>
|
|
<span>
|
|
{{ socket.error }}. Check the developer console for more
|
|
information.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</UAlert>
|
|
|
|
<UAlert
|
|
v-if="showConnectionBanner"
|
|
color="warning"
|
|
variant="soft"
|
|
orientation="horizontal"
|
|
:title="connectionBannerTitle"
|
|
>
|
|
<template #leading>
|
|
<UIcon
|
|
:name="connectionBannerIcon"
|
|
:class="[
|
|
'size-4 shrink-0 text-warning',
|
|
socket.connectionStatus === 'connecting' ? 'animate-spin' : '',
|
|
]"
|
|
/>
|
|
</template>
|
|
|
|
<template v-if="socket.connectionStatus === 'disconnected'" #actions>
|
|
<UButton
|
|
color="neutral"
|
|
variant="link"
|
|
size="sm"
|
|
class="px-0"
|
|
@click="() => socket.reconnect()"
|
|
>
|
|
Reconnect
|
|
</UButton>
|
|
</template>
|
|
</UAlert>
|
|
|
|
<div
|
|
v-if="config.is_loaded"
|
|
class="flex min-h-0 min-w-0 max-w-full flex-1 flex-col"
|
|
>
|
|
<NuxtPage :isLoading="loadingImage" @reload_bg="() => loadImage(true)" />
|
|
</div>
|
|
</div>
|
|
|
|
<footer
|
|
v-if="config.is_loaded"
|
|
class="shell-footer mt-auto border-t border-default pt-6 text-sm text-toned"
|
|
>
|
|
<div class="grid gap-6 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
|
|
<div class="space-y-3">
|
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
|
|
<NuxtLink
|
|
href="https://github.com/ArabCoders/ytptube"
|
|
target="_blank"
|
|
class="inline-flex items-center gap-2 font-semibold text-highlighted"
|
|
>
|
|
<UIcon name="i-lucide-github" class="size-4" />
|
|
<span>YTPTube</span>
|
|
</NuxtLink>
|
|
|
|
<UTooltip :text="buildTooltip">
|
|
<span class="text-xs has-tooltip">
|
|
{{ config?.app?.app_version || 'unknown' }}
|
|
</span>
|
|
</UTooltip>
|
|
</div>
|
|
|
|
<p
|
|
v-if="config.app?.new_version"
|
|
class="flex flex-wrap items-center gap-2 text-xs"
|
|
>
|
|
<UIcon name="i-lucide-info" class="size-4 text-warning" />
|
|
<span>Update available:</span>
|
|
<NuxtLink
|
|
:href="`https://github.com/ArabCoders/ytptube/releases/tag/${config.app.new_version}`"
|
|
target="_blank"
|
|
class="font-semibold text-highlighted"
|
|
>
|
|
{{ config.app.new_version }}
|
|
</NuxtLink>
|
|
</p>
|
|
|
|
<button
|
|
v-else
|
|
type="button"
|
|
class="inline-flex items-center gap-2 text-xs text-left transition-colors hover:text-highlighted disabled:opacity-60"
|
|
:disabled="checkingUpdates"
|
|
@click="checkForUpdates"
|
|
>
|
|
<UIcon
|
|
:name="
|
|
checkingUpdates
|
|
? 'i-lucide-loader-circle'
|
|
: 'i-lucide-circle-check-big'
|
|
"
|
|
:class="['size-4', checkingUpdates ? 'animate-spin' : '']"
|
|
/>
|
|
<span>{{ updateCheckMessage }}</span>
|
|
</button>
|
|
|
|
<p v-if="config.app?.started" class="flex items-center gap-2 text-xs">
|
|
<UIcon name="i-lucide-clock-3" class="size-4" />
|
|
<UTooltip
|
|
:text="
|
|
'Started: ' +
|
|
moment.unix(config.app?.started).format('YYYY-MM-DD HH:mm Z')
|
|
"
|
|
>
|
|
<span class="has-tooltip">
|
|
{{ moment.unix(config.app?.started).fromNow() }}
|
|
</span>
|
|
</UTooltip>
|
|
</p>
|
|
</div>
|
|
|
|
<div
|
|
class="space-y-3 border-t border-default pt-4 lg:border-t-0 lg:border-l lg:pl-6 lg:pt-0"
|
|
>
|
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-toned">
|
|
Powered by
|
|
</p>
|
|
|
|
<div class="space-y-2">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<NuxtLink
|
|
href="https://github.com/yt-dlp/yt-dlp"
|
|
target="_blank"
|
|
class="inline-flex items-center gap-2 font-semibold text-highlighted"
|
|
>
|
|
<UIcon name="i-lucide-github" class="size-4" />
|
|
<span>yt-dlp</span>
|
|
</NuxtLink>
|
|
|
|
<span class="text-xs">{{
|
|
config?.app?.ytdlp_version || 'unknown'
|
|
}}</span>
|
|
</div>
|
|
|
|
<p
|
|
v-if="config.app?.yt_new_version"
|
|
class="flex flex-wrap items-center gap-2 text-xs"
|
|
>
|
|
<UTooltip text="Restart container to update yt-dlp">
|
|
<span class="has-tooltip">
|
|
<UIcon name="i-lucide-info" class="size-4 text-warning" />
|
|
</span>
|
|
</UTooltip>
|
|
<span>Update available:</span>
|
|
<NuxtLink
|
|
:href="`https://github.com/yt-dlp/yt-dlp/releases/tag/${config.app.yt_new_version}`"
|
|
target="_blank"
|
|
class="font-semibold text-highlighted"
|
|
>
|
|
{{ config.app.yt_new_version }}
|
|
</NuxtLink>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
|
|
<ClientOnly>
|
|
<Dialog />
|
|
</ClientOnly>
|
|
</div>
|
|
</template>
|
|
</UDashboardPanel>
|
|
|
|
<UDashboardSearch
|
|
v-model:open="showRouteSearch"
|
|
:groups="routeSearchGroups"
|
|
shortcut="meta_k"
|
|
placeholder="Search routes and actions"
|
|
:ui="{ modal: 'sm:max-w-3xl h-full sm:h-[28rem]' }"
|
|
/>
|
|
</UDashboardGroup>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<SettingsPanel
|
|
:isOpen="show_settings"
|
|
:isLoading="loadingImage"
|
|
@close="closeSettings()"
|
|
@reload_bg="() => loadImage(true)"
|
|
direction="right"
|
|
/>
|
|
</UApp>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { NavigationMenuItem } from '@nuxt/ui';
|
|
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[];
|
|
};
|
|
|
|
type SidebarSection = {
|
|
id: string;
|
|
label?: string;
|
|
items: Array<Array<NavigationMenuItem>>;
|
|
};
|
|
|
|
const socket = useSocketStore();
|
|
const config = useConfigStore();
|
|
const route = useRoute();
|
|
const loadedImage = ref();
|
|
const loadingImage = ref(false);
|
|
const bg_enable = useStorage('random_bg', true);
|
|
const bg_opacity = useStorage('random_bg_opacity', 0.95);
|
|
const app_shutdown = ref<boolean>(false);
|
|
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false);
|
|
const show_settings = ref(false);
|
|
const checkingUpdates = ref(false);
|
|
const updateCheckMessage = ref('Up to date - Click to check');
|
|
const showRouteSearch = ref(false);
|
|
const { alertDialog, confirmDialog } = useDialog();
|
|
|
|
const dashboardSidebarUi = {
|
|
root: 'border-r border-default bg-default/95 backdrop-blur-sm',
|
|
header: 'border-b border-default px-2.5 py-3',
|
|
body: 'gap-3 px-2.5 py-3',
|
|
footer: 'border-t border-default px-2.5 py-3',
|
|
};
|
|
|
|
const dashboardNavbarUi = {
|
|
root: 'border-b border-default bg-transparent px-4 py-3 sm:px-5 lg:px-6',
|
|
title: 'text-sm font-semibold text-highlighted',
|
|
right: 'flex items-center shrink-0 gap-1.5',
|
|
};
|
|
|
|
const dashboardPanelUi = {
|
|
root: 'min-w-0 min-h-screen max-w-full flex flex-1 flex-col bg-transparent',
|
|
body: 'flex min-h-0 min-w-0 max-w-full flex-1 flex-col overflow-y-visible p-0',
|
|
};
|
|
|
|
const navigationUi = (collapsed: boolean) => ({
|
|
root: 'w-full',
|
|
list: 'gap-1.5',
|
|
link: collapsed
|
|
? 'justify-center rounded-lg px-2 py-2'
|
|
: 'rounded-lg px-2.5 py-2 text-sm font-medium text-default transition-colors',
|
|
linkLeadingIcon: collapsed ? 'size-5' : 'size-4',
|
|
linkLabel: collapsed ? 'hidden' : 'truncate',
|
|
});
|
|
|
|
const makeNavigationItem = (item: NavEntry): NavigationMenuItem => ({
|
|
label: item.label,
|
|
icon: item.icon,
|
|
to: item.to,
|
|
value: item.id,
|
|
});
|
|
|
|
const docsNavigationEntries = getDocsNavigationEntries();
|
|
|
|
const allNavItems = computed<NavEntry[]>(() => [
|
|
{
|
|
id: 'downloads',
|
|
label: 'Downloads',
|
|
description: 'Queued and completed downloads list.',
|
|
icon: 'i-lucide-download',
|
|
to: '/',
|
|
},
|
|
{
|
|
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 pageTitle = computed(() => {
|
|
const path = route.path;
|
|
const flat = allNavItems.value.flatMap((item) => [item, ...(item.children || [])]);
|
|
const match = flat
|
|
.filter((item) => item.to && (item.to === '/' ? path === '/' : path.startsWith(item.to)))
|
|
.sort((left, right) => (right.to?.length || 0) - (left.to?.length || 0))[0];
|
|
return match?.label || 'YTPTube';
|
|
});
|
|
|
|
const buildTooltip = computed(
|
|
() =>
|
|
`Build: ${config.app?.app_build_date}, Branch: ${config.app?.app_branch}, SHA: ${config.app?.app_commit_sha}`,
|
|
);
|
|
|
|
const showConnectionBanner = computed(() => socket.connectionStatus !== 'connected');
|
|
|
|
const connectionBannerTitle = computed(() => {
|
|
switch (socket.connectionStatus) {
|
|
case 'connecting':
|
|
return 'Connecting to websocket server...';
|
|
case 'disconnected':
|
|
default:
|
|
return 'Websocket connection lost.';
|
|
}
|
|
});
|
|
|
|
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', 'files']).length > 0
|
|
? [topLevelItems(['downloads', 'files'])]
|
|
: [],
|
|
},
|
|
{
|
|
id: 'automation',
|
|
label: 'Automation',
|
|
items: childItems('tasks').length > 0 ? [childItems('tasks')] : [],
|
|
},
|
|
{
|
|
id: 'configuration',
|
|
label: 'Configuration',
|
|
items: [
|
|
topLevelItems(['presets', 'custom-fields']),
|
|
topLevelItems(['conditions', 'notifications']),
|
|
].filter((group) => group.length > 0),
|
|
},
|
|
{
|
|
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(() => [
|
|
{
|
|
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];
|
|
}),
|
|
},
|
|
{
|
|
id: 'downloads',
|
|
label: 'Downloads',
|
|
items: [
|
|
config.paused
|
|
? {
|
|
label: 'Resume Downloads',
|
|
description: 'Resume globally paused downloads.',
|
|
icon: 'i-lucide-play',
|
|
onSelect: () => void resumeDownloads(),
|
|
}
|
|
: {
|
|
label: 'Pause Downloads',
|
|
description: 'Globally pause all non-active downloads.',
|
|
icon: 'i-lucide-pause',
|
|
onSelect: () => void pauseDownloads(),
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'preferences',
|
|
label: 'Preferences',
|
|
items: [
|
|
{
|
|
label: 'WebUI Settings',
|
|
description: 'Adjust interface behavior and download defaults.',
|
|
icon: 'i-lucide-settings-2',
|
|
onSelect: () => void openSettings(),
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const closeRouteSearch = async (): Promise<void> => {
|
|
if (!showRouteSearch.value) {
|
|
return;
|
|
}
|
|
|
|
showRouteSearch.value = false;
|
|
await nextTick();
|
|
};
|
|
|
|
const pauseDownloads = async (): Promise<void> => {
|
|
await closeRouteSearch();
|
|
await request('/api/system/pause', { method: 'POST' });
|
|
};
|
|
|
|
const resumeDownloads = async (): Promise<void> => {
|
|
await closeRouteSearch();
|
|
await request('/api/system/resume', { method: 'POST' });
|
|
};
|
|
|
|
const openSettings = async (): Promise<void> => {
|
|
await closeRouteSearch();
|
|
show_settings.value = true;
|
|
};
|
|
|
|
const syncShellModeClass = () => {
|
|
const html = document.documentElement;
|
|
html.classList.toggle('simple-mode', simpleMode.value);
|
|
};
|
|
|
|
const handleRouteSelect = async (item: NavEntry) => {
|
|
await closeRouteSearch();
|
|
|
|
if (item.to) {
|
|
await navigateTo(item.to);
|
|
}
|
|
};
|
|
|
|
const checkForUpdates = async () => {
|
|
if (checkingUpdates.value) {
|
|
return;
|
|
}
|
|
|
|
const msg = 'Up to date - Click to check';
|
|
|
|
try {
|
|
checkingUpdates.value = true;
|
|
updateCheckMessage.value = 'Checking...';
|
|
|
|
const response = await fetch('/api/system/check-updates', { method: 'POST' });
|
|
|
|
if (!response.ok) {
|
|
await response.json();
|
|
updateCheckMessage.value = 'Check failed';
|
|
setTimeout(() => (updateCheckMessage.value = msg), 3000);
|
|
return;
|
|
}
|
|
|
|
const data = await parse_api_response<version_check>(await response.json());
|
|
|
|
if ('update_available' === data.app.status) {
|
|
config.app.new_version = data.app.new_version;
|
|
}
|
|
if (data.ytdlp && 'update_available' === data.ytdlp.status) {
|
|
config.app.yt_new_version = data.ytdlp.new_version;
|
|
}
|
|
|
|
if ('update_available' === data.app.status) {
|
|
updateCheckMessage.value = 'Update found!';
|
|
} else if ('up_to_date' === data.app.status && 'up_to_date' === data.ytdlp?.status) {
|
|
updateCheckMessage.value = 'Up to date ✓';
|
|
setTimeout(() => (updateCheckMessage.value = msg), 3000);
|
|
} else if ('up_to_date' === data.app.status && 'update_available' === data.ytdlp?.status) {
|
|
updateCheckMessage.value = 'Up to date ✓';
|
|
setTimeout(() => (updateCheckMessage.value = msg), 3000);
|
|
} else {
|
|
updateCheckMessage.value = 'Check failed';
|
|
setTimeout(() => (updateCheckMessage.value = msg), 3000);
|
|
}
|
|
} catch (e) {
|
|
console.error('Update check failed:', e);
|
|
updateCheckMessage.value = 'Check failed';
|
|
setTimeout(() => (updateCheckMessage.value = msg), 3000);
|
|
} finally {
|
|
checkingUpdates.value = false;
|
|
}
|
|
};
|
|
|
|
onMounted(async () => {
|
|
syncShellModeClass();
|
|
|
|
try {
|
|
await config.loadConfig();
|
|
} catch {}
|
|
|
|
try {
|
|
const opts = await request('/api/yt-dlp/options');
|
|
if (opts.ok) {
|
|
config.ytdlp_options = (await opts.json()) as Array<YTDLPOption>;
|
|
}
|
|
} catch {}
|
|
|
|
try {
|
|
socket.connect();
|
|
} catch {}
|
|
|
|
try {
|
|
await handleImage(bg_enable.value);
|
|
} catch {}
|
|
});
|
|
|
|
watch(bg_enable, async (v) => await handleImage(v));
|
|
watch(simpleMode, () => syncShellModeClass());
|
|
watch(bg_opacity, (v) => {
|
|
if (false === bg_enable.value) {
|
|
return;
|
|
}
|
|
document.querySelector('body')?.setAttribute('style', `opacity: ${v}`);
|
|
});
|
|
|
|
watch(loadedImage, () => {
|
|
if (false === bg_enable.value) {
|
|
return;
|
|
}
|
|
|
|
const html = document.documentElement;
|
|
const body = document.querySelector('body');
|
|
|
|
const style = {
|
|
'background-color': 'unset',
|
|
display: 'block',
|
|
'min-height': '100%',
|
|
'min-width': '100%',
|
|
'background-image': `url(${loadedImage.value})`,
|
|
} as any;
|
|
|
|
html.setAttribute(
|
|
'style',
|
|
Object.keys(style)
|
|
.map((k) => `${k}: ${style[k]}`)
|
|
.join('; ')
|
|
.trim(),
|
|
);
|
|
html.classList.add('bg-fanart');
|
|
body?.setAttribute('style', `opacity: ${bg_opacity.value}`);
|
|
});
|
|
|
|
const handleImage = async (enabled: boolean) => {
|
|
if (false === enabled) {
|
|
const html = document.documentElement;
|
|
const body = document.querySelector('body');
|
|
|
|
html.classList.remove('bg-fanart');
|
|
|
|
if (html.getAttribute('style')) {
|
|
html.removeAttribute('style');
|
|
}
|
|
|
|
if (body?.getAttribute('style')) {
|
|
body.removeAttribute('style');
|
|
}
|
|
|
|
loadedImage.value = '';
|
|
return;
|
|
}
|
|
|
|
if (loadedImage.value) {
|
|
return;
|
|
}
|
|
|
|
await loadImage();
|
|
};
|
|
|
|
const loadImage = async (force = false) => {
|
|
if (loadingImage.value) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
loadingImage.value = true;
|
|
|
|
let url = '/api/random/background';
|
|
if (force) {
|
|
url += '?force=true';
|
|
}
|
|
|
|
const imgRequest = await request(url);
|
|
if (200 !== imgRequest.status) {
|
|
return;
|
|
}
|
|
|
|
loadedImage.value = URL.createObjectURL(await imgRequest.blob());
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
loadingImage.value = false;
|
|
}
|
|
};
|
|
|
|
const useVersionUpdate = () => {
|
|
const newVersionIsAvailable = ref(false);
|
|
const nuxtApp = useNuxtApp();
|
|
nuxtApp.hooks.addHooks({
|
|
'app:manifest:update': () => {
|
|
newVersionIsAvailable.value = true;
|
|
},
|
|
});
|
|
|
|
return {
|
|
newVersionIsAvailable: readonly(newVersionIsAvailable),
|
|
};
|
|
};
|
|
|
|
const { newVersionIsAvailable } = useVersionUpdate();
|
|
|
|
const closeSettings = () => (show_settings.value = false);
|
|
|
|
const shutdownApp = async () => {
|
|
if (false === config.app.is_native) {
|
|
await alertDialog({
|
|
title: 'Shutdown Unavailable',
|
|
message: 'The shutdown feature is only available when running as native application.',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const { status } = await confirmDialog({
|
|
title: 'Shutdown Application',
|
|
message: 'Are you sure you want to shutdown the application?',
|
|
});
|
|
|
|
if (false === status) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const resp = await fetch('/api/system/shutdown', { method: 'POST' });
|
|
if (!resp.ok) {
|
|
const body = await resp.json();
|
|
await alertDialog({
|
|
title: 'Shutdown Failed',
|
|
message: `Failed to shutdown the application: ${body.error || resp.statusText || resp.status}`,
|
|
});
|
|
return;
|
|
}
|
|
app_shutdown.value = true;
|
|
await nextTick();
|
|
} catch (e: any) {
|
|
await alertDialog({
|
|
title: 'Shutdown Failed',
|
|
message: `Failed to shutdown the application: ${e.message || e}`,
|
|
});
|
|
}
|
|
};
|
|
|
|
const reloadPage = () => window.location.reload();
|
|
|
|
const connectionStatusColor = computed(() => {
|
|
switch (socket.connectionStatus) {
|
|
case 'connected':
|
|
return 'text-success';
|
|
case 'connecting':
|
|
return 'text-warning';
|
|
case 'disconnected':
|
|
default:
|
|
return 'text-error';
|
|
}
|
|
});
|
|
|
|
const connectionStatusDotClass = computed(() => {
|
|
switch (socket.connectionStatus) {
|
|
case 'connected':
|
|
return 'bg-success';
|
|
case 'connecting':
|
|
return 'bg-warning';
|
|
case 'disconnected':
|
|
default:
|
|
return 'bg-error';
|
|
}
|
|
});
|
|
|
|
const connectionStatusLabel = computed(() => {
|
|
switch (socket.connectionStatus) {
|
|
case 'connected':
|
|
return 'Connected';
|
|
case 'connecting':
|
|
return 'Connecting...';
|
|
case 'disconnected':
|
|
default:
|
|
return 'Disconnected';
|
|
}
|
|
});
|
|
|
|
const toastPosition = useStorage<toastPosition>('toast_position', 'top-right');
|
|
|
|
const toasterConfig = computed(() => ({
|
|
position: toastPosition.value,
|
|
max: 5,
|
|
expand: true,
|
|
progress: true,
|
|
}));
|
|
</script>
|
|
|
|
<style>
|
|
.shell-stage {
|
|
min-height: 100vh;
|
|
min-height: 100dvh;
|
|
}
|
|
|
|
.shell-mode-enter-active,
|
|
.shell-mode-leave-active {
|
|
transition:
|
|
opacity 0.28s ease,
|
|
transform 0.34s cubic-bezier(0.22, 1, 0.36, 1);
|
|
will-change: opacity, transform;
|
|
}
|
|
|
|
.shell-mode-enter-from,
|
|
.shell-mode-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(18px) scale(0.985);
|
|
}
|
|
|
|
.shell-root {
|
|
min-height: 100vh;
|
|
min-height: 100dvh;
|
|
}
|
|
|
|
.shell-dashboard {
|
|
min-height: 100vh;
|
|
min-height: 100dvh;
|
|
}
|
|
|
|
.shell-footer {
|
|
padding-top: 1.5rem;
|
|
padding-bottom: 1.5rem;
|
|
}
|
|
</style>
|