ytptube/ui/app/layouts/default.vue
2026-06-08 07:53:05 +03:00

1259 lines
40 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 bg-default/95 backdrop-blur-sm"
>
<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 shell-surface 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>
<div class="flex items-center gap-3">
<UButton to="/changelog" color="neutral" variant="link" size="sm" class="px-0">
View changelog
</UButton>
<UButton color="neutral" variant="link" size="sm" class="px-0" @click="reloadPage">
Reload app
</UButton>
</div>
</template>
</UAlert>
<UDashboardGroup
storage="local"
storage-key="ytptube-shell"
class="shell-dashboard flex-1"
:ui="{ base: 'relative flex min-h-full overflow-visible' }"
>
<UDashboardSidebar
v-model:open="showSidebar"
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">
<UButton
color="neutral"
variant="ghost"
size="sm"
icon="i-lucide-gauge"
@click="showLimits = true"
>
<span class="hidden xl:inline">Limits</span>
</UButton>
<NotifyDropdown />
<UButton
color="neutral"
variant="ghost"
size="sm"
icon="i-lucide-search"
aria-label="Search routes and actions"
title="Search routes and actions"
class="shrink-0 lg:hidden"
@click="showRouteSearch = true"
/>
<UDashboardSearchButton class="hidden shrink-0 lg:inline-flex" />
<UButton
color="neutral"
variant="ghost"
size="sm"
:icon="colorModeButtonIcon"
:aria-label="colorModeButtonTitle"
:title="colorModeButtonTitle"
@click="colorMode.preference = nextColorModePreference"
>
<span class="hidden xl:inline">{{ colorModeButtonTitle }}</span>
</UButton>
<UButton
color="neutral"
variant="ghost"
size="sm"
icon="i-lucide-refresh-cw"
@click="$router.go(0)"
>
<span class="hidden xl:inline">Reload</span>
</UButton>
<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="$router.go(0)"
>
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="$router.go(0)"
>
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 to="/changelog" 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]' }"
/>
<UModal
v-if="showLimits"
:open="showLimits"
title="Download Limits"
:ui="{ content: 'sm:max-w-4xl', body: 'p-0' }"
@update:open="(open) => !open && (showLimits = false)"
>
<template #body>
<LimitsPage />
</template>
</UModal>
</UDashboardGroup>
</div>
</div>
</Transition>
<SettingsPanel
:isOpen="show_settings"
:isLoading="loadingImage"
@close="show_settings = false"
@reload_bg="loadImage(true)"
direction="right"
/>
</UApp>
</template>
<script setup lang="ts">
import type { NavigationMenuItem } from '@nuxt/ui';
import { ref, onBeforeUnmount, onMounted, readonly } from 'vue';
import { useStorage } from '@vueuse/core';
import moment from 'moment';
import { useMediaQuery } from '~/composables/useMediaQuery';
import type { toastPosition } from '~/composables/useNotification';
import LimitsPage from '~/components/LimitsPage.vue';
import { formatPageTitle, parse_api_response, request, syncOpacity, uri } from '~/utils';
import { getSidebarSwipeMode } from '~/utils/sidebarSwipe';
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';
import {
getActiveNavItem,
getNavItems,
getNavSections,
isNavItemActive,
type NavItem,
} from '~/utils/topLevelNavigation';
type SidebarSection = {
id: string;
label?: string;
items: Array<Array<NavigationMenuItem>>;
};
type ColorModePreference = 'system' | 'light' | 'dark';
type SwipeMode = 'open' | 'close';
const MOBILE_SIDEBAR_MIN_SWIPE_DISTANCE = 64;
const socket = useAppSocket();
const config = useYtpConfig();
const route = useRoute();
const colorMode = useColorMode();
const loadedImage = ref();
const loadingImage = ref(false);
const bg_enable = useStorage('random_bg', true);
const bg_opacity = useStorage('random_bg_opacity', 0.95);
const page_anims = useStorage<boolean>('page_anims', true);
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 - Check now');
const showRouteSearch = ref(false);
const showSidebar = ref(false);
const showLimits = ref(false);
const { alertDialog, confirmDialog } = useDialog();
const isMobile = useMediaQuery({ query: '(max-width: 1023px)' });
const SwipeState = {
mode: null as SwipeMode | null,
tracking: false,
startX: 0,
startY: 0,
endX: 0,
endY: 0,
};
const colorModePreferences: Array<ColorModePreference> = ['system', 'light', 'dark'];
const colorModePreference = computed<ColorModePreference>(() => {
const preference = colorMode.preference;
return colorModePreferences.includes(preference as ColorModePreference)
? (preference as ColorModePreference)
: 'system';
});
const colorModeButtonIcon = computed(() => {
switch (colorModePreference.value) {
case 'light':
return 'i-lucide-sun';
case 'dark':
return 'i-lucide-moon';
default:
return 'i-lucide-monitor';
}
});
const nextColorModePreference = computed<ColorModePreference>(() => {
const currentIndex = colorModePreferences.indexOf(colorModePreference.value);
return colorModePreferences[(currentIndex + 1) % colorModePreferences.length] ?? 'system';
});
const colorModeButtonTitle = computed(() => {
switch (colorModePreference.value) {
case 'light':
return 'Light';
case 'dark':
return 'Dark';
default:
return 'System';
}
});
const resetSwipe = (): void => {
SwipeState.mode = null;
SwipeState.tracking = false;
SwipeState.startX = 0;
SwipeState.startY = 0;
SwipeState.endX = 0;
SwipeState.endY = 0;
};
const updateSwipePosition = (touch?: Touch): void => {
if (!touch) {
return;
}
SwipeState.endX = touch.clientX;
SwipeState.endY = touch.clientY;
};
const handleSwipeStart = (event: TouchEvent): void => {
if (!isMobile.value || event.touches.length !== 1) {
resetSwipe();
return;
}
const touch = event.touches[0];
if (!touch) {
resetSwipe();
return;
}
const swipeMode: SwipeMode | null = getSidebarSwipeMode(
showSidebar.value,
touch.clientX,
navigator,
);
if (!swipeMode) {
resetSwipe();
return;
}
SwipeState.mode = swipeMode;
SwipeState.tracking = true;
SwipeState.startX = touch.clientX;
SwipeState.startY = touch.clientY;
updateSwipePosition(touch);
};
const handleSwipeMove = (event: TouchEvent): void => {
if (!SwipeState.tracking || event.touches.length !== 1) {
return;
}
updateSwipePosition(event.touches[0]);
};
const completeSwipe = (): void => {
if (!SwipeState.tracking) {
return;
}
const swipeMode = SwipeState.mode;
const deltaX = SwipeState.endX - SwipeState.startX;
const deltaY = SwipeState.endY - SwipeState.startY;
const isHorizontalOpenSwipe =
swipeMode === 'open' &&
deltaX >= MOBILE_SIDEBAR_MIN_SWIPE_DISTANCE &&
deltaX > Math.abs(deltaY);
const isHorizontalCloseSwipe =
swipeMode === 'close' &&
deltaX <= -MOBILE_SIDEBAR_MIN_SWIPE_DISTANCE &&
Math.abs(deltaX) > Math.abs(deltaY);
resetSwipe();
if (isHorizontalOpenSwipe) {
showSidebar.value = true;
}
if (isHorizontalCloseSwipe) {
showSidebar.value = false;
}
};
const handleSwipeEnd = (event: TouchEvent): void => {
updateSwipePosition(event.changedTouches[0]);
completeSwipe();
};
const handleSwipeCancel = (): void => {
resetSwipe();
};
const dashboardSidebarUi = {
root: 'shell-surface 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: NavItem): NavigationMenuItem => ({
label: item.label,
icon: item.icon,
to: item.to,
value: item.id,
active: isNavItemActive(item, route),
});
const navigationAvailability = computed(() => ({
fileLogging: Boolean(config.app?.file_logging),
consoleEnabled: Boolean(config.app?.console_enabled),
}));
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 activeNavItem = computed(() => getActiveNavItem(route, navigationAvailability.value));
const pageTitle = computed(
() => activeNavItem.value?.navbarTitle || activeNavItem.value?.label || 'YTPTube',
);
const documentTitle = computed(() => activeNavItem.value?.pageLabel || pageTitle.value);
useHead(() => ({
title: formatPageTitle(documentTitle.value),
}));
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>>(() =>
sidebarItems.value.map((section) => ({
...section,
items: section.items.map((group) => group.map((entry) => makeNavigationItem(entry))),
})),
);
const routeSearchGroups = computed(() => [
...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',
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: NavItem) => {
await closeRouteSearch();
if (item.to) {
await navigateTo(item.to);
}
};
const checkForUpdates = async () => {
if (checkingUpdates.value) {
return;
}
const msg = 'Up to date - Check now';
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 () => {
document.addEventListener('touchstart', handleSwipeStart, {
passive: true,
capture: true,
});
document.addEventListener('touchmove', handleSwipeMove, {
passive: true,
capture: true,
});
document.addEventListener('touchend', handleSwipeEnd, {
passive: true,
capture: true,
});
document.addEventListener('touchcancel', handleSwipeCancel, {
passive: true,
capture: true,
});
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 {}
});
onBeforeUnmount(() => {
document.removeEventListener('touchstart', handleSwipeStart, true);
document.removeEventListener('touchmove', handleSwipeMove, true);
document.removeEventListener('touchend', handleSwipeEnd, true);
document.removeEventListener('touchcancel', handleSwipeCancel, true);
});
watch(bg_enable, async (v) => await handleImage(v));
watch(simpleMode, () => syncShellModeClass());
watch(isMobile, (v) => {
if (v) {
return;
}
showSidebar.value = false;
resetSwipe();
});
watch(bg_opacity, () => {
if (false === bg_enable.value) {
return;
}
syncOpacity();
});
watch(
page_anims,
(val) => {
if (val) {
document.documentElement.classList.remove('no-page-anim');
} else {
document.documentElement.classList.add('no-page-anim');
}
},
{ immediate: true },
);
watch(loadedImage, () => {
if (false === bg_enable.value) {
return;
}
const html = document.documentElement;
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');
syncOpacity();
});
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 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 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,
}));
const reloadPage = () => window.location.reload();
</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>