refactor: seperate history from queue

This commit is contained in:
arabcoders 2026-04-21 21:46:55 +03:00
parent 210712cee2
commit e7dfee66b1
12 changed files with 4279 additions and 3418 deletions

File diff suppressed because it is too large Load diff

View file

@ -14,6 +14,9 @@ const props = defineProps<{
unrenderDelay?: number;
}>();
const ROOT_MARGIN = 600;
const nuxtApp = useNuxtApp();
const shouldRender = ref(false);
const targetEl = ref<HTMLElement | null>(null);
const fixedMinHeight = ref(0);
@ -21,12 +24,35 @@ const fixedMinHeight = ref(0);
let unrenderTimer: ReturnType<typeof setTimeout> | null = null;
let renderTimer: ReturnType<typeof setTimeout> | null = null;
function onIdle(cb: () => void): void {
if ('requestIdleCallback' in window) {
(window as any).requestIdleCallback(cb);
} else {
setTimeout(() => nextTick(cb), 300);
function ensureRenderedIfNearViewport(): void {
if (!targetEl.value) {
return;
}
const rect = targetEl.value.getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
if (
rect.bottom < -ROOT_MARGIN ||
rect.top > viewportHeight + ROOT_MARGIN ||
rect.right < 0 ||
rect.left > viewportWidth
) {
return;
}
if (unrenderTimer) {
clearTimeout(unrenderTimer);
unrenderTimer = null;
}
if (renderTimer) {
clearTimeout(renderTimer);
renderTimer = null;
}
shouldRender.value = true;
}
const { stop } = useIntersectionObserver(
@ -43,7 +69,7 @@ const { stop } = useIntersectionObserver(
props.unrender ? 200 : 0,
);
shouldRender.value = true;
ensureRenderedIfNearViewport();
if (!props.unrender) {
stop();
@ -61,19 +87,55 @@ const { stop } = useIntersectionObserver(
}, props.unrenderDelay ?? 6000);
}
},
{ rootMargin: '600px' },
{ rootMargin: `${ROOT_MARGIN}px` },
);
if (props.renderOnIdle) {
onIdle(() => {
shouldRender.value = true;
if (!props.unrender) {
stop();
}
const removePageHooks = [
nuxtApp.hook('page:finish', () => {
requestAnimationFrame(() => {
ensureRenderedIfNearViewport();
});
}),
nuxtApp.hook('page:transition:finish', () => {
requestAnimationFrame(() => {
ensureRenderedIfNearViewport();
});
}),
];
onMounted(() => {
window.addEventListener('resize', ensureRenderedIfNearViewport, { passive: true });
requestAnimationFrame(() => {
ensureRenderedIfNearViewport();
});
});
if (props.renderOnIdle) {
if ('requestIdleCallback' in window) {
(window as any).requestIdleCallback(() => {
shouldRender.value = true;
if (!props.unrender) {
stop();
}
});
} else {
setTimeout(
() =>
nextTick(() => {
shouldRender.value = true;
if (!props.unrender) {
stop();
}
}),
300,
);
}
}
onBeforeUnmount(() => {
window.removeEventListener('resize', ensureRenderedIfNearViewport);
removePageHooks.forEach((removeHook) => removeHook());
if (renderTimer) {
clearTimeout(renderTimer);
}

View file

@ -1,115 +0,0 @@
<template>
<div class="flex flex-wrap items-center gap-2">
<UButton
v-if="page !== 1"
rel="first"
aria-label="Go to first page"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-chevrons-left"
:disabled="isLoading"
:loading="isLoading"
square
@click="changePage(1)"
/>
<UButton
v-if="page > 1 && page - 1 !== 1"
rel="prev"
aria-label="Go to previous page"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-chevron-left"
:disabled="isLoading"
:loading="isLoading"
square
@click="changePage(page - 1)"
/>
<USelect
id="pager_list"
v-model="currentPage"
:items="paginationItems"
value-key="page"
label-key="text"
color="neutral"
variant="outline"
size="sm"
class="min-w-52"
:disabled="isLoading"
:ui="{ base: 'w-full' }"
@update:model-value="changePage"
/>
<UButton
v-if="page !== last_page && page + 1 !== last_page"
rel="next"
aria-label="Go to next page"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-chevron-right"
:disabled="isLoading"
:loading="isLoading"
square
@click="changePage(page + 1)"
/>
<UButton
v-if="page !== last_page"
rel="last"
aria-label="Go to last page"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-chevrons-right"
:disabled="isLoading"
:loading="isLoading"
square
@click="changePage(last_page)"
/>
</div>
</template>
<script setup lang="ts">
import { makePagination } from '~/utils/index';
const emitter = defineEmits<{
(e: 'navigate', page: number): void;
}>();
const props = defineProps<{
page: number;
last_page: number;
isLoading?: boolean;
}>();
const currentPage = ref(props.page);
const paginationItems = computed(() =>
makePagination(props.page, props.last_page).map((item) => ({
...item,
disabled: item.page === 0,
})),
);
watch(
() => props.page,
(value) => {
currentPage.value = value;
},
);
const changePage = (page: number | string): void => {
const nextPage = Number(page);
if (Number.isNaN(nextPage) || nextPage < 1 || nextPage > props.last_page) {
currentPage.value = props.page;
return;
}
emitter('navigate', nextPage);
currentPage.value = nextPage;
};
</script>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,184 @@
import { computed, ref } from 'vue';
import { useNotification } from '~/composables/useNotification';
import { useYtpConfig } from '~/composables/useYtpConfig';
import { parse_api_error, parse_list_response, request } from '~/utils';
import type { Pagination } from '~/types/responses';
import type { StoreItem } from '~/types/store';
type HistoryLoadOptions = {
order?: 'ASC' | 'DESC';
status?: string;
perPage?: number;
};
const items = ref<StoreItem[]>([]);
const pagination = ref<Pagination>({
page: 1,
per_page: 50,
total: 0,
total_pages: 0,
has_next: false,
has_prev: false,
});
const isLoading = ref<boolean>(false);
const isLoaded = ref<boolean>(false);
const lastError = ref<string | null>(null);
const throwInstead = ref(false);
const readJson = async (response: Response): Promise<unknown> => {
try {
const clone = response.clone();
return await clone.json();
} catch {
return null;
}
};
const ensureSuccess = async (response: Response): Promise<void> => {
if (response.ok) {
return;
}
const payload = await readJson(response);
const message = await parse_api_error(payload);
throw new Error(message);
};
const handleError = (error: unknown): void => {
const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
lastError.value = message;
useNotification().error(message);
};
const pageSize = computed<number>(() => {
const config = useYtpConfig();
return Number(config.app.default_pagination || 50);
});
const buildQuery = (
page: number,
perPage: number,
order: 'ASC' | 'DESC' = 'DESC',
status?: string,
): string => {
const params = new URLSearchParams({
type: 'done',
page: String(page),
per_page: String(perPage),
order,
});
if (status) {
params.set('status', status);
}
return params.toString();
};
const loadHistory = async (page: number = 1, options: HistoryLoadOptions = {}): Promise<void> => {
const { order = 'DESC', status, perPage = pageSize.value } = options;
isLoading.value = true;
try {
const response = await request(`/api/history?${buildQuery(page, perPage, order, status)}`);
await ensureSuccess(response);
const data = await response.json();
const { items: loadedItems, pagination: paginationData } =
await parse_list_response<StoreItem>(data);
items.value = loadedItems;
pagination.value = paginationData;
isLoaded.value = true;
lastError.value = null;
} catch (error) {
handleError(error);
if (throwInstead.value) {
throw error;
}
} finally {
isLoading.value = false;
}
};
const reloadHistory = async (options: HistoryLoadOptions = {}): Promise<void> => {
const targetPage = isLoaded.value ? pagination.value.page : 1;
await loadHistory(targetPage, options);
};
const deleteHistoryItems = async (
options: {
ids?: string[];
status?: string;
removeFile?: boolean;
} = {},
): Promise<number> => {
const { ids, status, removeFile = true } = options;
if (!ids && !status) {
throw new Error('Either ids or status filter must be provided');
}
try {
const response = await request('/api/history', {
method: 'DELETE',
body: JSON.stringify({
type: 'done',
ids,
status,
remove_file: removeFile,
}),
});
await ensureSuccess(response);
const result = (await response.json()) as {
deleted?: number;
error?: string;
message?: string;
};
if (result.error || result.message) {
throw new Error(result.error || result.message);
}
return Number(result.deleted ?? 0);
} catch (error) {
handleError(error);
if (throwInstead.value) {
throw error;
}
return 0;
}
};
const resetHistory = (): void => {
items.value = [];
pagination.value = {
page: 1,
per_page: pageSize.value,
total: 0,
total_pages: 0,
has_next: false,
has_prev: false,
};
isLoaded.value = false;
lastError.value = null;
};
export const useHistoryState = () => {
return {
items,
pagination,
isLoading,
isLoaded,
lastError,
pageSize,
loadHistory,
reloadHistory,
deleteHistoryItems,
resetHistory,
};
};

View file

@ -0,0 +1,243 @@
import { proxyRefs, reactive, toRefs } from 'vue';
import type { item_request } from '~/types/item';
import type { StoreItem } from '~/types/store';
import { request } from '~/utils';
type KeyType = string;
interface QueueState {
queue: Record<KeyType, StoreItem>;
}
const state = reactive<QueueState>({
queue: {},
});
const add = (key: KeyType, value: StoreItem): void => {
state.queue[key] = value;
};
const update = (key: KeyType, value: StoreItem): void => {
state.queue[key] = value;
};
const remove = (key: KeyType): void => {
if (!state.queue[key]) {
return;
}
const { [key]: _, ...rest } = state.queue;
state.queue = rest;
};
const get = (key: KeyType, defaultValue: StoreItem | null = null): StoreItem | null => {
return state.queue[key] || defaultValue;
};
const has = (key: KeyType): boolean => {
return !!state.queue[key];
};
const clearAll = (): void => {
state.queue = {};
};
const addAll = (data: Record<KeyType, StoreItem>): void => {
state.queue = data;
};
const count = (): number => {
return Object.keys(state.queue).length;
};
const loadQueue = async (): Promise<void> => {
try {
const response = await request('/api/history/live');
const data = (await response.json()) as {
queue: Record<KeyType, StoreItem>;
};
state.queue = data.queue || {};
} catch (error) {
console.error('Failed to load queue:', error);
throw error;
}
};
const addDownload = async (data: item_request): Promise<void> => {
const socket = useAppSocket();
const toast = useNotification();
if (socket.isConnected) {
socket.emit('add_url', data);
return;
}
try {
const response = await request('/api/history/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
toast.error(error.error || 'Failed to add download');
throw new Error(error.error || 'Failed to add download');
}
toast.success('Download added successfully');
await loadQueue();
} catch (error) {
console.error('Failed to add download:', error);
if (error instanceof Error && !error.message.includes('Failed to add download')) {
toast.error('Failed to add download');
}
throw error;
}
};
const startItems = async (ids: string[]): Promise<void> => {
const socket = useAppSocket();
const toast = useNotification();
if (socket.isConnected) {
ids.forEach((id) => socket.emit('item_start', id));
return;
}
try {
const response = await request('/api/history/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
if (!response.ok) {
const error = await response.json();
toast.error(error.error || 'Failed to start items');
throw new Error(error.error || 'Failed to start items');
}
const result = await response.json();
for (const id of ids) {
if ('started' === result[id]) {
const item = get(id);
if (item) {
update(id, { ...item, auto_start: true });
}
}
}
toast.success(`Started ${ids.length} item${1 === ids.length ? '' : 's'}`);
} catch (error) {
console.error('Failed to start items:', error);
if (error instanceof Error && !error.message.includes('Failed to start items')) {
toast.error('Failed to start items');
}
throw error;
}
};
const pauseItems = async (ids: string[]): Promise<void> => {
const socket = useAppSocket();
const toast = useNotification();
if (socket.isConnected) {
ids.forEach((id) => socket.emit('item_pause', id));
return;
}
try {
const response = await request('/api/history/pause', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
if (!response.ok) {
const error = await response.json();
toast.error(error.error || 'Failed to pause items');
throw new Error(error.error || 'Failed to pause items');
}
const result = await response.json();
for (const id of ids) {
if ('paused' === result[id]) {
const item = get(id);
if (item) {
update(id, { ...item, auto_start: false });
}
}
}
toast.success(`Paused ${ids.length} item${1 === ids.length ? '' : 's'}`);
} catch (error) {
console.error('Failed to pause items:', error);
if (error instanceof Error && !error.message.includes('Failed to pause items')) {
toast.error('Failed to pause items');
}
throw error;
}
};
const cancelItems = async (ids: string[]): Promise<void> => {
const socket = useAppSocket();
const toast = useNotification();
if (socket.isConnected) {
ids.forEach((id) => socket.emit('item_cancel', id));
return;
}
try {
const response = await request('/api/history/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
if (!response.ok) {
const error = await response.json();
toast.error(error.error || 'Failed to cancel items');
throw new Error(error.error || 'Failed to cancel items');
}
const result = await response.json();
for (const id of ids) {
if ('ok' === result[id]) {
remove(id);
}
}
toast.success(`Cancelled ${ids.length} item${1 === ids.length ? '' : 's'}`);
} catch (error) {
console.error('Failed to cancel items:', error);
if (error instanceof Error && !error.message.includes('Failed to cancel items')) {
toast.error('Failed to cancel items');
}
throw error;
}
};
const queueStateApi = proxyRefs({
...toRefs(state),
add,
update,
remove,
get,
has,
clearAll,
addAll,
count,
loadQueue,
addDownload,
startItems,
pauseItems,
cancelItems,
});
export const useQueueState = () => queueStateApi;

View file

@ -29,14 +29,14 @@
variant="link"
size="sm"
class="px-0"
@click="() => socket.reconnect()"
@click="socket.reconnect()"
>
Reconnect
</UButton>
</template>
</UAlert>
<Simple @show_settings="() => (show_settings = true)" />
<Simple @show_settings="show_settings = true" />
</div>
<div v-else key="regular" class="shell-stage flex flex-col">
@ -174,7 +174,7 @@
variant="ghost"
size="sm"
icon="i-lucide-refresh-cw"
@click="reloadPage"
@click="$router.go(0)"
>
<span class="hidden xl:inline">Reload</span>
</UButton>
@ -186,10 +186,12 @@
variant="ghost"
size="sm"
:icon="colorModeButtonIcon"
:aria-label="colorModeButtonAriaLabel"
:aria-label="colorModeButtonTitle"
:title="colorModeButtonTitle"
@click="cycleColorMode"
/>
@click="colorMode.preference = nextColorModePreference"
>
<span class="hidden xl:inline">{{ colorModeButtonTitle }}</span>
</UButton>
<UButton
color="neutral"
@ -256,7 +258,7 @@
<button
type="button"
class="font-semibold text-highlighted underline-offset-2 hover:underline"
@click="reloadPage"
@click="$router.go(0)"
>
click here
</button>
@ -269,7 +271,7 @@
<button
type="button"
class="font-semibold text-highlighted underline-offset-2 hover:underline"
@click="() => config.loadConfig(true)"
@click="config.loadConfig(true)"
>
reload configuration
</button>
@ -277,7 +279,7 @@
<button
type="button"
class="font-semibold text-highlighted underline-offset-2 hover:underline"
@click="reloadPage"
@click="$router.go(0)"
>
reload the page
</button>
@ -326,7 +328,7 @@
variant="link"
size="sm"
class="px-0"
@click="() => socket.reconnect()"
@click="socket.reconnect()"
>
Reconnect
</UButton>
@ -337,7 +339,7 @@
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)" />
<NuxtPage :isLoading="loadingImage" @reload_bg="loadImage(true)" />
</div>
</div>
@ -481,8 +483,8 @@
<SettingsPanel
:isOpen="show_settings"
:isLoading="loadingImage"
@close="closeSettings()"
@reload_bg="() => loadImage(true)"
@close="show_settings = false"
@reload_bg="loadImage(true)"
direction="right"
/>
</UApp>
@ -576,29 +578,14 @@ const nextColorModePreference = computed<ColorModePreference>(() => {
const colorModeButtonTitle = computed(() => {
switch (colorModePreference.value) {
case 'light':
return 'Theme: Light';
return 'Light';
case 'dark':
return 'Theme: Dark';
return 'Dark';
default:
return 'Theme: System';
return 'System';
}
});
const colorModeButtonAriaLabel = computed(() => {
switch (nextColorModePreference.value) {
case 'light':
return 'Switch theme to light';
case 'dark':
return 'Switch theme to dark';
default:
return 'Switch theme to system';
}
});
const cycleColorMode = (): void => {
colorMode.preference = nextColorModePreference.value;
};
const resetSwipe = (): void => {
SwipeState.mode = null;
SwipeState.tracking = false;
@ -1091,8 +1078,6 @@ const useVersionUpdate = () => {
const { newVersionIsAvailable } = useVersionUpdate();
const closeSettings = () => (show_settings.value = false);
const shutdownApp = async () => {
if (false === config.app.is_native) {
await alertDialog({
@ -1131,8 +1116,6 @@ const shutdownApp = async () => {
}
};
const reloadPage = () => window.location.reload();
const connectionStatusColor = computed(() => {
switch (socket.connectionStatus) {
case 'connected':
@ -1177,6 +1160,8 @@ const toasterConfig = computed(() => ({
expand: true,
progress: true,
}));
const reloadPage = () => window.location.reload();
</script>
<style>

1561
ui/app/pages/history.vue Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,138 +1,206 @@
<!doctype html>
<html lang="en" data-n-head-ssr="" data-n-head-ssr-body="">
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>YTPTube Loading...</title>
<meta name="theme-color" content="#0f172a" />
<title>Loading YTPTube</title>
<style>
:root {
color-scheme: light dark;
--bg: #edf2f9;
--bg-deep: #dbe5f1;
--panel: rgb(255 255 255 / 0.84);
--border: rgb(15 23 42 / 0.1);
--shadow: rgb(15 23 42 / 0.14);
--text: #0f172a;
--muted: #5b6b80;
--accent: #dc2626;
--accent-soft: rgb(220 38 38 / 0.18);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #08111d;
--bg-deep: #0f172a;
--panel: rgb(15 23 42 / 0.84);
--border: rgb(148 163 184 / 0.16);
--shadow: rgb(2 6 23 / 0.45);
--text: #e7eef9;
--muted: #9fb0c7;
--accent: #f87171;
--accent-soft: rgb(248 113 113 / 0.18);
}
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
padding: 0;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
color: #111;
user-select: none;
min-height: 100vh;
min-height: 100dvh;
padding: 1rem;
display: grid;
place-items: center;
overflow: hidden;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
transition:
background-color 0.3s ease,
color 0.3s ease;
}
@media (prefers-color-scheme: dark) {
html,
body {
background-color: #121212;
color: #eee;
}
color: var(--text);
user-select: none;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
background:
radial-gradient(circle at top left, var(--accent-soft), transparent 24%),
linear-gradient(180deg, var(--bg-deep), var(--bg));
}
.loader-container {
text-align: center;
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
background:
linear-gradient(rgb(255 255 255 / 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 0.035) 1px, transparent 1px);
background-size: 2rem 2rem;
opacity: 0.32;
}
.loading-card {
width: min(100%, 29rem);
position: relative;
padding: 1.65rem;
border: 1px solid var(--border);
border-radius: 1.1rem;
background: var(--panel);
box-shadow: 0 1rem 2.5rem var(--shadow);
backdrop-filter: blur(16px);
}
.brand {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.95rem;
}
svg.ytptube-loading {
width: 420px;
height: 105px;
stroke: #ff0000;
stroke-width: 4.5px;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
stroke-dasharray: 700;
stroke-dashoffset: 700;
animation: stroke-move 3s linear infinite;
user-select: none;
.brand img {
width: 2.8rem;
height: 2.8rem;
flex: none;
display: block;
object-fit: contain;
}
@media (prefers-color-scheme: dark) {
svg.ytptube-loading {
stroke: #ff4444;
.brand-copy {
min-width: 0;
}
.label {
margin: 0;
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--muted);
}
h1 {
margin: 0.35rem 0 0;
font-size: 1.55rem;
line-height: 1.1;
letter-spacing: -0.02em;
font-weight: 700;
}
.description {
margin: 1rem 0 0;
color: var(--muted);
font-size: 0.96rem;
line-height: 1.55;
}
.loader {
margin-top: 1.2rem;
height: 0.32rem;
overflow: hidden;
border-radius: 999px;
background: rgb(148 163 184 / 0.22);
}
.loader::before {
content: '';
display: block;
width: 36%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
animation: slide 1.35s ease-in-out infinite;
}
.hint {
margin: 0.95rem 0 0;
font-size: 0.82rem;
color: var(--muted);
}
@keyframes slide {
0% {
transform: translateX(-130%);
}
}
@keyframes stroke-move {
100% {
stroke-dashoffset: -700;
transform: translateX(330%);
}
}
.spinner {
margin: 20px auto 0;
width: 64px;
height: 64px;
border: 8px solid transparent;
border-top-color: #ff0000;
border-radius: 50%;
animation: spin 1.2s linear infinite;
filter: drop-shadow(0 0 6px rgba(255, 0, 0, 0.8));
}
@media (max-width: 560px) {
.loading-card {
padding: 1.3rem;
}
@media (prefers-color-scheme: dark) {
.spinner {
border-top-color: #ff4444;
filter: drop-shadow(0 0 8px #ff4444);
.brand {
align-items: flex-start;
}
h1 {
font-size: 1.35rem;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
}
}
.subtitle {
margin-top: 14px;
font-size: 1rem;
color: inherit;
opacity: 0.8;
user-select: none;
font-weight: 600;
letter-spacing: 0.1em;
}
</style>
</head>
<body>
<div class="loader-container" role="img" aria-label="Loading YTPTube">
<svg
class="ytptube-loading"
viewBox="0 0 420 105"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
focusable="false"
>
<!-- Y -->
<path d="M10 10 L25 37.5 L10 65" />
<path d="M25 37.5 L40 10" />
<!-- T -->
<path d="M50 10 L90 10" />
<path d="M70 10 L70 65" />
<!-- P -->
<path d="M100 65 L100 10 L130 10 Q145 10 145 27.5 Q145 45 130 45 L100 45" />
<!-- T -->
<path d="M160 10 L200 10" />
<path d="M180 10 L180 65" />
<!-- U -->
<path d="M210 10 L210 50 Q210 65 230 65 Q250 65 250 50 L250 10" />
<!-- B -->
<path d="M270 10 L270 65" />
<path d="M270 10 Q300 10 300 25 Q300 45 270 45" />
<path d="M270 45 Q300 45 300 60 Q300 65 270 65" />
<!-- E -->
<path d="M320 10 L320 65" />
<path d="M320 10 L360 10" />
<path d="M320 37.5 L350 37.5" />
<path d="M320 65 L360 65" />
</svg>
<div class="spinner"></div>
<div class="subtitle">Loading, please wait...</div>
</div>
<main class="loading-card" role="status" aria-live="polite" aria-busy="true">
<div class="brand">
<img src="/images/favicon.png" alt="" aria-hidden="true" />
<div class="brand-copy">
<p class="label">YTPTube</p>
<h1>Loading your dashboard</h1>
</div>
</div>
<p class="description">Getting the interface ready. Please wait a moment...</p>
<div class="loader" aria-hidden="true"></div>
<p class="hint">Please wait...</p>
</main>
</body>
</html>

View file

@ -20,7 +20,6 @@ type NavDefinition = {
matchPath?: string;
sidebarVisible?: boolean;
searchable?: boolean;
activeMode?: 'path' | 'downloads' | 'history';
navbarTitle?: string;
requires?: 'file_logging' | 'console_enabled';
};
@ -70,7 +69,6 @@ const NavItems: Array<NavDefinition> = [
icon: 'i-lucide-download',
to: '/',
matchPath: '/',
activeMode: 'downloads',
},
{
id: 'history',
@ -81,9 +79,8 @@ const NavItems: Array<NavDefinition> = [
breadcrumbSectionLabel: 'Workspace',
description: 'Completed, skipped, and failed downloads.',
icon: 'i-lucide-history',
to: '/#history',
matchPath: '/',
activeMode: 'history',
to: '/history',
matchPath: '/history',
navbarTitle: 'Downloads',
},
{
@ -269,24 +266,14 @@ export const getNavItemById = (id: string): NavItem | undefined => {
};
export const isNavItemActive = (entry: NavItem, route: LocationPath): boolean => {
switch (entry.activeMode) {
case 'downloads':
return route.path === '/' && route.hash !== '#history';
const current = normalizePath(route.path);
const target = normalizePath(entry.matchPath);
case 'history':
return route.path === '/' && route.hash === '#history';
default: {
const current = normalizePath(route.path);
const target = normalizePath(entry.matchPath);
if (target === '/') {
return current === '/';
}
return current === target || current.startsWith(`${target}/`);
}
if (target === '/') {
return current === '/';
}
return current === target || current.startsWith(`${target}/`);
};
export const getActiveNavItem = (