ytptube/ui/app/components/YTDLPOptions.vue
2026-03-31 18:34:59 +03:00

426 lines
13 KiB
Vue

<template>
<div class="w-full min-w-0 max-w-full space-y-4 p-1 sm:p-2">
<div class="grid gap-4 rounded-lg border border-default bg-muted/10 p-4 lg:grid-cols-12">
<UFormField label="Search" class="lg:col-span-4" :ui="fieldUi">
<UInput
v-model.trim="filters.query"
type="text"
placeholder="Filter by flag or description..."
autocomplete="off"
class="w-full"
:ui="inputUi"
>
<template #leading>
<UIcon name="i-lucide-search" class="size-4 text-toned" />
</template>
</UInput>
</UFormField>
<UFormField label="Group Filter" class="sm:col-span-6 lg:col-span-2" :ui="fieldUi">
<USelect
v-model="filters.group"
:items="groupItems"
value-key="value"
label-key="label"
class="w-full"
:ui="inputUi"
/>
</UFormField>
<UFormField label="Display" class="sm:col-span-6 lg:col-span-2" :ui="fieldUi">
<USelect
v-model="displayMode"
:items="displayItems"
value-key="value"
label-key="label"
class="w-full"
:ui="inputUi"
/>
</UFormField>
<UFormField label="Sort By" class="sm:col-span-6 lg:col-span-2" :ui="fieldUi">
<USelect
v-model="sortBy"
:items="sortItems"
value-key="value"
label-key="label"
class="w-full"
:ui="inputUi"
/>
</UFormField>
<UFormField label="Order" class="sm:col-span-6 lg:col-span-2" :ui="fieldUi">
<USelect
v-model="sortDir"
:items="orderItems"
value-key="value"
label-key="label"
class="w-full"
:ui="inputUi"
/>
</UFormField>
<UFormField label="Flags" class="lg:col-span-12" :ui="fieldUi">
<div class="flex flex-wrap gap-2">
<UButton
v-for="item in flagFilterItems"
:key="item.value"
type="button"
size="xs"
:color="filters.flagKind === item.value ? 'primary' : 'neutral'"
:variant="filters.flagKind === item.value ? 'solid' : 'outline'"
@click="filters.flagKind = item.value"
>
{{ item.label }}
</UButton>
<UButton
type="button"
color="neutral"
variant="ghost"
size="xs"
icon="i-lucide-refresh-cw"
:loading="isLoading"
:disabled="isLoading"
@click="() => void reload()"
>
Reload
</UButton>
</div>
</UFormField>
</div>
<UAlert
v-if="isLoading"
color="info"
variant="soft"
icon="i-lucide-loader-circle"
title="Loading"
description="Loading yt-dlp options. Please wait..."
/>
<UAlert
v-else-if="visible.length === 0"
color="warning"
variant="soft"
icon="i-lucide-search-x"
title="No options match your criteria."
/>
<template v-else-if="displayMode === 'grouped' && grouped.length !== 0">
<section v-for="group in grouped" :key="group.name" class="space-y-3">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon name="i-lucide-folder-open" class="size-4 text-toned" />
<span>{{ group.name }}</span>
<UBadge color="neutral" variant="soft" size="sm">{{ group.items.length }}</UBadge>
</div>
<div
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
>
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-180 w-full table-auto text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
<tr class="text-left [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
<th class="w-80 whitespace-nowrap">Flags</th>
<th>Description</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr
v-for="opt in group.items"
:key="opt.flags.join('|')"
class="align-top hover:bg-muted/20"
>
<td class="w-80 px-3 py-3 align-top">
<div class="flex items-start gap-2">
<div class="flex flex-wrap gap-1.5">
<UBadge
v-for="flag in opt.flags"
:key="flag"
color="info"
variant="soft"
size="sm"
class="max-w-full whitespace-nowrap font-mono"
>
{{ flag }}
</UBadge>
</div>
<UTooltip text="Copy long flag">
<UButton
type="button"
color="neutral"
variant="ghost"
size="xs"
icon="i-lucide-copy"
square
class="shrink-0"
:disabled="!hasLongFlag(opt.flags)"
@click="() => void copyFlag(opt.flags)"
/>
</UTooltip>
</div>
</td>
<td class="px-3 py-3 align-top text-default">
<div class="min-w-0 wrap-break-word whitespace-normal">
<span v-if="opt.description && opt.description.length !== 0">{{
opt.description
}}</span>
<span v-else class="text-toned">-</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</template>
<div
v-else
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
>
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-215 w-full table-auto text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
<tr class="text-left [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
<th class="w-80 whitespace-nowrap">Flags</th>
<th class="w-36 whitespace-nowrap">Group</th>
<th>Description</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr
v-for="opt in visible"
:key="opt.flags.join('|')"
class="align-top hover:bg-muted/20"
>
<td class="w-80 px-3 py-3 align-top">
<div class="flex items-start gap-2">
<div class="flex flex-wrap gap-1.5">
<UBadge
v-for="flag in opt.flags"
:key="flag"
color="info"
variant="soft"
size="sm"
class="max-w-full whitespace-nowrap font-mono"
>
{{ flag }}
</UBadge>
</div>
<UTooltip text="Copy long flag">
<UButton
type="button"
color="neutral"
variant="ghost"
size="xs"
icon="i-lucide-copy"
square
class="shrink-0"
:disabled="!hasLongFlag(opt.flags)"
@click="() => void copyFlag(opt.flags)"
/>
</UTooltip>
</div>
</td>
<td class="w-36 px-3 py-3 align-top font-medium text-default whitespace-nowrap">
{{ opt.group || 'root' }}
</td>
<td class="px-3 py-3 align-top text-default">
<div class="min-w-0 wrap-break-word whitespace-normal">
<span v-if="opt.description && opt.description.length !== 0">{{
opt.description
}}</span>
<span v-else class="text-toned">-</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useStorage } from '@vueuse/core';
import type { YTDLPOption } from '~/types/ytdlp';
import {
buildYtdlpGroupItems,
normalizeYtdlpGroupFilter,
YTDLP_ALL_GROUPS,
} from '~/utils/ytdlpOptions';
const isLoading = ref(false);
const options = ref<YTDLPOption[]>([]);
const displayMode = useStorage<'grouped' | 'list'>('opts_display', 'grouped');
const sortBy = useStorage<'flag' | 'group'>('opts_sort_by', 'flag');
const sortDir = useStorage<'asc' | 'desc'>('opts_sort_dir', 'asc');
const filters = reactive({
query: '',
group: YTDLP_ALL_GROUPS,
flagKind: 'any' as 'any' | 'short' | 'long',
});
const fieldUi = {
label: 'font-semibold text-default',
container: 'space-y-2',
description: 'text-sm text-toned',
};
const inputUi = {
root: 'w-full',
base: 'w-full bg-elevated/60 ring-default focus-visible:ring-primary',
};
const displayItems = [
{ label: 'Grouped', value: 'grouped' },
{ label: 'List', value: 'list' },
];
const sortItems = [
{ label: 'Flag', value: 'flag' },
{ label: 'Group', value: 'group' },
];
const orderItems = [
{ label: 'Asc', value: 'asc' },
{ label: 'Desc', value: 'desc' },
];
const flagFilterItems = [
{ label: 'Any', value: 'any' as const },
{ label: 'Short Only (-x)', value: 'short' as const },
{ label: 'Long Only (--xyz)', value: 'long' as const },
];
const reload = async (): Promise<void> => {
try {
isLoading.value = true;
const resp = await request('/api/yt-dlp/options');
if (!resp.ok) {
return;
}
const data = await resp.json();
if (Array.isArray(data)) {
options.value = data as YTDLPOption[];
}
} finally {
isLoading.value = false;
}
};
const hasLongFlag = (flags: string[]): boolean => {
return flags.some((flag) => flag.startsWith('--'));
};
const copyFlag = async (flags: string[]): Promise<void> => {
const longFlag = flags.find((flag) => flag.startsWith('--'));
if (!longFlag) {
return;
}
copyText(longFlag);
};
onMounted(async () => await reload());
const groupNames = computed<string[]>(() => {
const names = new Set<string>();
for (const option of options.value) {
names.add(option.group || 'root');
}
return Array.from(names).sort((a, b) => a.localeCompare(b));
});
const groupItems = computed(() => {
return buildYtdlpGroupItems(groupNames.value);
});
const filtered = computed<YTDLPOption[]>(() => {
const q = filters.query.toLowerCase();
const g = normalizeYtdlpGroupFilter(filters.group);
return options.value.filter((option) => {
if (option.ignored) {
return false;
}
if (g && (option.group || 'root') !== g) {
return false;
}
if (
filters.flagKind === 'short' &&
!option.flags.some((flag) => /^-\w(,|$)|^-\w$/.test(flag))
) {
return false;
}
if (
filters.flagKind === 'long' &&
!option.flags.some((flag) => /^--[a-zA-Z0-9][\w-]*/.test(flag))
) {
return false;
}
if (q.length !== 0) {
const haystack = [option.flags.join(' '), option.description || '', option.group || 'root']
.join(' ')
.toLowerCase();
if (!haystack.includes(q)) {
return false;
}
}
return true;
});
});
const sorted = computed<YTDLPOption[]>(() => {
const dir = sortDir.value === 'asc' ? 1 : -1;
const list = [...filtered.value];
list.sort((a, b) => {
if (sortBy.value === 'group') {
const groupCompare = (a.group || 'root').localeCompare(b.group || 'root');
if (groupCompare !== 0) {
return groupCompare * dir;
}
}
return (a.flags[0] || '').localeCompare(b.flags[0] || '') * dir;
});
return list;
});
const visible = computed(() => sorted.value);
const grouped = computed<{ name: string; items: YTDLPOption[] }[]>(() => {
const map = new Map<string, YTDLPOption[]>();
for (const option of visible.value) {
const key = option.group || 'root';
if (!map.has(key)) {
map.set(key, []);
}
map.get(key)?.push(option);
}
const dir = sortDir.value === 'asc' ? 1 : -1;
const list = Array.from(map.entries()).map(([name, items]) => ({ name, items }));
if (sortBy.value === 'group') {
list.sort((a, b) => a.name.localeCompare(b.name) * dir);
} else {
list.sort((a, b) => a.name.localeCompare(b.name));
}
return list;
});
</script>