refactor: drop pinia stores
This commit is contained in:
parent
fc5cf84dd2
commit
210712cee2
35 changed files with 871 additions and 1421 deletions
|
|
@ -538,7 +538,7 @@ const props = defineProps<{
|
|||
const toast = useNotification();
|
||||
const showImport = useStorage('showImport', false);
|
||||
const box = useConfirm();
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
|
||||
const form = reactive<Condition>(normalizeCondition(props.item));
|
||||
const importString = ref('');
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ const props = defineProps<{
|
|||
|
||||
const toast = useNotification();
|
||||
const box = useConfirm();
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
|
||||
const fieldTypes = ['string', 'text', 'bool'] as const;
|
||||
const fieldTypeItems = [...fieldTypes];
|
||||
|
|
|
|||
|
|
@ -520,7 +520,7 @@ const emitter = defineEmits<{
|
|||
(e: 'getInfo', url: string, preset: string | undefined, cli: string | undefined): void;
|
||||
(e: 'clear_form'): void;
|
||||
}>();
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
const toast = useNotification();
|
||||
const dialog = useDialog();
|
||||
const { findPreset, hasPreset, selectItems, getPresetDefault } = usePresetOptions();
|
||||
|
|
|
|||
|
|
@ -138,9 +138,10 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import moment from 'moment';
|
||||
import { useNotificationCenter } from '~/composables/useNotificationCenter';
|
||||
import type { notificationType } from '~/composables/useNotification';
|
||||
|
||||
const store = useNotificationStore();
|
||||
const store = useNotificationCenter();
|
||||
|
||||
const copiedId = ref<string | null>(null);
|
||||
const expandedId = ref<string | null>(null);
|
||||
|
|
|
|||
|
|
@ -370,7 +370,7 @@ const props = defineProps<{
|
|||
presets?: Preset[];
|
||||
}>();
|
||||
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
const toast = useNotification();
|
||||
const dialog = useDialog();
|
||||
const { presets, findPreset, selectItems } = usePresetOptions(() => props.presets);
|
||||
|
|
|
|||
|
|
@ -275,7 +275,6 @@
|
|||
<script setup lang="ts">
|
||||
import { watch, onMounted, onBeforeUnmount, ref, computed } from 'vue';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { useConfigStore } from '~/stores/ConfigStore';
|
||||
import { useNotification } from '~/composables/useNotification';
|
||||
import type { notificationTarget, toastPosition } from '~/composables/useNotification';
|
||||
|
||||
|
|
@ -294,7 +293,7 @@ const props = withDefaults(
|
|||
|
||||
const emitter = defineEmits<{ (e: 'close' | 'reload_bg'): void }>();
|
||||
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
const notification = useNotification();
|
||||
|
||||
const bg_enable = useStorage<boolean>('random_bg', true);
|
||||
|
|
|
|||
|
|
@ -521,7 +521,7 @@ const emitter = defineEmits<{
|
|||
}>();
|
||||
|
||||
const toast = useNotification();
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
const dialog = useDialog();
|
||||
const { findPreset, getPresetDefault, selectItems } = usePresetOptions();
|
||||
const showImport = useStorage('showTaskImport', false);
|
||||
|
|
|
|||
|
|
@ -127,7 +127,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { request } from '~/utils';
|
||||
import { useConfigStore } from '~/stores/ConfigStore';
|
||||
import type { TaskInspectRequest, TaskInspectResponse } from '~/types/task_inspect';
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -138,7 +137,7 @@ const props = defineProps<{
|
|||
|
||||
const { selectItems } = usePresetOptions();
|
||||
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
const url = ref(props.url ?? '');
|
||||
const preset = ref(props.preset || config.app.default_preset || '');
|
||||
const handler = ref(props.handler ?? '');
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ import { useKeyboardShortcuts } from '~/composables/useKeyboardShortcuts';
|
|||
import type { StoreItem } from '~/types/store';
|
||||
import type { file_info, video_source_element, video_track_element } from '~/types/video';
|
||||
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
|
||||
const props = defineProps<{ item: StoreItem }>();
|
||||
const emitter = defineEmits<{
|
||||
|
|
|
|||
403
ui/app/composables/useAppSocket.ts
Normal file
403
ui/app/composables/useAppSocket.ts
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
import { proxyRefs, readonly, ref } from 'vue';
|
||||
import { useYtpConfig } from '~/composables/useYtpConfig';
|
||||
import { useQueueState } from '~/composables/useQueueState';
|
||||
import type { StoreItem } from '~/types/store';
|
||||
import type {
|
||||
ConfigUpdatePayload,
|
||||
WebSocketClientEmits,
|
||||
WebSocketEnvelope,
|
||||
WSEP as WSEP,
|
||||
} from '~/types/sockets';
|
||||
|
||||
export type connectionStatus = 'connected' | 'disconnected' | 'connecting';
|
||||
|
||||
type SocketHandler = (...args: unknown[]) => void;
|
||||
type HandlerRegistry = Map<SocketHandler, SocketHandler>;
|
||||
type KnownEvent = keyof WSEP;
|
||||
|
||||
const getRuntimeConfig = () => useRuntimeConfig();
|
||||
const getConfig = () => useYtpConfig();
|
||||
const getQueueState = () => useQueueState();
|
||||
const getToast = () => useNotification();
|
||||
|
||||
const socket = ref<WebSocket | null>(null);
|
||||
const isConnected = ref<boolean>(false);
|
||||
const connectionStatus = ref<connectionStatus>('disconnected');
|
||||
const error = ref<string | null>(null);
|
||||
const error_count = ref<number>(0);
|
||||
const wasHidden = ref<boolean>(false);
|
||||
const reconnectTimeout = ref<NodeJS.Timeout | null>(null);
|
||||
const manualDisconnect = ref<boolean>(false);
|
||||
const reconnectAttempts = ref<number>(0);
|
||||
|
||||
const handlers = new Map<string, HandlerRegistry>();
|
||||
|
||||
const emit = <K extends keyof WebSocketClientEmits>(
|
||||
event: K,
|
||||
data: WebSocketClientEmits[K],
|
||||
): void => {
|
||||
if (!socket.value || WebSocket.OPEN !== socket.value.readyState) {
|
||||
return;
|
||||
}
|
||||
socket.value.send(JSON.stringify({ event, data }));
|
||||
};
|
||||
|
||||
function on<K extends KnownEvent>(event: K | K[], callback: (payload: WSEP[K]) => void): void;
|
||||
function on<K extends KnownEvent>(
|
||||
event: K | K[],
|
||||
callback: (event: K, payload: WSEP[K]) => void,
|
||||
withEvent: true,
|
||||
): void;
|
||||
function on(event: string | string[], callback: SocketHandler, withEvent?: boolean): void;
|
||||
function on(event: string | string[], callback: SocketHandler, withEvent: boolean = false): void {
|
||||
const events = Array.isArray(event) ? event : [event];
|
||||
events.forEach((eventName) => {
|
||||
if (!handlers.has(eventName)) {
|
||||
handlers.set(eventName, new Map());
|
||||
}
|
||||
|
||||
const registry = handlers.get(eventName) as HandlerRegistry;
|
||||
const handler =
|
||||
true === withEvent
|
||||
? (payload: unknown) => callback(eventName, payload)
|
||||
: (payload: unknown) => callback(payload);
|
||||
|
||||
registry.set(callback, handler);
|
||||
});
|
||||
}
|
||||
|
||||
function off<K extends KnownEvent>(event: K | K[], callback?: (payload: WSEP[K]) => void): void;
|
||||
function off(event: string | string[], callback?: SocketHandler): void;
|
||||
function off(event: string | string[], callback?: SocketHandler): void {
|
||||
const events = Array.isArray(event) ? event : [event];
|
||||
events.forEach((eventName) => {
|
||||
const registry = handlers.get(eventName);
|
||||
if (!registry) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!callback) {
|
||||
registry.clear();
|
||||
handlers.delete(eventName);
|
||||
return;
|
||||
}
|
||||
|
||||
registry.delete(callback);
|
||||
if (0 === registry.size) {
|
||||
handlers.delete(eventName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const getSessionId = (): string | null => null;
|
||||
|
||||
const dispatch = (eventName: string, payload: unknown): void => {
|
||||
const registry = handlers.get(eventName);
|
||||
if (!registry) {
|
||||
return;
|
||||
}
|
||||
|
||||
registry.forEach((handler) => handler(payload));
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
wasHidden.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (true === wasHidden.value && false === isConnected.value) {
|
||||
if (null !== reconnectTimeout.value) {
|
||||
clearTimeout(reconnectTimeout.value);
|
||||
reconnectTimeout.value = null;
|
||||
}
|
||||
|
||||
reconnectTimeout.value = setTimeout(() => {
|
||||
if (false === isConnected.value) {
|
||||
console.debug('[SocketStore] Page visible after background, reconnecting...');
|
||||
reconnect();
|
||||
}
|
||||
reconnectTimeout.value = null;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
wasHidden.value = false;
|
||||
};
|
||||
|
||||
const setupVisibilityListener = () => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupVisibilityListener = () => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
if (null !== reconnectTimeout.value) {
|
||||
clearTimeout(reconnectTimeout.value);
|
||||
reconnectTimeout.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (true === manualDisconnect.value || true === isConnected.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reconnectAttempts.value >= 50) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== reconnectTimeout.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectTimeout.value = setTimeout(() => {
|
||||
reconnectAttempts.value += 1;
|
||||
reconnectTimeout.value = null;
|
||||
connect();
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const reconnect = () => {
|
||||
if (true === isConnected.value) {
|
||||
return;
|
||||
}
|
||||
connect();
|
||||
connectionStatus.value = 'connecting';
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
manualDisconnect.value = true;
|
||||
if (null === socket.value) {
|
||||
return;
|
||||
}
|
||||
socket.value.close();
|
||||
socket.value = null;
|
||||
isConnected.value = false;
|
||||
connectionStatus.value = 'disconnected';
|
||||
cleanupVisibilityListener();
|
||||
};
|
||||
|
||||
const buildWsUrl = (): string => {
|
||||
const runtimeConfig = getRuntimeConfig();
|
||||
const basePath = runtimeConfig.app.baseURL.replace(/\/$/, '');
|
||||
const wsPath = `${basePath}/ws?_=${Date.now()}`;
|
||||
const configuredBase = runtimeConfig.public.wss?.trim();
|
||||
|
||||
if (configuredBase) {
|
||||
return new URL(wsPath, configuredBase).toString();
|
||||
}
|
||||
|
||||
const scheme = 'https:' === window.location.protocol ? 'wss' : 'ws';
|
||||
return new URL(wsPath, `${scheme}://${window.location.host}`).toString();
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
const runtimeConfig = getRuntimeConfig();
|
||||
|
||||
if (socket.value && WebSocket.OPEN === socket.value.readyState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (socket.value && WebSocket.CONNECTING === socket.value.readyState) {
|
||||
return;
|
||||
}
|
||||
|
||||
manualDisconnect.value = false;
|
||||
connectionStatus.value = 'connecting';
|
||||
|
||||
socket.value = new WebSocket(buildWsUrl());
|
||||
|
||||
if ('development' === runtimeConfig.public?.APP_ENV) {
|
||||
window.ws = socket.value;
|
||||
}
|
||||
|
||||
socket.value.addEventListener('open', () => {
|
||||
isConnected.value = true;
|
||||
connectionStatus.value = 'connected';
|
||||
error.value = null;
|
||||
error_count.value = 0;
|
||||
reconnectAttempts.value = 0;
|
||||
dispatch('connect', null);
|
||||
});
|
||||
|
||||
socket.value.addEventListener('close', () => {
|
||||
isConnected.value = false;
|
||||
connectionStatus.value = 'disconnected';
|
||||
error.value = 'Disconnected from server.';
|
||||
dispatch('disconnect', null);
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
socket.value.addEventListener('error', () => {
|
||||
isConnected.value = false;
|
||||
connectionStatus.value = 'disconnected';
|
||||
error.value = 'Connection error: Unknown error';
|
||||
error_count.value += 1;
|
||||
dispatch('connect_error', { message: 'Unknown error' });
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
socket.value.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
let payload: WebSocketEnvelope | null = null;
|
||||
try {
|
||||
payload = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload?.event || 'string' != typeof payload.event) {
|
||||
return;
|
||||
}
|
||||
|
||||
let data = payload.data;
|
||||
if ('string' === typeof data) {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch {
|
||||
data = payload.data;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(payload.event, data);
|
||||
});
|
||||
|
||||
setupVisibilityListener();
|
||||
};
|
||||
|
||||
on('connect', () => getConfig().loadConfig(false));
|
||||
|
||||
on('connected', () => {
|
||||
error.value = null;
|
||||
getConfig().loadConfig(false);
|
||||
});
|
||||
|
||||
on('item_added', (data: WSEP['item_added']) => {
|
||||
const queueState = getQueueState();
|
||||
const toast = getToast();
|
||||
|
||||
queueState.add(data.data._id, data.data);
|
||||
toast.success(`Item queued: ${ag(queueState.get(data.data._id, {} as StoreItem), 'title')}`);
|
||||
});
|
||||
|
||||
on(
|
||||
['log_info', 'log_success', 'log_warning', 'log_error'],
|
||||
(event, data: WSEP['log_info']) => {
|
||||
const toast = getToast();
|
||||
const message =
|
||||
'string' === typeof data?.message
|
||||
? data.message
|
||||
: String((data?.data as Record<string, unknown>)?.message ?? '');
|
||||
const extra = (data?.data as Record<string, unknown>)?.data || data?.data || {};
|
||||
switch (event) {
|
||||
case 'log_info':
|
||||
toast.info(message, extra);
|
||||
break;
|
||||
case 'log_success':
|
||||
toast.success(message, extra);
|
||||
break;
|
||||
case 'log_warning':
|
||||
toast.warning(message, extra);
|
||||
break;
|
||||
case 'log_error':
|
||||
toast.error(message, extra);
|
||||
break;
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
on('item_cancelled', (data: WSEP['item_cancelled']) => {
|
||||
const queueState = getQueueState();
|
||||
const toast = getToast();
|
||||
const id = data.data._id;
|
||||
|
||||
if (true !== queueState.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.warning(`Download cancelled: ${ag(queueState.get(id, {} as StoreItem), 'title')}`);
|
||||
|
||||
if (true === queueState.has(id)) {
|
||||
queueState.remove(id);
|
||||
}
|
||||
});
|
||||
|
||||
on('item_deleted', (data: WSEP['item_deleted']) => {
|
||||
const queueState = getQueueState();
|
||||
const id = data.data._id;
|
||||
|
||||
if (true === queueState.has(id)) {
|
||||
queueState.remove(id);
|
||||
}
|
||||
});
|
||||
|
||||
on('item_updated', (data: WSEP['item_updated']) => {
|
||||
const queueState = getQueueState();
|
||||
const id = data.data._id;
|
||||
|
||||
if (true === queueState.has(id)) {
|
||||
queueState.update(id, data.data);
|
||||
}
|
||||
});
|
||||
|
||||
on('item_moved', (data: WSEP['item_moved']) => {
|
||||
const queueState = getQueueState();
|
||||
const to = data.data.to;
|
||||
const id = data.data.item._id;
|
||||
|
||||
if ('queue' === to) {
|
||||
queueState.add(id, data.data.item);
|
||||
}
|
||||
|
||||
if ('history' === to) {
|
||||
if (true === queueState.has(id)) {
|
||||
queueState.remove(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
on(
|
||||
['paused', 'resumed'],
|
||||
(event, data: WSEP['paused']) => {
|
||||
const config = getConfig();
|
||||
const toast = getToast();
|
||||
const pausedState = Boolean(data.data.paused);
|
||||
config.update('paused', pausedState);
|
||||
|
||||
if ('resumed' === event) {
|
||||
toast.success('Download queue resumed.');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.warning('Download queue paused.', { timeout: 10000 });
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
on('config_update', (data: WSEP['config_update']) => {
|
||||
const config = getConfig();
|
||||
const configUpdate = data.data as ConfigUpdatePayload;
|
||||
if (!configUpdate) {
|
||||
return;
|
||||
}
|
||||
config.patch(configUpdate.feature, configUpdate.action, configUpdate.data);
|
||||
});
|
||||
|
||||
const appSocketApi = proxyRefs({
|
||||
connect,
|
||||
reconnect,
|
||||
disconnect,
|
||||
on,
|
||||
off,
|
||||
emit,
|
||||
isConnected,
|
||||
getSessionId,
|
||||
connectionStatus: readonly(connectionStatus),
|
||||
error: readonly(error),
|
||||
error_count: readonly(error_count),
|
||||
});
|
||||
|
||||
export const useAppSocket = () => appSocketApi;
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { ref, readonly, computed, toRaw } from 'vue';
|
||||
|
||||
import { useNotification } from '~/composables/useNotification';
|
||||
import { useConfigStore } from '~/stores/ConfigStore';
|
||||
import { request, parse_api_error, sTrim, encodePath } from '~/utils';
|
||||
import type { FileItem, Pagination } from '~/types/filebrowser';
|
||||
|
||||
|
|
@ -50,7 +49,7 @@ const handleError = (error: unknown): void => {
|
|||
};
|
||||
|
||||
const buildQueryParams = (page?: number): string => {
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page ?? pagination.value.page));
|
||||
params.set('per_page', String(config.app.default_pagination || 50));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useStorage } from '@vueuse/core';
|
||||
import { useNotificationCenter } from '~/composables/useNotificationCenter';
|
||||
import { getNuxtToastManager } from '~/utils/nuxtToastManager';
|
||||
|
||||
export type notificationType = 'info' | 'success' | 'warning' | 'error';
|
||||
|
|
@ -70,7 +71,7 @@ const sendMessage = (
|
|||
message: string,
|
||||
opts?: notificationOptions,
|
||||
): void => {
|
||||
const notificationStore = useNotificationStore();
|
||||
const notificationStore = useNotificationCenter();
|
||||
|
||||
const useToastNotification =
|
||||
!window.isSecureContext ||
|
||||
|
|
@ -115,7 +116,7 @@ const sendMessage = (
|
|||
};
|
||||
|
||||
const notify = (type: notificationType, message: string, opts?: notificationOptions): void => {
|
||||
const notificationStore = useNotificationStore();
|
||||
const notificationStore = useNotificationCenter();
|
||||
|
||||
if (!opts) {
|
||||
opts = {};
|
||||
|
|
|
|||
115
ui/app/composables/useNotificationCenter.ts
Normal file
115
ui/app/composables/useNotificationCenter.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { computed, proxyRefs } from 'vue';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import type { notification, notificationType } from '~/composables/useNotification';
|
||||
|
||||
const NOTIFICATION_META: Record<notificationType, { level: number; color: string; icon: string }> =
|
||||
{
|
||||
error: { level: 3, color: 'error', icon: 'i-lucide-triangle-alert' },
|
||||
warning: { level: 2, color: 'warning', icon: 'i-lucide-circle-alert' },
|
||||
success: { level: 1, color: 'success', icon: 'i-lucide-badge-check' },
|
||||
info: { level: 0, color: 'info', icon: 'i-lucide-info' },
|
||||
};
|
||||
|
||||
const notifications = useStorage<notification[]>('notifications', []);
|
||||
|
||||
const unreadCount = computed<number>(() => notifications.value.filter((n) => !n.seen).length);
|
||||
|
||||
const severityLevel = computed<notificationType | null>(() => {
|
||||
const unread = notifications.value.filter((n) => !n.seen);
|
||||
|
||||
if (0 === unread.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return unread.reduce((highest, current) =>
|
||||
NOTIFICATION_META[current.level].level > NOTIFICATION_META[highest.level].level
|
||||
? current
|
||||
: highest,
|
||||
).level;
|
||||
});
|
||||
|
||||
const severityColor = computed<string>(() => {
|
||||
const level = severityLevel.value;
|
||||
return level ? NOTIFICATION_META[level].color : '';
|
||||
});
|
||||
|
||||
const severityIcon = computed<string>(() => {
|
||||
const level = severityLevel.value;
|
||||
return level ? NOTIFICATION_META[level].icon : '';
|
||||
});
|
||||
|
||||
const sortedNotifications = computed<notification[]>(() => {
|
||||
return [...notifications.value].sort((left, right) => {
|
||||
const severityDiff = NOTIFICATION_META[right.level].level - NOTIFICATION_META[left.level].level;
|
||||
|
||||
if (0 !== severityDiff) {
|
||||
return severityDiff;
|
||||
}
|
||||
|
||||
return new Date(right.created).getTime() - new Date(left.created).getTime();
|
||||
});
|
||||
});
|
||||
|
||||
const add = (level: notificationType, message: string, seen: boolean = false): string => {
|
||||
const id = Array.from(window.crypto.getRandomValues(new Uint8Array(14 / 2)), (dec: number) =>
|
||||
dec.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
|
||||
notifications.value.unshift({
|
||||
id,
|
||||
message,
|
||||
level,
|
||||
seen,
|
||||
created: new Date(),
|
||||
});
|
||||
|
||||
if (notifications.value.length > 99) {
|
||||
notifications.value.length = 99;
|
||||
}
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
const clear = (): void => {
|
||||
notifications.value = [];
|
||||
};
|
||||
|
||||
const markAllRead = (): void => {
|
||||
notifications.value.forEach((item) => {
|
||||
item.seen = true;
|
||||
});
|
||||
};
|
||||
|
||||
const markRead = (id: string): void => {
|
||||
const entry = notifications.value.find((item) => item.id === id);
|
||||
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
entry.seen = true;
|
||||
};
|
||||
|
||||
const get = (id: string): notification | undefined =>
|
||||
notifications.value.find((item) => item.id === id);
|
||||
|
||||
const remove = (id: string): void => {
|
||||
notifications.value = notifications.value.filter((item) => item.id !== id);
|
||||
};
|
||||
|
||||
const notificationCenterApi = proxyRefs({
|
||||
notifications,
|
||||
unreadCount,
|
||||
severityLevel,
|
||||
severityColor,
|
||||
severityIcon,
|
||||
sortedNotifications,
|
||||
add,
|
||||
get,
|
||||
markAllRead,
|
||||
clear,
|
||||
markRead,
|
||||
remove,
|
||||
});
|
||||
|
||||
export const useNotificationCenter = () => notificationCenterApi;
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
|
||||
|
||||
import { useYtpConfig } from '~/composables/useYtpConfig';
|
||||
import type { Preset } from '~/types/presets';
|
||||
import { prettyName } from '~/utils';
|
||||
|
||||
|
|
@ -19,7 +20,7 @@ export const usePresetOptions = (
|
|||
source?: MaybeRefOrGetter<Preset[] | readonly Preset[] | undefined>,
|
||||
options: UsePresetOptionsOptions = {},
|
||||
) => {
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
|
||||
const presets = computed<Preset[]>(() => {
|
||||
const items = source ? toValue(source) : config.presets;
|
||||
|
|
|
|||
219
ui/app/composables/useYtpConfig.ts
Normal file
219
ui/app/composables/useYtpConfig.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { proxyRefs, reactive, toRefs } from 'vue';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import type { ConfigState } from '~/types/config';
|
||||
import type { DLField } from '~/types/dl_fields';
|
||||
import type { Preset } from '~/types/presets';
|
||||
import type { ConfigFeature, ConfigUpdateAction } from '~/types/sockets';
|
||||
import { useQueueState } from '~/composables/useQueueState';
|
||||
import { request } from '~/utils';
|
||||
|
||||
let last_reload = 0;
|
||||
const CONFIG_TTL = 10;
|
||||
|
||||
const state = reactive<ConfigState>({
|
||||
showForm: useStorage('showForm', true),
|
||||
app: {
|
||||
download_path: '/downloads',
|
||||
remove_files: false,
|
||||
ui_update_title: true,
|
||||
output_template: '',
|
||||
ytdlp_version: '',
|
||||
max_workers: 20,
|
||||
max_workers_per_extractor: 2,
|
||||
default_preset: 'default',
|
||||
instance_title: null,
|
||||
console_enabled: false,
|
||||
browser_control_enabled: false,
|
||||
file_logging: false,
|
||||
is_native: false,
|
||||
app_version: '',
|
||||
app_commit_sha: '',
|
||||
app_build_date: '',
|
||||
app_branch: '',
|
||||
started: 0,
|
||||
app_env: 'production',
|
||||
simple_mode: false,
|
||||
default_pagination: 50,
|
||||
check_for_updates: true,
|
||||
new_version: '',
|
||||
yt_new_version: '',
|
||||
},
|
||||
presets: [
|
||||
{
|
||||
name: 'default',
|
||||
description: 'Default preset',
|
||||
folder: '',
|
||||
template: '',
|
||||
cookies: '',
|
||||
cli: '',
|
||||
default: true,
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
dl_fields: [],
|
||||
folders: [],
|
||||
ytdlp_options: [],
|
||||
paused: false,
|
||||
is_loaded: false,
|
||||
is_loading: false,
|
||||
});
|
||||
|
||||
const loadConfig = async (force: boolean = false) => {
|
||||
if (state.is_loading) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
|
||||
if (state.is_loaded && !force && last_reload > 0) {
|
||||
const age = (now - last_reload) / 1000;
|
||||
if (age < CONFIG_TTL) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
state.is_loaded = false;
|
||||
state.is_loading = true;
|
||||
try {
|
||||
const resp = await request('/api/system/configuration', { timeout: 10 });
|
||||
if (!resp.ok) {
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const queueState = useQueueState();
|
||||
|
||||
if ('number' === typeof data.history_count) {
|
||||
delete data.history_count;
|
||||
}
|
||||
|
||||
if (data.queue) {
|
||||
queueState.addAll(data.queue);
|
||||
delete data.queue;
|
||||
}
|
||||
|
||||
setAll(data);
|
||||
state.is_loaded = true;
|
||||
last_reload = now;
|
||||
} catch (e: any) {
|
||||
console.error(`Failed to load configuration: ${e}`);
|
||||
} finally {
|
||||
state.is_loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const add = (key: string, value: any) => {
|
||||
if (key.includes('.')) {
|
||||
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
|
||||
state[parentKey][subKey] = value;
|
||||
return;
|
||||
}
|
||||
(state as any)[key] = value;
|
||||
};
|
||||
|
||||
const get = (key: string, defaultValue: any = null): any => {
|
||||
if (key.includes('.')) {
|
||||
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
|
||||
const parent = state[parentKey] as any;
|
||||
return parent?.[subKey] ?? defaultValue;
|
||||
}
|
||||
return (state as any)[key] ?? defaultValue;
|
||||
};
|
||||
|
||||
const isLoaded = () => state.is_loaded;
|
||||
|
||||
const update = add;
|
||||
|
||||
const getAll = (): ConfigState => state;
|
||||
|
||||
const setAll = (data: Record<string, any>) => {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (key.includes('.')) {
|
||||
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
|
||||
const parent = state[parentKey] as any;
|
||||
parent[subKey] = data[key];
|
||||
return;
|
||||
}
|
||||
(state as any)[key] = data[key];
|
||||
});
|
||||
|
||||
state.is_loaded = true;
|
||||
};
|
||||
|
||||
const patch = (feature: ConfigFeature, action: ConfigUpdateAction, data: unknown): void => {
|
||||
const supportedFeatures: ConfigFeature[] = ['dl_fields', 'presets'];
|
||||
|
||||
if (!supportedFeatures.includes(feature)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('presets' === feature) {
|
||||
const item = data as Preset;
|
||||
const current = get(feature, []) as Array<Preset>;
|
||||
|
||||
if ('create' === action) {
|
||||
current.push(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if ('delete' === action) {
|
||||
const index = current.findIndex((i) => i.id === item.id);
|
||||
if (-1 !== index) {
|
||||
current.splice(index, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ('update' === action) {
|
||||
const target = current.find((i) => i.id === item.id);
|
||||
if (target) {
|
||||
Object.assign(target, item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ('dl_fields' === feature) {
|
||||
const item = data as DLField;
|
||||
const current = get(feature, []) as Array<DLField>;
|
||||
|
||||
if ('create' === action) {
|
||||
current.push(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if ('delete' === action) {
|
||||
const index = current.findIndex((i) => i.id === item.id);
|
||||
if (-1 !== index) {
|
||||
current.splice(index, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ('update' === action) {
|
||||
const target = current.find((i) => i.id === item.id);
|
||||
if (target) {
|
||||
Object.assign(target, item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ('replace' === action) {
|
||||
state.dl_fields = data as Array<DLField>;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ytpConfigApi = proxyRefs({
|
||||
...toRefs(state),
|
||||
add,
|
||||
get,
|
||||
update,
|
||||
getAll,
|
||||
setAll,
|
||||
isLoaded,
|
||||
patch,
|
||||
loadConfig,
|
||||
});
|
||||
|
||||
export const useYtpConfig = () => ytpConfigApi;
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
<template>
|
||||
<UApp :toaster="toasterConfig">
|
||||
<Transition name="shell-mode" mode="out-in">
|
||||
<div v-if="simpleMode" key="simple" class="shell-stage 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"
|
||||
|
|
@ -517,8 +521,8 @@ type SwipeMode = 'open' | 'close';
|
|||
const MOBILE_SIDEBAR_EDGE_WIDTH = 32;
|
||||
const MOBILE_SIDEBAR_MIN_SWIPE_DISTANCE = 64;
|
||||
|
||||
const socket = useSocketStore();
|
||||
const config = useConfigStore();
|
||||
const socket = useAppSocket();
|
||||
const config = useYtpConfig();
|
||||
const route = useRoute();
|
||||
const colorMode = useColorMode();
|
||||
const loadedImage = ref();
|
||||
|
|
|
|||
|
|
@ -153,12 +153,15 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<Pager
|
||||
<UPagination
|
||||
v-if="pagination.total_pages > 1"
|
||||
:page="pagination.page"
|
||||
:last_page="pagination.total_pages"
|
||||
:isLoading="isLoading"
|
||||
@navigate="handlePageChange"
|
||||
:total="pagination.total"
|
||||
:items-per-page="pagination.per_page"
|
||||
:disabled="isLoading"
|
||||
show-edges
|
||||
:sibling-count="0"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
|
||||
<div
|
||||
|
|
@ -481,12 +484,15 @@
|
|||
description="You can enable rename, delete, move, and create directory controls by setting YTP_BROWSER_CONTROL_ENABLED=true and restarting the application."
|
||||
/>
|
||||
|
||||
<Pager
|
||||
<UPagination
|
||||
v-if="pagination.total_pages > 1"
|
||||
:page="pagination.page"
|
||||
:last_page="pagination.total_pages"
|
||||
:isLoading="isLoading"
|
||||
@navigate="handlePageChange"
|
||||
:total="pagination.total"
|
||||
:items-per-page="pagination.per_page"
|
||||
:disabled="isLoading"
|
||||
show-edges
|
||||
:sibling-count="0"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
|
||||
<UModal
|
||||
|
|
@ -536,7 +542,7 @@ import { requirePageShell } from '~/utils/topLevelNavigation';
|
|||
|
||||
const route = useRoute();
|
||||
const toast = useNotification();
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
const dialog = useDialog();
|
||||
const browser = useBrowser();
|
||||
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ import { request, ucFirst, uri } from '~/utils';
|
|||
import { requirePageShell } from '~/utils/topLevelNavigation';
|
||||
|
||||
const toast = useNotification();
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
const pageShell = requirePageShell('changelog');
|
||||
|
||||
useHead({ title: 'CHANGELOG' });
|
||||
|
|
|
|||
|
|
@ -82,12 +82,15 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Pager
|
||||
<UPagination
|
||||
v-if="paging?.total_pages > 1"
|
||||
:page="paging.page"
|
||||
:last_page="paging.total_pages"
|
||||
:isLoading="isLoading"
|
||||
@navigate="navigatePage"
|
||||
:total="paging.total"
|
||||
:items-per-page="paging.per_page"
|
||||
:disabled="isLoading"
|
||||
show-edges
|
||||
:sibling-count="0"
|
||||
@update:page="navigatePage"
|
||||
/>
|
||||
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -338,7 +338,7 @@ let terminalResizeObserver: ResizeObserver | null = null;
|
|||
let didInitialRender = false;
|
||||
let renderedChunkCount = 0;
|
||||
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
const toast = useNotification();
|
||||
const dialog = useDialog();
|
||||
const consoleSession = useConsoleSession();
|
||||
|
|
|
|||
|
|
@ -82,12 +82,15 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Pager
|
||||
<UPagination
|
||||
v-if="paging?.total_pages > 1"
|
||||
:page="paging.page"
|
||||
:last_page="paging.total_pages"
|
||||
:isLoading="isLoading"
|
||||
@navigate="navigatePage"
|
||||
:total="paging.total"
|
||||
:items-per-page="paging.per_page"
|
||||
:disabled="isLoading"
|
||||
show-edges
|
||||
:sibling-count="0"
|
||||
@update:page="navigatePage"
|
||||
/>
|
||||
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ const FILTER_CONTEXT_REGEX = /context:(\d+)/;
|
|||
let scrollTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
const toast = useNotification();
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
const route = useRoute();
|
||||
const pageShell = requirePageShell('logs');
|
||||
|
||||
|
|
|
|||
|
|
@ -95,12 +95,15 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Pager
|
||||
<UPagination
|
||||
v-if="paging?.total_pages > 1"
|
||||
:page="paging.page"
|
||||
:last_page="paging.total_pages"
|
||||
:isLoading="isLoading"
|
||||
@navigate="navigatePage"
|
||||
:total="paging.total"
|
||||
:items-per-page="paging.per_page"
|
||||
:disabled="isLoading"
|
||||
show-edges
|
||||
:sibling-count="0"
|
||||
@update:page="navigatePage"
|
||||
/>
|
||||
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -468,7 +468,7 @@ import { requirePageShell } from '~/utils/topLevelNavigation';
|
|||
type PresetWithUI = Preset & { raw?: boolean; toggle_description?: boolean };
|
||||
|
||||
const presetsStore = usePresets();
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
const box = useConfirm();
|
||||
const editor = usePresetEditor();
|
||||
const pageShell = requirePageShell('presets');
|
||||
|
|
|
|||
|
|
@ -718,9 +718,9 @@ import { requirePageShell } from '~/utils/topLevelNavigation';
|
|||
|
||||
const box = useConfirm();
|
||||
const toast = useNotification();
|
||||
const config = useConfigStore();
|
||||
const socket = useSocketStore();
|
||||
const stateStore = useStateStore();
|
||||
const config = useYtpConfig();
|
||||
const socket = useAppSocket();
|
||||
const stateStore = useQueueState();
|
||||
const pageShell = requirePageShell('tasks');
|
||||
const { confirmDialog } = useDialog();
|
||||
const sessionCache = useSessionCache();
|
||||
|
|
|
|||
|
|
@ -1,227 +0,0 @@
|
|||
import { useStorage } from '@vueuse/core';
|
||||
import type { ConfigState } from '~/types/config';
|
||||
import type { DLField } from '~/types/dl_fields';
|
||||
import type { Preset } from '~/types/presets';
|
||||
import type { ConfigFeature, ConfigUpdateAction } from '~/types/sockets';
|
||||
import { request } from '~/utils';
|
||||
|
||||
let last_reload = 0;
|
||||
const CONFIG_TTL = 10;
|
||||
|
||||
export const useConfigStore = defineStore('config', () => {
|
||||
const state = reactive<ConfigState>({
|
||||
showForm: useStorage('showForm', true),
|
||||
app: {
|
||||
download_path: '/downloads',
|
||||
remove_files: false,
|
||||
ui_update_title: true,
|
||||
output_template: '',
|
||||
ytdlp_version: '',
|
||||
max_workers: 20,
|
||||
max_workers_per_extractor: 2,
|
||||
default_preset: 'default',
|
||||
instance_title: null,
|
||||
console_enabled: false,
|
||||
browser_control_enabled: false,
|
||||
file_logging: false,
|
||||
is_native: false,
|
||||
app_version: '',
|
||||
app_commit_sha: '',
|
||||
app_build_date: '',
|
||||
app_branch: '',
|
||||
started: 0,
|
||||
app_env: 'production',
|
||||
simple_mode: false,
|
||||
default_pagination: 50,
|
||||
check_for_updates: true,
|
||||
new_version: '',
|
||||
yt_new_version: '',
|
||||
},
|
||||
presets: [
|
||||
{
|
||||
name: 'default',
|
||||
description: 'Default preset',
|
||||
folder: '',
|
||||
template: '',
|
||||
cookies: '',
|
||||
cli: '',
|
||||
default: true,
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
dl_fields: [],
|
||||
folders: [],
|
||||
ytdlp_options: [],
|
||||
paused: false,
|
||||
is_loaded: false,
|
||||
is_loading: false,
|
||||
});
|
||||
|
||||
const loadConfig = async (force: boolean = false) => {
|
||||
if (state.is_loading) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
|
||||
if (state.is_loaded && !force && last_reload > 0) {
|
||||
const age = (now - last_reload) / 1000;
|
||||
if (age < CONFIG_TTL) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
state.is_loaded = false;
|
||||
state.is_loading = true;
|
||||
try {
|
||||
const resp = await request('/api/system/configuration', { timeout: 10 });
|
||||
if (!resp.ok) {
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const stateStore = useStateStore();
|
||||
|
||||
if ('number' === typeof data.history_count) {
|
||||
stateStore.setHistoryCount(data.history_count);
|
||||
delete data.history_count;
|
||||
}
|
||||
|
||||
if (data.queue) {
|
||||
stateStore.addAll('queue', data.queue);
|
||||
delete data.queue;
|
||||
}
|
||||
|
||||
setAll(data);
|
||||
state.is_loaded = true;
|
||||
last_reload = now;
|
||||
} catch (e: any) {
|
||||
console.error(`Failed to load configuration: ${e}`);
|
||||
} finally {
|
||||
state.is_loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const add = (key: string, value: any) => {
|
||||
if (key.includes('.')) {
|
||||
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
|
||||
state[parentKey][subKey] = value;
|
||||
return;
|
||||
}
|
||||
(state as any)[key] = value;
|
||||
};
|
||||
|
||||
const get = (key: string, defaultValue: any = null): any => {
|
||||
if (key.includes('.')) {
|
||||
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
|
||||
const parent = state[parentKey] as any;
|
||||
return parent?.[subKey] ?? defaultValue;
|
||||
}
|
||||
return (state as any)[key] ?? defaultValue;
|
||||
};
|
||||
const isLoaded = () => state.is_loaded;
|
||||
|
||||
const update = add;
|
||||
|
||||
const getAll = (): ConfigState => state;
|
||||
|
||||
const setAll = (data: Record<string, any>) => {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (key.includes('.')) {
|
||||
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
|
||||
const parent = state[parentKey] as any;
|
||||
parent[subKey] = data[key];
|
||||
return;
|
||||
}
|
||||
(state as any)[key] = data[key];
|
||||
});
|
||||
|
||||
state.is_loaded = true;
|
||||
};
|
||||
|
||||
const patch = (feature: ConfigFeature, action: ConfigUpdateAction, data: unknown): void => {
|
||||
const supportedFeatures: ConfigFeature[] = ['dl_fields', 'presets'];
|
||||
|
||||
if (!supportedFeatures.includes(feature)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('presets' === feature) {
|
||||
const item = data as Preset;
|
||||
const current = get(feature, []) as Array<Preset>;
|
||||
|
||||
if ('create' === action) {
|
||||
current.push(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if ('delete' === action) {
|
||||
const index = current.findIndex((i) => i.id === item.id);
|
||||
if (-1 !== index) {
|
||||
current.splice(index, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ('update' === action) {
|
||||
const target = current.find((i) => i.id === item.id);
|
||||
if (target) {
|
||||
Object.assign(target, item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ('dl_fields' === feature) {
|
||||
const item = data as DLField;
|
||||
const current = get(feature, []) as Array<DLField>;
|
||||
|
||||
if ('create' === action) {
|
||||
current.push(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if ('delete' === action) {
|
||||
const index = current.findIndex((i) => i.id === item.id);
|
||||
if (-1 !== index) {
|
||||
current.splice(index, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ('update' === action) {
|
||||
const target = current.find((i) => i.id === item.id);
|
||||
if (target) {
|
||||
Object.assign(target, item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ('replace' === action) {
|
||||
state.dl_fields = data as Array<DLField>;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
add,
|
||||
get,
|
||||
update,
|
||||
getAll,
|
||||
setAll,
|
||||
isLoaded,
|
||||
patch,
|
||||
loadConfig,
|
||||
} as { [K in keyof ConfigState]: Ref<ConfigState[K]> } & {
|
||||
add: typeof add;
|
||||
get: typeof get;
|
||||
update: typeof update;
|
||||
getAll: typeof getAll;
|
||||
setAll: typeof setAll;
|
||||
patch: typeof patch;
|
||||
isLoaded: typeof isLoaded;
|
||||
loadConfig: typeof loadConfig;
|
||||
};
|
||||
});
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
import { defineStore } from 'pinia';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import type { notification, notificationType } from '~/composables/useNotification';
|
||||
|
||||
const _map: Record<notificationType, { level: number; color: string; icon: string }> = {
|
||||
error: { level: 3, color: 'error', icon: 'i-lucide-triangle-alert' },
|
||||
warning: { level: 2, color: 'warning', icon: 'i-lucide-circle-alert' },
|
||||
success: { level: 1, color: 'success', icon: 'i-lucide-badge-check' },
|
||||
info: { level: 0, color: 'info', icon: 'i-lucide-info' },
|
||||
};
|
||||
|
||||
export const useNotificationStore = defineStore('notifications', () => {
|
||||
const notifications = useStorage<notification[]>('notifications', []);
|
||||
const unreadCount = computed<number>(() => notifications.value.filter((n) => !n.seen).length);
|
||||
|
||||
const severityLevel = computed<notificationType | null>(() => {
|
||||
const unread = notifications.value.filter((n) => !n.seen);
|
||||
if (0 === unread.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return unread.reduce((h, n) => (_map[n.level].level > _map[h.level].level ? n : h)).level;
|
||||
});
|
||||
|
||||
const severityColor = computed<string>(() => {
|
||||
const level = severityLevel.value;
|
||||
return level ? _map[level].color : '';
|
||||
});
|
||||
|
||||
const severityIcon = computed<string>(() => {
|
||||
const level = severityLevel.value;
|
||||
return level ? _map[level].icon : '';
|
||||
});
|
||||
|
||||
const sortedNotifications = computed<notification[]>(() => {
|
||||
return [...notifications.value].sort((a, b) => {
|
||||
const severityDiff = _map[b.level].level - _map[a.level].level;
|
||||
if (0 !== severityDiff) {
|
||||
return severityDiff;
|
||||
}
|
||||
return new Date(b.created).getTime() - new Date(a.created).getTime();
|
||||
});
|
||||
});
|
||||
|
||||
const add = (level: notificationType, message: string, seen: boolean = false): string => {
|
||||
const id = Array.from(window.crypto.getRandomValues(new Uint8Array(14 / 2)), (dec: any) =>
|
||||
dec.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
|
||||
notifications.value.unshift({
|
||||
id: id,
|
||||
message,
|
||||
level,
|
||||
seen: seen,
|
||||
created: new Date(),
|
||||
});
|
||||
|
||||
if (notifications.value.length > 99) {
|
||||
notifications.value.length = 99;
|
||||
}
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
const clear = () => (notifications.value = []);
|
||||
const markAllRead = () => notifications.value.forEach((n) => (n.seen = true));
|
||||
|
||||
const markRead = (id: string) => {
|
||||
const n = notifications.value.find((n) => n.id === id);
|
||||
if (!n) {
|
||||
return;
|
||||
}
|
||||
n.seen = true;
|
||||
};
|
||||
|
||||
const get = (id: string): notification | undefined =>
|
||||
notifications.value.find((n) => n.id === id);
|
||||
|
||||
const remove = (id: string) =>
|
||||
(notifications.value = notifications.value.filter((n) => n.id !== id));
|
||||
|
||||
return {
|
||||
notifications,
|
||||
unreadCount,
|
||||
severityLevel,
|
||||
severityColor,
|
||||
severityIcon,
|
||||
sortedNotifications,
|
||||
add,
|
||||
get,
|
||||
markAllRead,
|
||||
clear,
|
||||
markRead,
|
||||
remove,
|
||||
};
|
||||
});
|
||||
|
|
@ -1,404 +0,0 @@
|
|||
import { ref, readonly } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import type { StoreItem } from '~/types/store';
|
||||
import type {
|
||||
ConfigUpdatePayload,
|
||||
WebSocketClientEmits,
|
||||
WebSocketEnvelope,
|
||||
WSEP as WSEP,
|
||||
} from '~/types/sockets';
|
||||
|
||||
export type connectionStatus = 'connected' | 'disconnected' | 'connecting';
|
||||
|
||||
type SocketHandler = (...args: unknown[]) => void;
|
||||
type HandlerRegistry = Map<SocketHandler, SocketHandler>;
|
||||
type KnownEvent = keyof WSEP;
|
||||
|
||||
export const useSocketStore = defineStore('socket', () => {
|
||||
const runtimeConfig = useRuntimeConfig();
|
||||
const config = useConfigStore();
|
||||
const stateStore = useStateStore();
|
||||
const toast = useNotification();
|
||||
|
||||
const socket = ref<WebSocket | null>(null);
|
||||
const isConnected = ref<boolean>(false);
|
||||
const connectionStatus = ref<connectionStatus>('disconnected');
|
||||
const error = ref<string | null>(null);
|
||||
const error_count = ref<number>(0);
|
||||
const wasHidden = ref<boolean>(false);
|
||||
const reconnectTimeout = ref<NodeJS.Timeout | null>(null);
|
||||
const manualDisconnect = ref<boolean>(false);
|
||||
const reconnectAttempts = ref<number>(0);
|
||||
|
||||
const handlers = new Map<string, HandlerRegistry>();
|
||||
|
||||
const emit = <K extends keyof WebSocketClientEmits>(
|
||||
event: K,
|
||||
data: WebSocketClientEmits[K],
|
||||
): void => {
|
||||
if (!socket.value || WebSocket.OPEN !== socket.value.readyState) {
|
||||
return;
|
||||
}
|
||||
socket.value.send(JSON.stringify({ event, data }));
|
||||
};
|
||||
|
||||
function on<K extends KnownEvent>(event: K, callback: (payload: WSEP[K]) => void): void;
|
||||
function on<K extends KnownEvent>(event: K[], callback: (payload: WSEP[K]) => void): void;
|
||||
function on<K extends KnownEvent>(
|
||||
event: K | K[],
|
||||
callback: (event: K, payload: WSEP[K]) => void,
|
||||
withEvent: true,
|
||||
): void;
|
||||
function on(event: string | string[], callback: SocketHandler, withEvent?: boolean): void;
|
||||
function on(event: string | string[], callback: SocketHandler, withEvent: boolean = false): void {
|
||||
const events = Array.isArray(event) ? event : [event];
|
||||
events.forEach((eventName) => {
|
||||
if (!handlers.has(eventName)) {
|
||||
handlers.set(eventName, new Map());
|
||||
}
|
||||
|
||||
const registry = handlers.get(eventName) as HandlerRegistry;
|
||||
const handler =
|
||||
true === withEvent
|
||||
? (payload: unknown) => callback(eventName, payload)
|
||||
: (payload: unknown) => callback(payload);
|
||||
|
||||
registry.set(callback, handler);
|
||||
});
|
||||
}
|
||||
|
||||
function off<K extends KnownEvent>(event: K, callback?: (payload: WSEP[K]) => void): void;
|
||||
function off<K extends KnownEvent>(event: K[], callback?: (payload: WSEP[K]) => void): void;
|
||||
function off(event: string | string[], callback?: SocketHandler): void;
|
||||
function off(event: string | string[], callback?: SocketHandler): void {
|
||||
const events = Array.isArray(event) ? event : [event];
|
||||
events.forEach((eventName) => {
|
||||
const registry = handlers.get(eventName);
|
||||
if (!registry) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!callback) {
|
||||
registry.clear();
|
||||
handlers.delete(eventName);
|
||||
return;
|
||||
}
|
||||
|
||||
registry.delete(callback);
|
||||
if (0 === registry.size) {
|
||||
handlers.delete(eventName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const getSessionId = (): string | null => null;
|
||||
|
||||
const dispatch = (eventName: string, payload: unknown): void => {
|
||||
const registry = handlers.get(eventName);
|
||||
if (!registry) {
|
||||
return;
|
||||
}
|
||||
|
||||
registry.forEach((handler) => handler(payload));
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
wasHidden.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (true === wasHidden.value && false === isConnected.value) {
|
||||
if (null !== reconnectTimeout.value) {
|
||||
clearTimeout(reconnectTimeout.value);
|
||||
reconnectTimeout.value = null;
|
||||
}
|
||||
|
||||
reconnectTimeout.value = setTimeout(() => {
|
||||
if (false === isConnected.value) {
|
||||
console.debug('[SocketStore] Page visible after background, reconnecting...');
|
||||
reconnect();
|
||||
}
|
||||
reconnectTimeout.value = null;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
wasHidden.value = false;
|
||||
};
|
||||
|
||||
const setupVisibilityListener = () => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupVisibilityListener = () => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
if (null !== reconnectTimeout.value) {
|
||||
clearTimeout(reconnectTimeout.value);
|
||||
reconnectTimeout.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (true === manualDisconnect.value || true === isConnected.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reconnectAttempts.value >= 50) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== reconnectTimeout.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectTimeout.value = setTimeout(() => {
|
||||
reconnectAttempts.value += 1;
|
||||
reconnectTimeout.value = null;
|
||||
connect();
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const reconnect = () => {
|
||||
if (true === isConnected.value) {
|
||||
return;
|
||||
}
|
||||
connect();
|
||||
connectionStatus.value = 'connecting';
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
manualDisconnect.value = true;
|
||||
if (null === socket.value) {
|
||||
return;
|
||||
}
|
||||
socket.value.close();
|
||||
socket.value = null;
|
||||
isConnected.value = false;
|
||||
connectionStatus.value = 'disconnected';
|
||||
cleanupVisibilityListener();
|
||||
};
|
||||
|
||||
const buildWsUrl = (): string => {
|
||||
const basePath = runtimeConfig.app.baseURL.replace(/\/$/, '');
|
||||
const wsPath = `${basePath}/ws?_=${Date.now()}`;
|
||||
const configuredBase = runtimeConfig.public.wss?.trim();
|
||||
|
||||
if (configuredBase) {
|
||||
return new URL(wsPath, configuredBase).toString();
|
||||
}
|
||||
|
||||
const scheme = 'https:' === window.location.protocol ? 'wss' : 'ws';
|
||||
return new URL(wsPath, `${scheme}://${window.location.host}`).toString();
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (socket.value && WebSocket.OPEN === socket.value.readyState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (socket.value && WebSocket.CONNECTING === socket.value.readyState) {
|
||||
return;
|
||||
}
|
||||
|
||||
manualDisconnect.value = false;
|
||||
connectionStatus.value = 'connecting';
|
||||
|
||||
socket.value = new WebSocket(buildWsUrl());
|
||||
|
||||
if ('development' === runtimeConfig.public?.APP_ENV) {
|
||||
window.ws = socket.value;
|
||||
}
|
||||
|
||||
socket.value.addEventListener('open', () => {
|
||||
isConnected.value = true;
|
||||
connectionStatus.value = 'connected';
|
||||
error.value = null;
|
||||
error_count.value = 0;
|
||||
reconnectAttempts.value = 0;
|
||||
dispatch('connect', null);
|
||||
});
|
||||
|
||||
socket.value.addEventListener('close', () => {
|
||||
isConnected.value = false;
|
||||
connectionStatus.value = 'disconnected';
|
||||
error.value = 'Disconnected from server.';
|
||||
dispatch('disconnect', null);
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
socket.value.addEventListener('error', () => {
|
||||
isConnected.value = false;
|
||||
connectionStatus.value = 'disconnected';
|
||||
error.value = 'Connection error: Unknown error';
|
||||
error_count.value += 1;
|
||||
dispatch('connect_error', { message: 'Unknown error' });
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
socket.value.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
let payload: WebSocketEnvelope | null = null;
|
||||
try {
|
||||
payload = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload?.event || 'string' != typeof payload.event) {
|
||||
return;
|
||||
}
|
||||
|
||||
let data = payload.data;
|
||||
if ('string' === typeof data) {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch {
|
||||
data = payload.data;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(payload.event, data);
|
||||
});
|
||||
|
||||
setupVisibilityListener();
|
||||
};
|
||||
|
||||
on('connect', () => config.loadConfig(false));
|
||||
|
||||
on('connected', () => {
|
||||
error.value = null;
|
||||
config.loadConfig(false);
|
||||
});
|
||||
|
||||
on('item_added', (data: WSEP['item_added']) => {
|
||||
stateStore.add('queue', data.data._id, data.data);
|
||||
toast.success(
|
||||
`Item queued: ${ag(stateStore.get('queue', data.data._id, {} as StoreItem), 'title')}`,
|
||||
);
|
||||
});
|
||||
|
||||
on(
|
||||
['log_info', 'log_success', 'log_warning', 'log_error'],
|
||||
(event, data: WSEP['log_info']) => {
|
||||
const message =
|
||||
'string' === typeof data?.message
|
||||
? data.message
|
||||
: String((data?.data as Record<string, unknown>)?.message ?? '');
|
||||
const extra = (data?.data as Record<string, unknown>)?.data || data?.data || {};
|
||||
switch (event) {
|
||||
case 'log_info':
|
||||
toast.info(message, extra);
|
||||
break;
|
||||
case 'log_success':
|
||||
toast.success(message, extra);
|
||||
break;
|
||||
case 'log_warning':
|
||||
toast.warning(message, extra);
|
||||
break;
|
||||
case 'log_error':
|
||||
toast.error(message, extra);
|
||||
break;
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
on('item_cancelled', (data: WSEP['item_cancelled']) => {
|
||||
const id = data.data._id;
|
||||
|
||||
if (true !== stateStore.has('queue', id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.warning(
|
||||
`Download cancelled: ${ag(stateStore.get('queue', id, {} as StoreItem), 'title')}`,
|
||||
);
|
||||
|
||||
if (true === stateStore.has('queue', id)) {
|
||||
stateStore.remove('queue', id);
|
||||
}
|
||||
});
|
||||
|
||||
on('item_deleted', (data: WSEP['item_deleted']) => {
|
||||
const id = data.data._id;
|
||||
|
||||
if (true !== stateStore.has('history', id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
stateStore.remove('history', id);
|
||||
});
|
||||
|
||||
on('item_updated', (data: WSEP['item_updated']) => {
|
||||
const id = data.data._id;
|
||||
|
||||
if (true === stateStore.has('history', id)) {
|
||||
stateStore.update('history', id, data.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (true === stateStore.has('queue', id)) {
|
||||
stateStore.update('queue', id, data.data);
|
||||
}
|
||||
});
|
||||
|
||||
on('item_moved', (data: WSEP['item_moved']) => {
|
||||
const to = data.data.to;
|
||||
const id = data.data.item._id;
|
||||
|
||||
if ('queue' === to) {
|
||||
if (true === stateStore.has('history', id)) {
|
||||
stateStore.remove('history', id);
|
||||
}
|
||||
stateStore.add('queue', id, data.data.item);
|
||||
}
|
||||
|
||||
if ('history' === to) {
|
||||
if (true === stateStore.has('queue', id)) {
|
||||
stateStore.remove('queue', id);
|
||||
}
|
||||
stateStore.add('history', id, data.data.item);
|
||||
}
|
||||
});
|
||||
|
||||
on(
|
||||
['paused', 'resumed'],
|
||||
(event, data: WSEP['paused']) => {
|
||||
const pausedState = Boolean(data.data.paused);
|
||||
config.update('paused', pausedState);
|
||||
|
||||
if ('resumed' === event) {
|
||||
toast.success('Download queue resumed.');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.warning('Download queue paused.', { timeout: 10000 });
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
on('config_update', (data: WSEP['config_update']) => {
|
||||
const configUpdate = data.data as ConfigUpdatePayload;
|
||||
if (!configUpdate) {
|
||||
return;
|
||||
}
|
||||
config.patch(configUpdate.feature, configUpdate.action, configUpdate.data);
|
||||
});
|
||||
|
||||
return {
|
||||
connect,
|
||||
reconnect,
|
||||
disconnect,
|
||||
on,
|
||||
off,
|
||||
emit,
|
||||
isConnected,
|
||||
getSessionId,
|
||||
connectionStatus: readonly(connectionStatus) as Readonly<Ref<connectionStatus>>,
|
||||
error: readonly(error) as Readonly<Ref<string | null>>,
|
||||
error_count: readonly(error_count) as Readonly<Ref<number>>,
|
||||
};
|
||||
});
|
||||
|
|
@ -1,531 +0,0 @@
|
|||
import { defineStore } from 'pinia';
|
||||
import type { item_request } from '~/types/item';
|
||||
import type { StoreItem } from '~/types/store';
|
||||
import { request } from '~/utils';
|
||||
|
||||
type StateType = 'queue' | 'history';
|
||||
type KeyType = string;
|
||||
|
||||
interface State {
|
||||
queue: Record<KeyType, StoreItem>;
|
||||
history: Record<KeyType, StoreItem>;
|
||||
pagination: {
|
||||
page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
isLoaded: boolean;
|
||||
isLoading: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const useStateStore = defineStore('state', () => {
|
||||
const state = reactive<State>({
|
||||
queue: {},
|
||||
history: {},
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 50,
|
||||
total: 0,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
isLoaded: false,
|
||||
isLoading: false,
|
||||
},
|
||||
});
|
||||
|
||||
const add = (type: StateType, key: KeyType, value: StoreItem): void => {
|
||||
if ('history' === type && state.pagination.total > 0) {
|
||||
state.pagination.total += 1;
|
||||
}
|
||||
state[type][key] = value;
|
||||
};
|
||||
|
||||
const update = (type: StateType, key: KeyType, value: StoreItem): void => {
|
||||
state[type][key] = value;
|
||||
};
|
||||
|
||||
const remove = (type: StateType, key: KeyType): void => {
|
||||
if (!state[type][key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('history' === type && state.pagination.total > 0) {
|
||||
state.pagination.total -= 1;
|
||||
}
|
||||
|
||||
const { [key]: _, ...rest } = state[type];
|
||||
state[type] = rest;
|
||||
};
|
||||
|
||||
const get = (
|
||||
type: StateType,
|
||||
key: KeyType,
|
||||
defaultValue: StoreItem | null = null,
|
||||
): StoreItem | null => {
|
||||
return state[type][key] || defaultValue;
|
||||
};
|
||||
|
||||
const has = (type: StateType, key: KeyType): boolean => {
|
||||
return !!state[type][key];
|
||||
};
|
||||
|
||||
const clearAll = (type: StateType): void => {
|
||||
state[type] = {};
|
||||
if ('queue' === type) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.pagination.total = 0;
|
||||
state.pagination.page = 1;
|
||||
state.pagination.total_pages = 0;
|
||||
state.pagination.has_next = false;
|
||||
state.pagination.has_prev = false;
|
||||
};
|
||||
|
||||
const addAll = (type: StateType, data: Record<KeyType, StoreItem>): void => {
|
||||
state[type] = data;
|
||||
};
|
||||
|
||||
const move = (fromType: StateType, toType: StateType, key: KeyType): void => {
|
||||
if (true === has(fromType, key)) {
|
||||
remove(fromType, key);
|
||||
}
|
||||
|
||||
add(toType, key, get(fromType, key, {} as StoreItem) as StoreItem);
|
||||
};
|
||||
|
||||
const count = (type: StateType): number => {
|
||||
if ('history' === type && state.pagination.total > 0) {
|
||||
return state.pagination.total;
|
||||
}
|
||||
return Object.keys(state[type]).length;
|
||||
};
|
||||
|
||||
const loadPaginated = async (
|
||||
type: StateType,
|
||||
page: number = 1,
|
||||
per_page: number = 50,
|
||||
order: 'ASC' | 'DESC' = 'DESC',
|
||||
append: boolean = false,
|
||||
status?: string,
|
||||
): Promise<void> => {
|
||||
if ('history' !== type) {
|
||||
throw new Error('Pagination is only supported for history type');
|
||||
}
|
||||
|
||||
state.pagination.isLoading = true;
|
||||
|
||||
try {
|
||||
const params: Record<string, string> = {
|
||||
type: 'done',
|
||||
page: page.toString(),
|
||||
per_page: per_page.toString(),
|
||||
order,
|
||||
};
|
||||
|
||||
if (status) {
|
||||
params.status = status;
|
||||
}
|
||||
|
||||
const search = new URLSearchParams(params);
|
||||
|
||||
const response = await request(`/api/history?${search}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.pagination) {
|
||||
state.pagination = { ...data.pagination, isLoaded: true, isLoading: false };
|
||||
const items: Record<KeyType, StoreItem> = {};
|
||||
for (const item of data.items || []) {
|
||||
items[item._id] = item;
|
||||
}
|
||||
|
||||
state[type] = append ? { ...state[type], ...items } : items;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load ${type} page ${page}:`, error);
|
||||
state.pagination.isLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadNextPage = async (type: StateType, append: boolean = false): Promise<void> => {
|
||||
if ('history' !== type) {
|
||||
throw new Error('Pagination is only supported for history type');
|
||||
}
|
||||
|
||||
if (!state.pagination.has_next || state.pagination.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
await loadPaginated(type, state.pagination.page + 1, state.pagination.per_page, 'DESC', append);
|
||||
};
|
||||
|
||||
const loadPreviousPage = async (type: StateType): Promise<void> => {
|
||||
if ('history' !== type) {
|
||||
throw new Error('Pagination is only supported for history type');
|
||||
}
|
||||
|
||||
if (!state.pagination.has_prev || state.pagination.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
await loadPaginated(type, state.pagination.page - 1, state.pagination.per_page);
|
||||
};
|
||||
|
||||
const reloadCurrentPage = async (type: StateType): Promise<void> => {
|
||||
if ('history' !== type) {
|
||||
throw new Error('Pagination is only supported for history type');
|
||||
}
|
||||
if (!state.pagination.isLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
await loadPaginated(type, state.pagination.page, state.pagination.per_page);
|
||||
};
|
||||
|
||||
const getPagination = () => state.pagination;
|
||||
|
||||
const setHistoryCount = (count: number) => {
|
||||
state.pagination.total = count;
|
||||
if (count > 0 && !state.pagination.isLoaded) {
|
||||
state.pagination.isLoaded = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Load queue data from REST API.
|
||||
* Uses the /live endpoint to get real-time in-memory data with live progress.
|
||||
*
|
||||
* @returns Promise that resolves when queue is loaded
|
||||
*/
|
||||
const loadQueue = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await request('/api/history/live');
|
||||
const data = (await response.json()) as {
|
||||
queue: Record<KeyType, StoreItem>;
|
||||
history_count: number;
|
||||
};
|
||||
|
||||
state.queue = data.queue || {};
|
||||
setHistoryCount(data.history_count);
|
||||
} catch (error) {
|
||||
console.error('Failed to load queue:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a download using WebSocket if connected, fallback to REST API.
|
||||
*
|
||||
* @param data - Download data (url, preset, folder, etc.)
|
||||
* @returns Promise that resolves when download is added
|
||||
*/
|
||||
const addDownload = async (data: item_request): Promise<void> => {
|
||||
const socket = useSocketStore();
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Start one or more downloads using WebSocket if connected, fallback to REST API.
|
||||
*
|
||||
* @param ids - Array of item IDs to start
|
||||
* @returns Promise that resolves when items are started
|
||||
*/
|
||||
const startItems = async (ids: string[]): Promise<void> => {
|
||||
const socket = useSocketStore();
|
||||
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('queue', id);
|
||||
if (item) {
|
||||
update('queue', 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;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Pause one or more downloads using WebSocket if connected, fallback to REST API.
|
||||
*
|
||||
* @param ids - Array of item IDs to pause
|
||||
* @returns Promise that resolves when items are paused
|
||||
*/
|
||||
const pauseItems = async (ids: string[]): Promise<void> => {
|
||||
const socket = useSocketStore();
|
||||
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('queue', id);
|
||||
if (item) {
|
||||
update('queue', 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;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancel one or more downloads using WebSocket if connected, fallback to REST API.
|
||||
*
|
||||
* @param ids - Array of item IDs to cancel
|
||||
* @returns Promise that resolves when items are cancelled
|
||||
*/
|
||||
const cancelItems = async (ids: string[]): Promise<void> => {
|
||||
const socket = useSocketStore();
|
||||
const toast = useNotification();
|
||||
|
||||
if (socket.isConnected) {
|
||||
ids.forEach((id) => socket.emit('item_cancel', id));
|
||||
return;
|
||||
}
|
||||
|
||||
const itemsToMove: Record<string, StoreItem> = {};
|
||||
for (const id of ids) {
|
||||
const item = get('queue', id);
|
||||
if (item) {
|
||||
itemsToMove[id] = { ...item };
|
||||
}
|
||||
}
|
||||
|
||||
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] && itemsToMove[id]) {
|
||||
remove('queue', id);
|
||||
const cancelledItem = { ...itemsToMove[id], status: 'cancelled' } as StoreItem;
|
||||
add('history', id, cancelledItem);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove items using WebSocket if connected, fallback to REST API.
|
||||
*
|
||||
* @param type - The store type ('queue' or 'history')
|
||||
* @param ids - Array of item IDs to remove
|
||||
* @param removeFile - Whether to remove files from disk (default: false)
|
||||
* @returns Promise that resolves when items are removed
|
||||
*/
|
||||
const removeItems = async (
|
||||
type: StateType,
|
||||
ids: string[],
|
||||
removeFile: boolean = false,
|
||||
): Promise<void> => {
|
||||
const socket = useSocketStore();
|
||||
const toast = useNotification();
|
||||
|
||||
if (socket.isConnected) {
|
||||
ids.forEach((id) => socket.emit('item_delete', { id, remove_file: removeFile }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteItems(type, { ids, removeFile });
|
||||
} catch (error) {
|
||||
console.error('Failed to remove items:', error);
|
||||
toast.error('Failed to remove items');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete items by specific IDs or status filter.
|
||||
*
|
||||
* @param type - The store type ('queue' or 'history')
|
||||
* @param options - Delete options
|
||||
* @param options.ids - Array of item IDs to delete (if provided, status is ignored)
|
||||
* @param options.status - Status filter (e.g., 'finished' or '!finished')
|
||||
* @param options.removeFile - Whether to remove files from disk (default: true)
|
||||
*
|
||||
* @returns Number of items deleted
|
||||
*/
|
||||
const deleteItems = async (
|
||||
type: StateType,
|
||||
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 body: Record<string, unknown> = {
|
||||
type: type === 'queue' ? 'queue' : 'done',
|
||||
remove_file: removeFile,
|
||||
};
|
||||
|
||||
if (ids) {
|
||||
body.ids = ids;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
body.status = status;
|
||||
}
|
||||
|
||||
const response = await request('/api/history', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const result = (await response.json()) as {
|
||||
items: Record<string, string>;
|
||||
deleted: number;
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (result.error || result.message || !response.ok) {
|
||||
throw new Error(result.error || result.message || 'Failed to delete items.');
|
||||
}
|
||||
|
||||
for (const id of Object.keys(result.items)) {
|
||||
remove(type, id);
|
||||
}
|
||||
|
||||
return result.deleted;
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete items:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
add,
|
||||
update,
|
||||
remove,
|
||||
get,
|
||||
has,
|
||||
clearAll,
|
||||
addAll,
|
||||
move,
|
||||
count,
|
||||
loadPaginated,
|
||||
loadNextPage,
|
||||
loadPreviousPage,
|
||||
reloadCurrentPage,
|
||||
getPagination,
|
||||
setHistoryCount,
|
||||
loadQueue,
|
||||
addDownload,
|
||||
startItems,
|
||||
pauseItems,
|
||||
cancelItems,
|
||||
removeItems,
|
||||
deleteItems,
|
||||
};
|
||||
});
|
||||
|
|
@ -142,45 +142,6 @@ const dEvent = (eventName: string, detail: Record<string, any> = {}): boolean =>
|
|||
return window.dispatchEvent(new CustomEvent(eventName, { detail }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a pagination list based on current page, total pages, and delta range.
|
||||
*
|
||||
* @param current - The current active page number.
|
||||
* @param last - The last page number.
|
||||
* @param delta - How many pages to show before/after the current page.
|
||||
* @returns An array of pagination entries including optional gaps.
|
||||
*/
|
||||
const makePagination = (
|
||||
current: number,
|
||||
last: number,
|
||||
delta: number = 5,
|
||||
): Array<{ page: number; text: string; selected: boolean }> => {
|
||||
const pagination: Array<{ page: number; text: string; selected: boolean }> = [];
|
||||
if (last < 2) {
|
||||
return pagination;
|
||||
}
|
||||
|
||||
const strR = '-'.repeat(9 + `${last}`.length);
|
||||
const left = current - delta;
|
||||
const right = current + delta + 1;
|
||||
|
||||
for (let i = 1; i <= last; i++) {
|
||||
if (1 === i || last === i || (i >= left && i < right)) {
|
||||
if (i === left && i > 2) {
|
||||
pagination.push({ page: 0, text: strR, selected: false });
|
||||
}
|
||||
|
||||
pagination.push({ page: i, text: `Page #${i}`, selected: i === current });
|
||||
|
||||
if (i === right - 1 && i < last - 1) {
|
||||
pagination.push({ page: 0, text: strR, selected: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pagination;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely encode a path string for use in a URL.
|
||||
*
|
||||
|
|
@ -1027,7 +988,6 @@ export {
|
|||
r,
|
||||
copyText,
|
||||
dEvent,
|
||||
makePagination,
|
||||
encodePath,
|
||||
request,
|
||||
removeANSIColors,
|
||||
|
|
|
|||
106
ui/bun.lock
106
ui/bun.lock
|
|
@ -5,41 +5,39 @@
|
|||
"": {
|
||||
"name": "nuxt-app",
|
||||
"dependencies": {
|
||||
"@iconify-json/lucide": "latest",
|
||||
"@microsoft/fetch-event-source": "latest",
|
||||
"@nuxt/eslint": "latest",
|
||||
"@nuxt/eslint-config": "latest",
|
||||
"@nuxt/ui": "latest",
|
||||
"@pinia/nuxt": "latest",
|
||||
"@vueuse/core": "latest",
|
||||
"@vueuse/nuxt": "latest",
|
||||
"@xterm/addon-fit": "latest",
|
||||
"@xterm/xterm": "latest",
|
||||
"cron-parser": "latest",
|
||||
"cronstrue": "latest",
|
||||
"hls.js": "latest",
|
||||
"marked": "latest",
|
||||
"marked-alert": "latest",
|
||||
"marked-base-url": "latest",
|
||||
"marked-gfm-heading-id": "latest",
|
||||
"moment": "latest",
|
||||
"nuxt": "latest",
|
||||
"pinia": "latest",
|
||||
"tailwindcss": "latest",
|
||||
"vue": "latest",
|
||||
"vue-router": "latest",
|
||||
"@iconify-json/lucide": "^1.2.102",
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"@nuxt/eslint": "^1.15.2",
|
||||
"@nuxt/eslint-config": "^1.15.2",
|
||||
"@nuxt/ui": "^4.6.1",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@vueuse/nuxt": "^14.2.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"cron-parser": "^5.5.0",
|
||||
"cronstrue": "^3.14.0",
|
||||
"hls.js": "^1.6.16",
|
||||
"marked": "^18.0.0",
|
||||
"marked-alert": "^2.1.2",
|
||||
"marked-base-url": "^1.1.9",
|
||||
"marked-gfm-heading-id": "^4.1.4",
|
||||
"moment": "^2.30.1",
|
||||
"nuxt": "^4.4.2",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.4",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/jsdom": "latest",
|
||||
"@types/node": "latest",
|
||||
"@typescript-eslint/parser": "latest",
|
||||
"eslint": "latest",
|
||||
"jsdom": "latest",
|
||||
"oxfmt": "latest",
|
||||
"typescript": "latest",
|
||||
"vue-eslint-parser": "latest",
|
||||
"vue-tsc": "latest",
|
||||
"@types/bun": "^1.3.12",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"@types/node": "25.6.0",
|
||||
"@typescript-eslint/parser": "^8.58.2",
|
||||
"eslint": "^10.2.0",
|
||||
"jsdom": "^29.0.2",
|
||||
"oxfmt": "^0.45.0",
|
||||
"typescript": "^6.0.2",
|
||||
"vue-eslint-parser": "^10.4.0",
|
||||
"vue-tsc": "^3.2.6",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -512,8 +510,6 @@
|
|||
|
||||
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
|
||||
|
||||
"@pinia/nuxt": ["@pinia/nuxt@0.11.3", "", { "dependencies": { "@nuxt/kit": "^4.2.0" }, "peerDependencies": { "pinia": "^3.0.4" } }, "sha512-7WVNHpWx4qAEzOlnyrRC88kYrwnlR/PrThWT0XI1dSNyUAXu/KBv9oR37uCgYkZroqP5jn8DfzbkNF3BtKvE9w=="],
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||
|
|
@ -838,7 +834,7 @@
|
|||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.32", "", { "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/shared": "3.5.32" } }, "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw=="],
|
||||
|
||||
"@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
|
||||
"@vue/devtools-api": ["@vue/devtools-api@8.0.6", "", { "dependencies": { "@vue/devtools-kit": "^8.0.6" } }, "sha512-+lGBI+WTvJmnU2FZqHhEB8J1DXcvNlDeEalz77iYgOdY1jTj1ipSBaKj3sRhYcy+kqA8v/BSuvOz1XJucfQmUA=="],
|
||||
|
||||
"@vue/devtools-core": ["@vue/devtools-core@8.1.0", "", { "dependencies": { "@vue/devtools-kit": "^8.1.0", "@vue/devtools-shared": "^8.1.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-LvD1VgDpoHmYL00IgKRLKktF6SsPAb0yaV8wB8q2jRwsAWvqhS8+vsMLEGKNs7uoKyymXhT92dhxgf/wir6YGQ=="],
|
||||
|
||||
|
|
@ -2262,7 +2258,7 @@
|
|||
|
||||
"@vue/compiler-ssr/@vue/shared": ["@vue/shared@3.5.32", "", {}, "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg=="],
|
||||
|
||||
"@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="],
|
||||
"@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@8.0.6", "", { "dependencies": { "@vue/devtools-shared": "^8.0.6", "birpc": "^2.6.1", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^2.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-9zXZPTJW72OteDXeSa5RVML3zWDCRcO5t77aJqSs228mdopYj5AiTpihozbsfFJ0IodfNs7pSgOGO3qfCuxDtw=="],
|
||||
|
||||
"@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
|
|
@ -2386,6 +2382,8 @@
|
|||
|
||||
"path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
|
||||
|
||||
"pinia/@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
|
||||
|
||||
"readdir-glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
|
||||
|
||||
"rollup-plugin-visualizer/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
|
||||
|
|
@ -2448,8 +2446,6 @@
|
|||
|
||||
"vue-eslint-parser/espree": ["espree@11.1.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw=="],
|
||||
|
||||
"vue-router/@vue/devtools-api": ["@vue/devtools-api@8.0.6", "", { "dependencies": { "@vue/devtools-kit": "^8.0.6" } }, "sha512-+lGBI+WTvJmnU2FZqHhEB8J1DXcvNlDeEalz77iYgOdY1jTj1ipSBaKj3sRhYcy+kqA8v/BSuvOz1XJucfQmUA=="],
|
||||
|
||||
"vue-router/mlly": ["mlly@1.8.1", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ=="],
|
||||
|
||||
"whatwg-url/@exodus/bytes": ["@exodus/bytes@1.14.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ=="],
|
||||
|
|
@ -2580,14 +2576,12 @@
|
|||
|
||||
"@vue/babel-plugin-resolve-type/@vue/compiler-sfc/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
|
||||
"@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@8.0.6", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-Pp1JylTqlgMJvxW6MGyfTF8vGvlBSCAvMFaDCYa82Mgw7TT5eE5kkHgDvmOGHWeJE4zIDfCpCxHapsK2LtIAJg=="],
|
||||
|
||||
"@vue/devtools-api/@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
"@vue/devtools-api/@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"@vue/devtools-api/@vue/devtools-kit/perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
|
||||
"@vue/language-core/@vue/compiler-dom/@vue/compiler-core": ["@vue/compiler-core@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.30", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw=="],
|
||||
|
||||
"archiver-utils/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
|
@ -2628,10 +2622,10 @@
|
|||
|
||||
"nuxt/vue/@vue/server-renderer": ["@vue/server-renderer@3.5.30", "", { "dependencies": { "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "vue": "3.5.30" } }, "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ=="],
|
||||
|
||||
"nuxt/vue-router/@vue/devtools-api": ["@vue/devtools-api@8.0.6", "", { "dependencies": { "@vue/devtools-kit": "^8.0.6" } }, "sha512-+lGBI+WTvJmnU2FZqHhEB8J1DXcvNlDeEalz77iYgOdY1jTj1ipSBaKj3sRhYcy+kqA8v/BSuvOz1XJucfQmUA=="],
|
||||
|
||||
"nuxt/vue-router/mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
|
||||
|
||||
"pinia/@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="],
|
||||
|
||||
"readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"rollup-plugin-visualizer/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
|
||||
|
|
@ -2666,8 +2660,6 @@
|
|||
|
||||
"vue-eslint-parser/espree/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"vue-router/@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@8.0.6", "", { "dependencies": { "@vue/devtools-shared": "^8.0.6", "birpc": "^2.6.1", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^2.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-9zXZPTJW72OteDXeSa5RVML3zWDCRcO5t77aJqSs228mdopYj5AiTpihozbsfFJ0IodfNs7pSgOGO3qfCuxDtw=="],
|
||||
|
||||
"vue-router/mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
||||
|
||||
"@dxup/nuxt/@nuxt/kit/mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
||||
|
|
@ -2742,8 +2734,6 @@
|
|||
|
||||
"nuxt/mlly/pkg-types/mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
|
||||
|
||||
"nuxt/vue-router/@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@8.0.6", "", { "dependencies": { "@vue/devtools-shared": "^8.0.6", "birpc": "^2.6.1", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^2.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-9zXZPTJW72OteDXeSa5RVML3zWDCRcO5t77aJqSs228mdopYj5AiTpihozbsfFJ0IodfNs7pSgOGO3qfCuxDtw=="],
|
||||
|
||||
"nuxt/vue-router/mlly/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"nuxt/vue-router/mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
||||
|
|
@ -2764,6 +2754,14 @@
|
|||
|
||||
"nuxt/vue/@vue/server-renderer/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA=="],
|
||||
|
||||
"pinia/@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
|
||||
|
||||
"pinia/@vue/devtools-api/@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
"pinia/@vue/devtools-api/@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"pinia/@vue/devtools-api/@vue/devtools-kit/perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
|
||||
"readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"unplugin-vue-components/mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
|
||||
|
|
@ -2786,12 +2784,6 @@
|
|||
|
||||
"vaul-vue/vue/@vue/server-renderer/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA=="],
|
||||
|
||||
"vue-router/@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@8.0.6", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-Pp1JylTqlgMJvxW6MGyfTF8vGvlBSCAvMFaDCYa82Mgw7TT5eE5kkHgDvmOGHWeJE4zIDfCpCxHapsK2LtIAJg=="],
|
||||
|
||||
"vue-router/@vue/devtools-api/@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
"vue-router/@vue/devtools-api/@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"vue-router/mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
|
||||
|
||||
"vue-router/mlly/pkg-types/mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
|
||||
|
|
@ -2848,12 +2840,6 @@
|
|||
|
||||
"nuxt/mlly/pkg-types/mlly/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"nuxt/vue-router/@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@8.0.6", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-Pp1JylTqlgMJvxW6MGyfTF8vGvlBSCAvMFaDCYa82Mgw7TT5eE5kkHgDvmOGHWeJE4zIDfCpCxHapsK2LtIAJg=="],
|
||||
|
||||
"nuxt/vue-router/@vue/devtools-api/@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
"nuxt/vue-router/@vue/devtools-api/@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"nuxt/vue-router/mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
|
||||
|
||||
"nuxt/vue/@vue/compiler-dom/@vue/compiler-core/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
|
||||
|
|
|
|||
|
|
@ -59,11 +59,12 @@ export default defineNuxtConfig({
|
|||
},
|
||||
pageTransition: { name: 'page' },
|
||||
},
|
||||
modules: ['@nuxt/ui', '@pinia/nuxt', '@vueuse/nuxt', '@nuxt/eslint'],
|
||||
modules: ['@nuxt/ui', '@vueuse/nuxt', '@nuxt/eslint'],
|
||||
icon: {
|
||||
provider: 'none',
|
||||
fallbackToApi: false,
|
||||
clientBundle: {
|
||||
icons: isProd ? [] : ['lucide:book'],
|
||||
scan: {
|
||||
globInclude: ['app/**/*.{vue,ts,js}', 'node_modules/@nuxt/ui/dist/shared/ui*.mjs'],
|
||||
globExclude: ['dist', 'build', 'coverage', 'test', 'tests', '.*'],
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@
|
|||
"@nuxt/eslint": "^1.15.2",
|
||||
"@nuxt/eslint-config": "^1.15.2",
|
||||
"@nuxt/ui": "^4.6.1",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@vueuse/nuxt": "^14.2.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
|
|
@ -40,7 +39,6 @@
|
|||
"marked-gfm-heading-id": "^4.1.4",
|
||||
"moment": "^2.30.1",
|
||||
"nuxt": "^4.4.2",
|
||||
"pinia": "^3.0.4",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.4"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,24 @@
|
|||
import { describe, expect, it } from 'bun:test'
|
||||
import { beforeAll, describe, expect, it, mock } from 'bun:test'
|
||||
|
||||
import { usePresetOptions } from '~/composables/usePresetOptions'
|
||||
import type { Preset } from '~/types/presets'
|
||||
|
||||
let configState = {
|
||||
presets: [] as Preset[],
|
||||
app: {
|
||||
download_path: '/downloads',
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('~/composables/useYtpConfig', () => ({
|
||||
useYtpConfig: () => configState,
|
||||
}))
|
||||
|
||||
let usePresetOptions: typeof import('~/composables/usePresetOptions').usePresetOptions
|
||||
|
||||
beforeAll(async () => {
|
||||
;({ usePresetOptions } = await import('~/composables/usePresetOptions'))
|
||||
})
|
||||
|
||||
const buildPreset = (name: string, isDefault: boolean): Preset => ({
|
||||
name,
|
||||
default: isDefault,
|
||||
|
|
@ -15,12 +31,12 @@ const buildPreset = (name: string, isDefault: boolean): Preset => ({
|
|||
})
|
||||
|
||||
const setConfigStore = (presets: Preset[]) => {
|
||||
;(globalThis as any).useConfigStore = () => ({
|
||||
configState = {
|
||||
presets,
|
||||
app: {
|
||||
download_path: '/downloads',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('usePresetOptions', () => {
|
||||
|
|
|
|||
|
|
@ -326,15 +326,6 @@ describe('data conversion helpers', () => {
|
|||
expect(utils.decode(encoded)).toEqual(payload);
|
||||
});
|
||||
|
||||
it('makePagination builds a ranged pagination list', () => {
|
||||
const pages = utils.makePagination(5, 10, 1);
|
||||
const selected = pages.find((page: any) => page.selected);
|
||||
expect(selected?.page).toBe(5);
|
||||
expect(pages.length).toBeGreaterThan(0);
|
||||
expect(pages[0]?.page).toBe(1);
|
||||
expect(pages[pages.length - 1]?.page).toBe(10);
|
||||
});
|
||||
|
||||
it('getQueryParams parses query strings', () => {
|
||||
expect(utils.getQueryParams('?a=1&b=two')).toEqual({ a: '1', b: 'two' });
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue