refactor: re-design simple mode toggle.
This commit is contained in:
parent
72f2373c42
commit
3381ca3805
15 changed files with 2459 additions and 2344 deletions
25
FAQ.md
25
FAQ.md
|
|
@ -3,6 +3,9 @@
|
||||||
Certain configuration values can be set via environment variables, using the `-e` parameter on the docker command line,
|
Certain configuration values can be set via environment variables, using the `-e` parameter on the docker command line,
|
||||||
or the `environment:` section in `compose.yaml` file.
|
or the `environment:` section in `compose.yaml` file.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Click to expand</summary>
|
||||||
|
|
||||||
| Environment Variable | Description | Default |
|
| Environment Variable | Description | Default |
|
||||||
| ------------------------------- | ------------------------------------------------------------------- | --------------------- |
|
| ------------------------------- | ------------------------------------------------------------------- | --------------------- |
|
||||||
| TZ | The timezone to use for the application | `(not_set)` |
|
| TZ | The timezone to use for the application | `(not_set)` |
|
||||||
|
|
@ -61,19 +64,19 @@ or the `environment:` section in `compose.yaml` file.
|
||||||
| YTP_THUMB_GENERATE | Enable ffmpeg thumbnail generation when no local thumbnail exists. | `true` |
|
| YTP_THUMB_GENERATE | Enable ffmpeg thumbnail generation when no local thumbnail exists. | `true` |
|
||||||
| YTP_THUMB_SIDECAR | Save generated thumbnails next to media instead of temp cache. | `false` |
|
| YTP_THUMB_SIDECAR | Save generated thumbnails next to media instead of temp cache. | `false` |
|
||||||
| YTP_DISABLE_EXEC | Strip some dangerous yt-dlp options. | `false` |
|
| YTP_DISABLE_EXEC | Strip some dangerous yt-dlp options. | `false` |
|
||||||
|
</details>
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> To raise the maximum workers for specific extractor, you need to add a ENV variable that follows the pattern `YTP_MAX_WORKERS_FOR_<EXTRACTOR_NAME>`.
|
> To raise the worker limit for a specific extractor, set an env variable using this format: `YTP_MAX_WORKERS_FOR_<EXTRACTOR_NAME>`
|
||||||
> The extractor name must be in uppercase, to know the extractor name, check the log for the specific extractor used for the download.
|
> The extractor name must be uppercase. You can find the extractor name in the download logs. This value cannot be
|
||||||
> The limit should not exceed the `YTP_MAX_WORKERS` value as it will be ignored.
|
> higher than `YTP_MAX_WORKERS`; higher values are ignored.
|
||||||
|
>
|
||||||
> [!IMPORTANT]
|
> `YTP_SIMPLE_MODE=true` only applies when the browser has no saved layout choice yet. Users can still choose a layout in
|
||||||
> The env variable `YTP_SIMPLE_MODE` only control what being displayed for first time visitor, the users can still switch between the two modes via the WebUI settings page.
|
> WebUI Settings. `/?simple=1` forces and saves Simple for that browser.
|
||||||
|
>
|
||||||
## Notes about YTP_AUTO_CLEAR_HISTORY_DAYS
|
> `YTP_AUTO_CLEAR_HISTORY_DAYS` `0` days means no automatic clearing of the download history. lowest value that will
|
||||||
|
> trigger the clearing is `1` day. This setting will **NOT** delete the downloaded files, it will only clear the
|
||||||
- `0` days means no automatic clearing of the download history. lowest value that will trigger the clearing is `1` day.
|
> history from the database.
|
||||||
- This setting will **NOT** delete the downloaded files, it will only clear the history from the database.
|
|
||||||
|
|
||||||
# Browser extensions & bookmarklets
|
# Browser extensions & bookmarklets
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,10 +131,9 @@ class LogWrapper:
|
||||||
if level < target.level:
|
if level < target.level:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if target.logger:
|
|
||||||
log_kwargs = {**kwargs}
|
|
||||||
log_kwargs.setdefault("stacklevel", 3)
|
|
||||||
if isinstance(target.target, logging.Logger):
|
if isinstance(target.target, logging.Logger):
|
||||||
|
log_kwargs: dict[str, Any] = {**kwargs}
|
||||||
|
log_kwargs.setdefault("stacklevel", 3)
|
||||||
target.target.log(level, msg, *args, **log_kwargs)
|
target.target.log(level, msg, *args, **log_kwargs)
|
||||||
elif callable(target.target):
|
elif callable(target.target):
|
||||||
target.target(level, msg, *args, **kwargs)
|
target.target(level, msg, *args, **kwargs)
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ async def serve_static_file(request: Request, config: Config) -> StreamResponse:
|
||||||
else:
|
else:
|
||||||
return web.json_response({"error": "File not found.", "file": path}, status=web.HTTPNotFound.status_code)
|
return web.json_response({"error": "File not found.", "file": path}, status=web.HTTPNotFound.status_code)
|
||||||
|
|
||||||
return web.FileResponse(
|
response = web.FileResponse(
|
||||||
path=file_path,
|
path=file_path,
|
||||||
headers={
|
headers={
|
||||||
"Pragma": "public",
|
"Pragma": "public",
|
||||||
|
|
@ -154,6 +154,16 @@ async def serve_static_file(request: Request, config: Config) -> StreamResponse:
|
||||||
status=web.HTTPOk.status_code,
|
status=web.HTTPOk.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if STATIC_STATE.index_file is not None and file_path == STATIC_STATE.index_file:
|
||||||
|
response.set_cookie(
|
||||||
|
name="simple_mode",
|
||||||
|
value="true" if config.simple_mode else "false",
|
||||||
|
path=config.base_path if "/" != config.base_path else "/",
|
||||||
|
samesite="Lax",
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def setup_static_routes(root_path: Path, config: Config) -> None:
|
def setup_static_routes(root_path: Path, config: Config) -> None:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
159
ui/app/components/AppRoot.vue
Normal file
159
ui/app/components/AppRoot.vue
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
<template>
|
||||||
|
<UApp :toaster="toaster">
|
||||||
|
<slot :openSettings="open" :reloadBg="loadBg" :bgLoading="bgLoading" />
|
||||||
|
|
||||||
|
<SettingsPanel
|
||||||
|
:isOpen="settings"
|
||||||
|
:isLoading="bgLoading"
|
||||||
|
direction="right"
|
||||||
|
@close="settings = false"
|
||||||
|
@reload_bg="loadBg(true)"
|
||||||
|
/>
|
||||||
|
</UApp>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useStorage } from '@vueuse/core';
|
||||||
|
import SettingsPanel from '~/components/SettingsPanel.vue';
|
||||||
|
import type { toastPosition } from '~/composables/useNotification';
|
||||||
|
import type { YTDLPOption } from '~/types/ytdlp';
|
||||||
|
import { request, syncOpacity } from '~/utils';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
mode?: 'regular' | 'simple';
|
||||||
|
loadOpts?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
mode: 'regular',
|
||||||
|
loadOpts: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{ ready: [] }>();
|
||||||
|
|
||||||
|
const cfg = useYtpConfig();
|
||||||
|
const sock = useAppSocket();
|
||||||
|
const bgOn = useStorage<boolean>('random_bg', true);
|
||||||
|
const bgOpacity = useStorage<number>('random_bg_opacity', 0.95);
|
||||||
|
const anim = useStorage<boolean>('page_anims', true);
|
||||||
|
const toastPos = useStorage<toastPosition>('toast_position', 'top-right');
|
||||||
|
const settings = ref(false);
|
||||||
|
const bg = ref('');
|
||||||
|
const bgLoading = ref(false);
|
||||||
|
|
||||||
|
const toaster = computed(() => ({
|
||||||
|
position: toastPos.value,
|
||||||
|
max: 5,
|
||||||
|
expand: true,
|
||||||
|
progress: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const open = (): void => {
|
||||||
|
settings.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setMode = (): void => {
|
||||||
|
document.documentElement.classList.toggle('simple-mode', props.mode === 'simple');
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearBg = (): void => {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const body = document.querySelector('body');
|
||||||
|
|
||||||
|
html.classList.remove('bg-fanart');
|
||||||
|
html.removeAttribute('style');
|
||||||
|
body?.removeAttribute('style');
|
||||||
|
bg.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadBg = async (force = false): Promise<void> => {
|
||||||
|
if (bgLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
bgLoading.value = true;
|
||||||
|
const resp = await request(`/api/random/background${force ? '?force=true' : ''}`);
|
||||||
|
if (resp.status === 200) {
|
||||||
|
bg.value = URL.createObjectURL(await resp.blob());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
bgLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ open, loadBg });
|
||||||
|
|
||||||
|
const syncBg = async (): Promise<void> => {
|
||||||
|
if (!bgOn.value) {
|
||||||
|
clearBg();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bg.value) {
|
||||||
|
await loadBg();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadOptions = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const resp = await request('/api/yt-dlp/options');
|
||||||
|
if (resp.ok) {
|
||||||
|
cfg.ytdlp_options = (await resp.json()) as Array<YTDLPOption>;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
setMode();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cfg.loadConfig();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (props.loadOpts) {
|
||||||
|
await loadOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
sock.connect();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
await syncBg();
|
||||||
|
emit('ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (props.mode === 'simple') {
|
||||||
|
document.documentElement.classList.remove('simple-mode');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(bgOn, () => void syncBg());
|
||||||
|
watch(bgOpacity, () => bgOn.value && syncOpacity());
|
||||||
|
watch(anim, (on) => document.documentElement.classList.toggle('no-page-anim', !on), {
|
||||||
|
immediate: true,
|
||||||
|
});
|
||||||
|
watch(bg, () => {
|
||||||
|
if (!bgOn.value || !bg.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.setAttribute(
|
||||||
|
'style',
|
||||||
|
[
|
||||||
|
'background-color: unset',
|
||||||
|
'display: block',
|
||||||
|
'min-height: 100%',
|
||||||
|
'min-width: 100%',
|
||||||
|
`background-image: url(${bg.value})`,
|
||||||
|
].join('; '),
|
||||||
|
);
|
||||||
|
document.documentElement.classList.add('bg-fanart');
|
||||||
|
syncOpacity();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
43
ui/app/components/ConnectionBanner.vue
Normal file
43
ui/app/components/ConnectionBanner.vue
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<template>
|
||||||
|
<UAlert
|
||||||
|
v-if="sock.connectionStatus !== 'connected'"
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
orientation="horizontal"
|
||||||
|
:title="title"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon
|
||||||
|
:name="icon"
|
||||||
|
:class="[
|
||||||
|
'size-4 shrink-0 text-warning',
|
||||||
|
sock.connectionStatus === 'connecting' ? 'animate-spin' : '',
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="sock.connectionStatus === 'disconnected'" #actions>
|
||||||
|
<UButton color="neutral" variant="link" size="sm" class="px-0" @click="sock.reconnect()">
|
||||||
|
Reconnect
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UAlert>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const sock = useAppSocket();
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
if (sock.connectionStatus === 'connecting') {
|
||||||
|
return 'Connecting to websocket server...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Websocket connection lost.';
|
||||||
|
});
|
||||||
|
|
||||||
|
const icon = computed(() =>
|
||||||
|
sock.connectionStatus === 'connecting' ? 'i-lucide-loader-circle' : 'i-lucide-info',
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
@ -264,6 +264,10 @@
|
||||||
.markdown-alert-caution {
|
.markdown-alert-caution {
|
||||||
--markdown-alert-accent: var(--ui-error);
|
--markdown-alert-accent: var(--ui-error);
|
||||||
}
|
}
|
||||||
|
.docs-markdown details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,9 @@
|
||||||
size="sm"
|
size="sm"
|
||||||
square
|
square
|
||||||
icon="i-lucide-x"
|
icon="i-lucide-x"
|
||||||
class="ml-auto shrink-0 sm:hidden"
|
aria-label="Close settings"
|
||||||
|
title="Close settings"
|
||||||
|
class="ml-auto shrink-0"
|
||||||
@click="emitter('close')"
|
@click="emitter('close')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -37,15 +39,32 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #body>
|
<template #body>
|
||||||
<USwitch
|
<UFormField label="" class="w-full" :ui="settingsFieldUi">
|
||||||
v-model="simpleMode"
|
<USelect
|
||||||
class="w-full"
|
v-model="draftMode"
|
||||||
|
:items="modeItems"
|
||||||
|
value-key="value"
|
||||||
|
label-key="label"
|
||||||
size="lg"
|
size="lg"
|
||||||
:ui="settingsSwitchUi"
|
class="w-full"
|
||||||
:label="simpleMode ? 'Simple View' : 'Regular View'"
|
:ui="{ base: 'w-full' }"
|
||||||
description="The simple view is ideal for non-technical users and mobile devices."
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="mt-3 flex justify-end">
|
||||||
|
<UButton
|
||||||
|
color="primary"
|
||||||
|
variant="soft"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-save"
|
||||||
|
:disabled="!modeChanged || savingMode"
|
||||||
|
:loading="savingMode"
|
||||||
|
@click="saveMode"
|
||||||
|
>
|
||||||
|
Save layout
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
<USwitch
|
<USwitch
|
||||||
v-model="page_anims"
|
v-model="page_anims"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
|
|
@ -91,12 +110,12 @@
|
||||||
class="w-full"
|
class="w-full"
|
||||||
v-if="bg_enable"
|
v-if="bg_enable"
|
||||||
label="Background visibility"
|
label="Background visibility"
|
||||||
:hint="String(parseFloat(String(1.0 - bg_opacity)).toFixed(2))"
|
:hint="`${Math.round(bgVisibilityModel * 100)}%`"
|
||||||
>
|
>
|
||||||
<USlider
|
<USlider
|
||||||
v-model="bgOpacityModel"
|
v-model="bgVisibilityModel"
|
||||||
:min="0.5"
|
:min="0"
|
||||||
:max="1"
|
:max="0.5"
|
||||||
:step="0.01"
|
:step="0.01"
|
||||||
size="lg"
|
size="lg"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
|
|
@ -114,12 +133,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #body>
|
<template #body>
|
||||||
<UFormField
|
<UFormField v-if="!modeOn" label="URL Separator" class="w-full" :ui="settingsFieldUi">
|
||||||
v-if="!simpleMode"
|
|
||||||
label="URL Separator"
|
|
||||||
class="w-full"
|
|
||||||
:ui="settingsFieldUi"
|
|
||||||
>
|
|
||||||
<USelect
|
<USelect
|
||||||
v-model="separator"
|
v-model="separator"
|
||||||
:items="separatorItems"
|
:items="separatorItems"
|
||||||
|
|
@ -286,6 +300,7 @@ import { watch, onMounted, onBeforeUnmount, ref, computed } from 'vue';
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import { useNotification } from '~/composables/useNotification';
|
import { useNotification } from '~/composables/useNotification';
|
||||||
import type { notificationTarget, toastPosition } from '~/composables/useNotification';
|
import type { notificationTarget, toastPosition } from '~/composables/useNotification';
|
||||||
|
import { useMode, type Mode } from '~/composables/useMode';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|
@ -302,7 +317,6 @@ const props = withDefaults(
|
||||||
|
|
||||||
const emitter = defineEmits<{ (e: 'close' | 'reload_bg'): void }>();
|
const emitter = defineEmits<{ (e: 'close' | 'reload_bg'): void }>();
|
||||||
|
|
||||||
const config = useYtpConfig();
|
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
const bg_enable = useStorage<boolean>('random_bg', true);
|
const bg_enable = useStorage<boolean>('random_bg', true);
|
||||||
|
|
@ -315,7 +329,9 @@ const show_thumbnail = useStorage<boolean>('show_thumbnail', true);
|
||||||
const show_popover = useStorage<boolean>('show_popover', true);
|
const show_popover = useStorage<boolean>('show_popover', true);
|
||||||
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1');
|
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1');
|
||||||
const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',');
|
const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',');
|
||||||
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false);
|
const { mode, on: modeOn, save: applyMode } = useMode();
|
||||||
|
const draftMode = ref<Mode>(mode.value);
|
||||||
|
const savingMode = ref(false);
|
||||||
const page_anims = useStorage<boolean>('page_anims', true);
|
const page_anims = useStorage<boolean>('page_anims', true);
|
||||||
const queue_auto_refresh = useStorage<boolean>('queue_auto_refresh', true);
|
const queue_auto_refresh = useStorage<boolean>('queue_auto_refresh', true);
|
||||||
const queue_auto_refresh_delay = useStorage<number>('queue_auto_refresh_delay', 10000);
|
const queue_auto_refresh_delay = useStorage<number>('queue_auto_refresh_delay', 10000);
|
||||||
|
|
@ -338,10 +354,10 @@ const settingsSwitchUi = {
|
||||||
wrapper: 'ms-0 flex-1 text-sm',
|
wrapper: 'ms-0 flex-1 text-sm',
|
||||||
};
|
};
|
||||||
|
|
||||||
const bgOpacityModel = computed<number>({
|
const bgVisibilityModel = computed<number>({
|
||||||
get: () => Number(bg_opacity.value),
|
get: () => Number((1 - Number(bg_opacity.value)).toFixed(2)),
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
bg_opacity.value = Number(value);
|
bg_opacity.value = Number((1 - Number(value)).toFixed(2));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -356,6 +372,30 @@ const separatorItems = computed(() =>
|
||||||
separators.map((sep) => ({ label: `${sep.name} (${sep.value})`, value: sep.value })),
|
separators.map((sep) => ({ label: `${sep.name} (${sep.value})`, value: sep.value })),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const modeItems = computed<Array<{ label: string; value: Mode }>>(() => [
|
||||||
|
{
|
||||||
|
label: `Default`,
|
||||||
|
value: 'default',
|
||||||
|
},
|
||||||
|
{ label: 'Simple', value: 'simple' },
|
||||||
|
{ label: 'Regular', value: 'regular' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const modeChanged = computed(() => draftMode.value !== mode.value);
|
||||||
|
|
||||||
|
const saveMode = async (): Promise<void> => {
|
||||||
|
if (!modeChanged.value || savingMode.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
savingMode.value = true;
|
||||||
|
try {
|
||||||
|
await applyMode(draftMode.value);
|
||||||
|
} finally {
|
||||||
|
savingMode.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const thumbnailRatioItems = [
|
const thumbnailRatioItems = [
|
||||||
{ label: '16:9', value: 'is-16by9' },
|
{ label: '16:9', value: 'is-16by9' },
|
||||||
{ label: '3:1', value: 'is-3by1' },
|
{ label: '3:1', value: 'is-3by1' },
|
||||||
|
|
@ -412,12 +452,21 @@ watch(
|
||||||
() => props.isOpen,
|
() => props.isOpen,
|
||||||
(isOpen) => {
|
(isOpen) => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
draftMode.value = mode.value;
|
||||||
document.body.classList.add('settings-panel-open');
|
document.body.classList.add('settings-panel-open');
|
||||||
} else {
|
} else {
|
||||||
document.body.classList.remove('settings-panel-open');
|
document.body.classList.remove('settings-panel-open');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(mode, (value) => {
|
||||||
|
if (props.isOpen && modeChanged.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
draftMode.value = value;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
66
ui/app/components/ThemeButton.vue
Normal file
66
ui/app/components/ThemeButton.vue
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<template>
|
||||||
|
<UTooltip :text="title">
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:icon="icon"
|
||||||
|
:square="square"
|
||||||
|
:aria-label="title"
|
||||||
|
:title="title"
|
||||||
|
@click="color.preference = next"
|
||||||
|
>
|
||||||
|
<span v-if="showLabel" :class="labelClass">{{ title }}</span>
|
||||||
|
</UButton>
|
||||||
|
</UTooltip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
square?: boolean;
|
||||||
|
showLabel?: boolean;
|
||||||
|
labelClass?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
square: false,
|
||||||
|
showLabel: true,
|
||||||
|
labelClass: '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
type Choice = 'system' | 'light' | 'dark';
|
||||||
|
|
||||||
|
const opts: Array<Choice> = ['system', 'light', 'dark'];
|
||||||
|
const color = useColorMode();
|
||||||
|
const current = computed<Choice>(() =>
|
||||||
|
opts.includes(color.preference as Choice) ? (color.preference as Choice) : 'system',
|
||||||
|
);
|
||||||
|
const next = computed<Choice>(
|
||||||
|
() => opts[(opts.indexOf(current.value) + 1) % opts.length] ?? 'system',
|
||||||
|
);
|
||||||
|
const icon = computed(() => {
|
||||||
|
if (current.value === 'light') {
|
||||||
|
return 'i-lucide-sun';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.value === 'dark') {
|
||||||
|
return 'i-lucide-moon';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'i-lucide-monitor';
|
||||||
|
});
|
||||||
|
const title = computed(() => {
|
||||||
|
if (current.value === 'light') {
|
||||||
|
return 'Light';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.value === 'dark') {
|
||||||
|
return 'Dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'System';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
130
ui/app/composables/useMode.ts
Normal file
130
ui/app/composables/useMode.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { computed, nextTick, type ComputedRef, type WritableComputedRef } from 'vue';
|
||||||
|
import { useStorage, type RemovableRef } from '@vueuse/core';
|
||||||
|
|
||||||
|
export type Mode = 'default' | 'simple' | 'regular';
|
||||||
|
type Pref = boolean | null;
|
||||||
|
|
||||||
|
export const MODE_KEY = 'simple_mode';
|
||||||
|
export const SIMPLE_PATH = '/simple';
|
||||||
|
|
||||||
|
const YES = new Set(['1', 'true', 'yes', 'on']);
|
||||||
|
const NO = new Set(['0', 'false', 'no', 'off']);
|
||||||
|
|
||||||
|
let prefRef: RemovableRef<Pref> | null = null;
|
||||||
|
|
||||||
|
export const parseMode = (value: unknown): Pref => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return parseMode(value[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('boolean' === typeof value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('number' === typeof value) {
|
||||||
|
return value === 1 ? true : value === 0 ? false : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('string' !== typeof value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = value.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (YES.has(item)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (NO.has(item)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const modeFromPref = (pref: Pref): Mode => {
|
||||||
|
if (pref === null) {
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
return pref ? 'simple' : 'regular';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefFromMode = (mode: Mode): Pref => {
|
||||||
|
if (mode === 'default') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mode === 'simple';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isSimple = (pref: Pref, fallback: boolean): boolean => pref ?? fallback;
|
||||||
|
|
||||||
|
export const cleanPath = (path: string): string => path.replace(/\/+$/, '') || '/';
|
||||||
|
|
||||||
|
export const usePref = (): RemovableRef<Pref> => {
|
||||||
|
if (prefRef === null) {
|
||||||
|
prefRef = useStorage<Pref>(MODE_KEY, null, undefined, {
|
||||||
|
serializer: {
|
||||||
|
read: parseMode,
|
||||||
|
write: (value) => (value === null ? 'null' : String(value)),
|
||||||
|
},
|
||||||
|
writeDefaults: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefRef;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cookieDefault = (): Pref => parseMode(useCookie<string | null>(MODE_KEY).value);
|
||||||
|
|
||||||
|
export const savePref = (value: Pref): void => {
|
||||||
|
const pref = usePref();
|
||||||
|
pref.value = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMode = (): {
|
||||||
|
mode: WritableComputedRef<Mode>;
|
||||||
|
on: ComputedRef<boolean>;
|
||||||
|
def: ComputedRef<boolean>;
|
||||||
|
save: (mode: Mode) => Promise<void>;
|
||||||
|
} => {
|
||||||
|
const cfg = useYtpConfig();
|
||||||
|
const route = useRoute();
|
||||||
|
const pref = usePref();
|
||||||
|
const def = computed(() => Boolean(cfg.is_loaded ? cfg.app.simple_mode : cookieDefault()));
|
||||||
|
const on = computed(() => isSimple(pref.value, def.value));
|
||||||
|
|
||||||
|
const save = async (value: Mode): Promise<void> => {
|
||||||
|
savePref(prefFromMode(value));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const simpleRoute = cleanPath(route.path) === SIMPLE_PATH;
|
||||||
|
|
||||||
|
if (on.value && !simpleRoute) {
|
||||||
|
await navigateTo(SIMPLE_PATH);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!on.value && simpleRoute) {
|
||||||
|
await navigateTo('/');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mode = computed<Mode>({
|
||||||
|
get: () => modeFromPref(pref.value),
|
||||||
|
set: (value) => {
|
||||||
|
void save(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { mode, on, def, save };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const routeTarget = (path: string, pref: Pref, fallback: boolean): string | null => {
|
||||||
|
if (cleanPath(path) !== '/') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isSimple(pref, fallback) ? SIMPLE_PATH : null;
|
||||||
|
};
|
||||||
|
|
@ -1,45 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<UApp :toaster="toasterConfig">
|
<AppRoot ref="root" mode="regular" load-opts v-slot="{ reloadBg, bgLoading }">
|
||||||
<Transition name="shell-mode" mode="out-in">
|
<div class="shell-stage shell-surface flex flex-col">
|
||||||
<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" />
|
<Shutdown v-if="app_shutdown" />
|
||||||
|
|
||||||
<div id="main_container" class="shell-root flex flex-col" v-else>
|
<div id="main_container" class="shell-root flex flex-col" v-else>
|
||||||
|
|
@ -198,17 +159,7 @@
|
||||||
|
|
||||||
<UDashboardSearchButton class="hidden shrink-0 lg:inline-flex" />
|
<UDashboardSearchButton class="hidden shrink-0 lg:inline-flex" />
|
||||||
|
|
||||||
<UButton
|
<ThemeButton label-class="hidden xl:inline" />
|
||||||
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
|
<UButton
|
||||||
color="neutral"
|
color="neutral"
|
||||||
|
|
@ -225,7 +176,7 @@
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
icon="i-lucide-settings-2"
|
icon="i-lucide-settings-2"
|
||||||
@click="show_settings = !show_settings"
|
@click="root?.open()"
|
||||||
>
|
>
|
||||||
<span class="hidden xl:inline">WebUI Settings</span>
|
<span class="hidden xl:inline">WebUI Settings</span>
|
||||||
</UButton>
|
</UButton>
|
||||||
|
|
@ -266,9 +217,7 @@
|
||||||
<template #leading>
|
<template #leading>
|
||||||
<UIcon
|
<UIcon
|
||||||
:name="
|
:name="
|
||||||
config.is_loading
|
config.is_loading ? 'i-lucide-loader-circle' : 'i-lucide-triangle-alert'
|
||||||
? 'i-lucide-loader-circle'
|
|
||||||
: 'i-lucide-triangle-alert'
|
|
||||||
"
|
"
|
||||||
:class="[
|
:class="[
|
||||||
'size-4 shrink-0',
|
'size-4 shrink-0',
|
||||||
|
|
@ -280,8 +229,7 @@
|
||||||
<template #description>
|
<template #description>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<p v-if="config.is_loading" class="text-sm text-default">
|
<p v-if="config.is_loading" class="text-sm text-default">
|
||||||
This usually takes less than a few seconds. If this is taking too
|
This usually takes less than a few seconds. If this is taking too long,
|
||||||
long,
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="font-semibold text-highlighted underline-offset-2 hover:underline"
|
class="font-semibold text-highlighted underline-offset-2 hover:underline"
|
||||||
|
|
@ -324,49 +272,20 @@
|
||||||
{{ socket.error_count }}
|
{{ socket.error_count }}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{{ socket.error }}. Check the developer console for more
|
{{ socket.error }}. Check the developer console for more information.
|
||||||
information.
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UAlert>
|
</UAlert>
|
||||||
|
|
||||||
<UAlert
|
<ConnectionBanner />
|
||||||
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
|
<div
|
||||||
v-if="config.is_loaded"
|
v-if="config.is_loaded"
|
||||||
class="flex min-h-0 min-w-0 max-w-full flex-1 flex-col"
|
class="flex min-h-0 min-w-0 max-w-full flex-1 flex-col"
|
||||||
>
|
>
|
||||||
<NuxtPage :isLoading="loadingImage" @reload_bg="loadImage(true)" />
|
<NuxtPage :isLoading="bgLoading" @reload_bg="reloadBg(true)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -513,32 +432,22 @@
|
||||||
</UDashboardGroup>
|
</UDashboardGroup>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</AppRoot>
|
||||||
|
|
||||||
<SettingsPanel
|
|
||||||
:isOpen="show_settings"
|
|
||||||
:isLoading="loadingImage"
|
|
||||||
@close="show_settings = false"
|
|
||||||
@reload_bg="loadImage(true)"
|
|
||||||
direction="right"
|
|
||||||
/>
|
|
||||||
</UApp>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { NavigationMenuItem } from '@nuxt/ui';
|
import type { NavigationMenuItem } from '@nuxt/ui';
|
||||||
import { ref, onBeforeUnmount, onMounted, readonly } from 'vue';
|
import { ref, onBeforeUnmount, onMounted, readonly } from 'vue';
|
||||||
import { useStorage } from '@vueuse/core';
|
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { useMediaQuery } from '~/composables/useMediaQuery';
|
import { useMediaQuery } from '~/composables/useMediaQuery';
|
||||||
import type { toastPosition } from '~/composables/useNotification';
|
import AppRoot from '~/components/AppRoot.vue';
|
||||||
|
import ConnectionBanner from '~/components/ConnectionBanner.vue';
|
||||||
import LimitsPage from '~/components/LimitsPage.vue';
|
import LimitsPage from '~/components/LimitsPage.vue';
|
||||||
import { formatPageTitle, parse_api_response, request, syncOpacity, uri } from '~/utils';
|
import ThemeButton from '~/components/ThemeButton.vue';
|
||||||
|
import { formatPageTitle, parse_api_response, request, uri } from '~/utils';
|
||||||
import { getSidebarSwipeMode } from '~/utils/sidebarSwipe';
|
import { getSidebarSwipeMode } from '~/utils/sidebarSwipe';
|
||||||
import type { YTDLPOption } from '~/types/ytdlp';
|
|
||||||
import { useDialog } from '~/composables/useDialog';
|
import { useDialog } from '~/composables/useDialog';
|
||||||
import Dialog from '~/components/Dialog.vue';
|
import Dialog from '~/components/Dialog.vue';
|
||||||
import Simple from '~/components/Simple.vue';
|
|
||||||
import Shutdown from '~/components/shutdown.vue';
|
import Shutdown from '~/components/shutdown.vue';
|
||||||
import type { version_check } from '~/types';
|
import type { version_check } from '~/types';
|
||||||
import {
|
import {
|
||||||
|
|
@ -555,7 +464,6 @@ type SidebarSection = {
|
||||||
items: Array<Array<NavigationMenuItem>>;
|
items: Array<Array<NavigationMenuItem>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ColorModePreference = 'system' | 'light' | 'dark';
|
|
||||||
type SwipeMode = 'open' | 'close';
|
type SwipeMode = 'open' | 'close';
|
||||||
|
|
||||||
const MOBILE_SIDEBAR_MIN_SWIPE_DISTANCE = 64;
|
const MOBILE_SIDEBAR_MIN_SWIPE_DISTANCE = 64;
|
||||||
|
|
@ -563,15 +471,8 @@ const MOBILE_SIDEBAR_MIN_SWIPE_DISTANCE = 64;
|
||||||
const socket = useAppSocket();
|
const socket = useAppSocket();
|
||||||
const config = useYtpConfig();
|
const config = useYtpConfig();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const colorMode = useColorMode();
|
const root = ref<InstanceType<typeof AppRoot> | null>(null);
|
||||||
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 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 checkingUpdates = ref(false);
|
||||||
const updateCheckMessage = ref('Up to date - Check now');
|
const updateCheckMessage = ref('Up to date - Check now');
|
||||||
const showRouteSearch = ref(false);
|
const showRouteSearch = ref(false);
|
||||||
|
|
@ -589,42 +490,6 @@ const SwipeState = {
|
||||||
endY: 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 => {
|
const resetSwipe = (): void => {
|
||||||
SwipeState.mode = null;
|
SwipeState.mode = null;
|
||||||
SwipeState.tracking = false;
|
SwipeState.tracking = false;
|
||||||
|
|
@ -806,21 +671,41 @@ const buildTooltip = computed(
|
||||||
`Build: ${config.app?.app_build_date}, Branch: ${config.app?.app_branch}, SHA: ${config.app?.app_commit_sha}`,
|
`Build: ${config.app?.app_build_date}, Branch: ${config.app?.app_branch}, SHA: ${config.app?.app_commit_sha}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const showConnectionBanner = computed(() => socket.connectionStatus !== 'connected');
|
const connectionStatusColor = computed(() => {
|
||||||
|
if (socket.connectionStatus === 'connected') {
|
||||||
const connectionBannerTitle = computed(() => {
|
return 'text-success';
|
||||||
switch (socket.connectionStatus) {
|
|
||||||
case 'connecting':
|
|
||||||
return 'Connecting to websocket server...';
|
|
||||||
case 'disconnected':
|
|
||||||
default:
|
|
||||||
return 'Websocket connection lost.';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (socket.connectionStatus === 'connecting') {
|
||||||
|
return 'text-warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'text-error';
|
||||||
});
|
});
|
||||||
|
|
||||||
const connectionBannerIcon = computed(() =>
|
const connectionStatusDotClass = computed(() => {
|
||||||
socket.connectionStatus === 'connecting' ? 'i-lucide-loader-circle' : 'i-lucide-info',
|
if (socket.connectionStatus === 'connected') {
|
||||||
);
|
return 'bg-success';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (socket.connectionStatus === 'connecting') {
|
||||||
|
return 'bg-warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'bg-error';
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectionStatusLabel = computed(() => {
|
||||||
|
if (socket.connectionStatus === 'connected') {
|
||||||
|
return 'Connected';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (socket.connectionStatus === 'connecting') {
|
||||||
|
return 'Connecting...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Disconnected';
|
||||||
|
});
|
||||||
|
|
||||||
const sidebarSections = computed<Array<SidebarSection>>(() =>
|
const sidebarSections = computed<Array<SidebarSection>>(() =>
|
||||||
sidebarItems.value.map((section) => ({
|
sidebarItems.value.map((section) => ({
|
||||||
|
|
@ -900,12 +785,7 @@ const resumeDownloads = async (): Promise<void> => {
|
||||||
|
|
||||||
const openSettings = async (): Promise<void> => {
|
const openSettings = async (): Promise<void> => {
|
||||||
await closeRouteSearch();
|
await closeRouteSearch();
|
||||||
show_settings.value = true;
|
root.value?.open();
|
||||||
};
|
|
||||||
|
|
||||||
const syncShellModeClass = () => {
|
|
||||||
const html = document.documentElement;
|
|
||||||
html.classList.toggle('simple-mode', simpleMode.value);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRouteSelect = async (item: NavItem) => {
|
const handleRouteSelect = async (item: NavItem) => {
|
||||||
|
|
@ -983,26 +863,6 @@ onMounted(async () => {
|
||||||
passive: true,
|
passive: true,
|
||||||
capture: 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(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|
@ -1012,8 +872,6 @@ onBeforeUnmount(() => {
|
||||||
document.removeEventListener('touchcancel', handleSwipeCancel, true);
|
document.removeEventListener('touchcancel', handleSwipeCancel, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(bg_enable, async (v) => await handleImage(v));
|
|
||||||
watch(simpleMode, () => syncShellModeClass());
|
|
||||||
watch(isMobile, (v) => {
|
watch(isMobile, (v) => {
|
||||||
if (v) {
|
if (v) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -1022,103 +880,6 @@ watch(isMobile, (v) => {
|
||||||
showSidebar.value = false;
|
showSidebar.value = false;
|
||||||
resetSwipe();
|
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 useVersionUpdate = () => {
|
||||||
const newVersionIsAvailable = ref(false);
|
const newVersionIsAvailable = ref(false);
|
||||||
|
|
@ -1174,51 +935,6 @@ const shutdownApp = async () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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();
|
const reloadPage = () => window.location.reload();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -1228,20 +944,6 @@ const reloadPage = () => window.location.reload();
|
||||||
min-height: 100dvh;
|
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 {
|
.shell-root {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
|
|
|
||||||
35
ui/app/middleware/mode.global.ts
Normal file
35
ui/app/middleware/mode.global.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { cookieDefault, parseMode, routeTarget, savePref, usePref } from '~/composables/useMode';
|
||||||
|
|
||||||
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
|
const path = to.path.replace(/\/+$/, '') || '/';
|
||||||
|
const pref = usePref();
|
||||||
|
|
||||||
|
if (to.query.simple !== undefined) {
|
||||||
|
const value = parseMode(to.query.simple);
|
||||||
|
if (value !== null) {
|
||||||
|
savePref(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = { ...to.query };
|
||||||
|
delete query.simple;
|
||||||
|
const target = value === true ? '/simple' : value === false ? '/' : to.path;
|
||||||
|
|
||||||
|
return navigateTo({ path: target, query, hash: to.hash }, { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path !== '/') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let def = cookieDefault();
|
||||||
|
if (pref.value === null && def === null) {
|
||||||
|
const cfg = useYtpConfig();
|
||||||
|
await cfg.loadConfig();
|
||||||
|
def = Boolean(cfg.app.simple_mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = routeTarget(path, pref.value, Boolean(def));
|
||||||
|
if (target) {
|
||||||
|
return navigateTo(target, { replace: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -754,7 +754,6 @@ import { requirePageShell } from '~/utils/topLevelNavigation';
|
||||||
const config = useYtpConfig();
|
const config = useYtpConfig();
|
||||||
const stateStore = useQueueState();
|
const stateStore = useQueueState();
|
||||||
const socket = useAppSocket();
|
const socket = useAppSocket();
|
||||||
const route = useRoute();
|
|
||||||
const toast = useNotification();
|
const toast = useNotification();
|
||||||
const box = useConfirm();
|
const box = useConfirm();
|
||||||
const { confirmDialog } = useDialog();
|
const { confirmDialog } = useDialog();
|
||||||
|
|
@ -889,15 +888,6 @@ const stopAutoRefresh = (): void => {
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (route.query?.simple !== undefined) {
|
|
||||||
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false);
|
|
||||||
simpleMode.value = ['true', '1', 'yes', 'on'].includes(route.query.simple as string);
|
|
||||||
await nextTick();
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.delete('simple');
|
|
||||||
window.history.replaceState({}, '', url.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(pendingDownloadFormItem.value).length > 0) {
|
if (Object.keys(pendingDownloadFormItem.value).length > 0) {
|
||||||
await toNewDownload(pendingDownloadFormItem.value);
|
await toNewDownload(pendingDownloadFormItem.value);
|
||||||
pendingDownloadFormItem.value = {};
|
pendingDownloadFormItem.value = {};
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
44
ui/tests/composables/useMode.test.ts
Normal file
44
ui/tests/composables/useMode.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
SIMPLE_PATH,
|
||||||
|
isSimple,
|
||||||
|
modeFromPref,
|
||||||
|
parseMode,
|
||||||
|
prefFromMode,
|
||||||
|
routeTarget,
|
||||||
|
} from '~/composables/useMode';
|
||||||
|
|
||||||
|
describe('useMode', () => {
|
||||||
|
it('parse', () => {
|
||||||
|
expect(parseMode('true')).toBe(true);
|
||||||
|
expect(parseMode('false')).toBe(false);
|
||||||
|
expect(parseMode(true)).toBe(true);
|
||||||
|
expect(parseMode(false)).toBe(false);
|
||||||
|
expect(parseMode('null')).toBeNull();
|
||||||
|
expect(parseMode('')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolve', () => {
|
||||||
|
expect(isSimple(null, true)).toBe(true);
|
||||||
|
expect(isSimple(null, false)).toBe(false);
|
||||||
|
expect(isSimple(false, true)).toBe(false);
|
||||||
|
expect(isSimple(true, false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('map', () => {
|
||||||
|
expect(modeFromPref(null)).toBe('default');
|
||||||
|
expect(modeFromPref(true)).toBe('simple');
|
||||||
|
expect(modeFromPref(false)).toBe('regular');
|
||||||
|
expect(prefFromMode('default')).toBeNull();
|
||||||
|
expect(prefFromMode('simple')).toBe(true);
|
||||||
|
expect(prefFromMode('regular')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('route', () => {
|
||||||
|
expect(routeTarget('/', null, true)).toBe(SIMPLE_PATH);
|
||||||
|
expect(routeTarget('/', false, true)).toBeNull();
|
||||||
|
expect(routeTarget('/history', null, true)).toBeNull();
|
||||||
|
expect(routeTarget(SIMPLE_PATH, null, false)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue