ytptube/ui/app/components/GetInfo.vue
2026-06-19 18:39:35 +03:00

341 lines
7.8 KiB
Vue

<template>
<UModal v-model:open="modalOpen" :title="resolvedTitle" :ui="modalUi">
<template #description>
<span class="sr-only">{{ modalDescription }}</span>
</template>
<template #body>
<div class="space-y-4">
<div v-if="isLoading" class="flex min-h-80 items-center justify-center">
<UIcon name="i-lucide-loader-circle" class="size-16 animate-spin text-primary" />
</div>
<UAlert
v-else-if="errorMessage"
color="error"
variant="soft"
icon="i-lucide-triangle-alert"
title="Unable to load information"
:description="errorMessage"
/>
<div v-else class="space-y-3">
<UInput
v-model="query"
type="search"
placeholder="Filter text"
icon="i-lucide-filter"
size="sm"
class="w-full"
/>
<UAlert
v-if="query && 0 === filteredLineCount"
color="warning"
variant="soft"
icon="i-lucide-filter"
title="No matching lines"
/>
<UAlert
v-else-if="!hasDisplayText"
color="neutral"
variant="soft"
icon="i-lucide-info"
title="No content returned"
description="The request completed successfully but did not return any visible content."
/>
<pre
v-else-if="!query || filteredLineCount > 0"
ref="contentView"
class="ytp-terminal max-h-[55vh] overflow-auto"
:class="wrap ? 'whitespace-pre-wrap wrap-break-word' : 'whitespace-pre'"
><code v-text="displayedText" /></pre>
</div>
</div>
</template>
<template #footer>
<div class="flex w-full flex-wrap items-center justify-end gap-2">
<UButton
type="button"
color="neutral"
:variant="wrap ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-wrap-text"
:disabled="isLoading || !hasVisibleText"
@click="wrap = !wrap"
>
<span class="hidden sm:inline">Wrap</span>
</UButton>
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-copy"
:disabled="isLoading || !hasVisibleText"
@click="copyData"
>
Copy
</UButton>
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-arrow-down"
:disabled="isLoading || !hasVisibleText"
@click="scrollContent('end')"
>
Go down
</UButton>
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-refresh-cw"
:disabled="!link"
:loading="isLoading"
@click="() => void loadInfo()"
>
Reload
</UButton>
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-arrow-up"
:disabled="isLoading || !hasVisibleText"
@click="scrollContent('start')"
>
Go up
</UButton>
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
import { useStorage } from '@vueuse/core';
import { filterLogTextLines } from '~/utils/logs';
const emitter = defineEmits<{ (e: 'closeModel'): void }>();
const props = withDefaults(
defineProps<{
link?: string;
preset?: string;
cli?: string;
useUrl?: boolean;
}>(),
{
link: '',
preset: '',
cli: '',
useUrl: false,
},
);
const isLoading = ref(true);
const data = ref<unknown>(null);
const errorMessage = ref('');
const query = ref('');
const wrap = useStorage<boolean>('getinfo_wrap', false);
const contentView = ref<HTMLElement | null>(null);
let latestRequestId = 0;
const toast = useNotification();
const modalUi = {
content: 'w-full sm:max-w-5xl',
body: 'p-4 sm:p-5',
footer: 'px-4 pb-4 sm:px-5 sm:pb-5',
} as const;
const modalOpen = computed({
get: () => true,
set: (value: boolean) => {
if (!value) {
emitter('closeModel');
}
},
});
const resolvedTitle = computed(() => {
if (props.useUrl && props.link.startsWith('/api/history/')) {
return 'Local Information';
}
if (props.useUrl) {
return 'File Contents';
}
return 'yt-dlp Information';
});
const modalDescription = computed(() => {
if (props.useUrl && props.link.startsWith('/api/history/')) {
return 'View the stored local information payload for this item.';
}
if (props.useUrl) {
return 'Preview the fetched text response.';
}
return 'View the yt-dlp information payload for this URL.';
});
const displayText = computed(() => {
if (typeof data.value === 'string') {
return data.value;
}
if (data.value === null || data.value === undefined) {
return '';
}
try {
return JSON.stringify(data.value, null, 2) ?? '';
} catch {
return String(data.value ?? '');
}
});
const filteredLines = computed<Array<string>>(() =>
filterLogTextLines(displayText.value, query.value),
);
const filteredLineCount = computed(() => filteredLines.value.length);
const displayedText = computed(() =>
query.value ? filteredLines.value.join('\n') : displayText.value,
);
const hasDisplayText = computed(() => displayText.value.length > 0);
const hasVisibleText = computed(() => displayedText.value.length > 0);
const buildRequestUrl = (): string => {
if (props.useUrl) {
return props.link;
}
const params = new URLSearchParams();
if (props.preset) {
params.append('preset', props.preset);
}
if (props.cli) {
params.append('args', props.cli);
}
params.append('url', props.link);
return `/api/yt-dlp/url/info?${params.toString()}`;
};
const parseResponseBody = (body: string): unknown => {
try {
return JSON.parse(body);
} catch {
return body;
}
};
const getErrorMessage = (body: string, status: number): string => {
if (!body) {
return `Request failed with status ${status}.`;
}
try {
const parsed = JSON.parse(body) as { message?: string; error?: string };
return parsed.message || parsed.error || body;
} catch {
return body;
}
};
const loadInfo = async (): Promise<void> => {
latestRequestId += 1;
const requestId = latestRequestId;
query.value = '';
isLoading.value = true;
errorMessage.value = '';
data.value = null;
if (!props.link) {
errorMessage.value = 'No source URL was provided.';
isLoading.value = false;
return;
}
try {
const response = await request(buildRequestUrl());
const body = await response.text();
if (requestId !== latestRequestId) {
return;
}
if (!response.ok) {
throw new Error(getErrorMessage(body, response.status));
}
data.value = parseResponseBody(body);
} catch (error: unknown) {
if (requestId !== latestRequestId) {
return;
}
console.error(error);
const message = error instanceof Error ? error.message : 'Failed to load information.';
errorMessage.value = message;
toast.error(`Error: ${message}`);
} finally {
if (requestId === latestRequestId) {
isLoading.value = false;
}
}
};
const copyData = (): void => {
if (!displayedText.value) {
return;
}
copyText(displayedText.value, false);
};
const scrollContent = (dir: 'start' | 'end'): void => {
if (!contentView.value) {
return;
}
contentView.value.scrollTo({
top: 'start' === dir ? 0 : contentView.value.scrollHeight,
behavior: 'smooth',
});
};
watch(query, () => {
if (!contentView.value) {
return;
}
contentView.value.scrollTo({ top: 0 });
});
watch(
() => [props.link, props.preset, props.cli, props.useUrl],
() => {
void loadInfo();
},
{ immediate: true },
);
</script>