refactor: seperate history from queue
This commit is contained in:
parent
210712cee2
commit
e7dfee66b1
12 changed files with 4279 additions and 3418 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
184
ui/app/composables/useHistoryState.ts
Normal file
184
ui/app/composables/useHistoryState.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
243
ui/app/composables/useQueueState.ts
Normal file
243
ui/app/composables/useQueueState.ts
Normal 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;
|
||||
|
|
@ -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
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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
Loading…
Reference in a new issue