mirror of https://github.com/VERT-sh/VERT.git
598 lines
16 KiB
Svelte
598 lines
16 KiB
Svelte
<script lang="ts">
|
|
import { duration, fade, transition } from "$lib/util/animation";
|
|
import { m } from "$lib/paraglide/messages";
|
|
import { isMobile, files, dropdownStates } from "$lib/store/index.svelte";
|
|
import type { Categories } from "$lib/types";
|
|
import clsx from "clsx";
|
|
import { ChevronDown, SearchIcon } from "lucide-svelte";
|
|
import { onMount } from "svelte";
|
|
import { quintOut } from "svelte/easing";
|
|
import { VertFile } from "$lib/types";
|
|
import SettingsModal from "./SettingsModal.svelte";
|
|
import { log } from "$lib/util/logger";
|
|
|
|
type Props = {
|
|
categories: Categories;
|
|
from?: string;
|
|
selected?: string;
|
|
onselect?: (option: string) => void;
|
|
disabled?: boolean;
|
|
dropdownSize?: "default" | "large" | "small";
|
|
file?: VertFile;
|
|
};
|
|
|
|
let {
|
|
categories,
|
|
from,
|
|
selected = $bindable(""),
|
|
onselect,
|
|
disabled,
|
|
dropdownSize = "default",
|
|
file,
|
|
}: Props = $props();
|
|
let open = $state(false);
|
|
let dropdown = $state<HTMLDivElement>();
|
|
let currentCategory = $state<string | null>();
|
|
let searchQuery = $state("");
|
|
let dropdownMenu: HTMLElement | undefined = $state();
|
|
let rootCategory: string | null = null;
|
|
let dropdownPosition = $state<"left" | "center" | "right">("center");
|
|
|
|
// initialize current category
|
|
$effect(() => {
|
|
if (currentCategory) return;
|
|
|
|
log(
|
|
["dropdown", "init"],
|
|
`initializing category, file: ${file?.name}, from: ${from}`,
|
|
);
|
|
|
|
// find the category whose formats overlap most with the converters for this file (or all files)
|
|
// this finds the best matching category based on the formats supported by the converters
|
|
const pickCategoryFromConverters = (
|
|
convList: VertFile["converters"],
|
|
) => {
|
|
let bestCategory: string | null = null;
|
|
let maxOverlap = 0;
|
|
|
|
for (const cat of Object.keys(categories)) {
|
|
const overlapCount = categories[cat].formats.filter((fmt) =>
|
|
convList.some((conv) => conv.formatStrings().includes(fmt)),
|
|
).length;
|
|
|
|
if (overlapCount > maxOverlap) {
|
|
maxOverlap = overlapCount;
|
|
bestCategory = cat;
|
|
}
|
|
}
|
|
|
|
return bestCategory;
|
|
};
|
|
|
|
// decide which converters to use to detect category:
|
|
// - if file provided, prefer its primary converter -- individual file dropdown
|
|
// - if no file provided, use all converters from all files -- "set all to" dropdown
|
|
const convertersToCheck = file
|
|
? file.findConverter()
|
|
? [file.findConverter()!]
|
|
: file.converters
|
|
: files.files.flatMap((f) => f.converters);
|
|
|
|
log(
|
|
["dropdown", "init"],
|
|
`checking converters:`,
|
|
convertersToCheck.map((c) => c.formatStrings()),
|
|
);
|
|
|
|
// if file is provided, first try to find its category by input format
|
|
let detectedCategory: string | null = null;
|
|
if (file && from) {
|
|
detectedCategory =
|
|
Object.keys(categories).find((cat) =>
|
|
categories[cat].formats.includes(from),
|
|
) || null;
|
|
log(
|
|
["dropdown", "init"],
|
|
`detected category from input format (${from}):`,
|
|
detectedCategory,
|
|
);
|
|
}
|
|
|
|
// fallback to category with most converter overlap if input category not found
|
|
detectedCategory =
|
|
detectedCategory ||
|
|
pickCategoryFromConverters(convertersToCheck) ||
|
|
Object.keys(categories)[0];
|
|
|
|
log(["dropdown", "init"], `final detected category:`, detectedCategory);
|
|
|
|
currentCategory = detectedCategory;
|
|
rootCategory = detectedCategory;
|
|
});
|
|
|
|
// other available categories based on current category (e.g. converting between video and audio)
|
|
const availableCategories = $derived.by(() => {
|
|
if (!rootCategory) return Object.keys(categories);
|
|
|
|
let finalCategories = Object.keys(categories).filter(
|
|
(cat) =>
|
|
cat === rootCategory ||
|
|
categories[rootCategory!]?.canConvertTo?.includes(cat),
|
|
);
|
|
|
|
// handle special cases
|
|
if (from === ".gif" || from === ".webp") finalCategories.push("video");
|
|
if (from === ".apng") {
|
|
//finalCategories.push("image"); // -- buggy, magick can't convert from or to apng properly
|
|
finalCategories = finalCategories.filter((cat) => cat !== "audio");
|
|
}
|
|
|
|
// filter out categories that can't handle large files (due to browser/device limitations)
|
|
if (file && file.isLarge()) {
|
|
// if file is large video, disable audio conversion
|
|
if (rootCategory === "video")
|
|
finalCategories = finalCategories.filter(
|
|
(cat) => cat !== "audio",
|
|
);
|
|
}
|
|
|
|
return finalCategories;
|
|
});
|
|
|
|
const shouldInclude = (format: string, category: string): boolean => {
|
|
// if converting from audio to video, dont show gifs
|
|
if (
|
|
categories["audio"]?.formats.includes(from ?? "") &&
|
|
format === ".gif"
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const filteredData = $derived.by(() => {
|
|
const normalize = (str: string) => str.replace(/^\./, "").toLowerCase();
|
|
|
|
// if no query, return formats for current category
|
|
if (!searchQuery) {
|
|
let formats = currentCategory
|
|
? categories[currentCategory].formats.filter((format) =>
|
|
shouldInclude(format, currentCategory!),
|
|
)
|
|
: [];
|
|
|
|
// if no formats found at all, show everything
|
|
if (formats.length !== 0) {
|
|
const allCategories = Object.keys(categories);
|
|
// show formats for current category if set, otherwise all formats
|
|
const fallbackFormats =
|
|
currentCategory && allCategories.includes(currentCategory)
|
|
? categories[currentCategory].formats
|
|
: allCategories.flatMap(
|
|
(cat) => categories[cat].formats,
|
|
);
|
|
|
|
log(
|
|
["dropdown", "filter"],
|
|
`no formats found for category ${currentCategory}, showing all categories and formats as fallback`,
|
|
);
|
|
|
|
return {
|
|
categories: allCategories,
|
|
formats: fallbackFormats,
|
|
isFallback: true,
|
|
};
|
|
}
|
|
|
|
return {
|
|
categories: availableCategories,
|
|
formats,
|
|
isFallback: false,
|
|
};
|
|
}
|
|
const searchLower = normalize(searchQuery);
|
|
|
|
// find all categories that have formats matching the search query
|
|
const matchingCategories = availableCategories.filter((cat) =>
|
|
categories[cat].formats.some(
|
|
(format) =>
|
|
normalize(format).includes(searchLower) &&
|
|
shouldInclude(format, cat),
|
|
),
|
|
);
|
|
if (matchingCategories.length === 0) {
|
|
return {
|
|
categories: availableCategories,
|
|
formats: [],
|
|
isFallback: false,
|
|
};
|
|
}
|
|
|
|
// if current category has no matches, switch to first category that does
|
|
const currentCategoryHasMatches =
|
|
currentCategory &&
|
|
matchingCategories.some((cat) => cat === currentCategory);
|
|
if (!currentCategoryHasMatches && matchingCategories.length > 0) {
|
|
const newCategory = matchingCategories[0];
|
|
currentCategory = newCategory;
|
|
}
|
|
|
|
// return formats only from the current category that match the search
|
|
let filteredFormats = currentCategory
|
|
? categories[currentCategory].formats.filter(
|
|
(format) =>
|
|
normalize(format).includes(searchLower) &&
|
|
shouldInclude(format, currentCategory!),
|
|
)
|
|
: [];
|
|
|
|
// sorting exact match first, then others
|
|
filteredFormats = filteredFormats.sort((a, b) => {
|
|
const aExact = normalize(a) === searchLower;
|
|
const bExact = normalize(b) === searchLower;
|
|
if (aExact && !bExact) return -1;
|
|
if (!aExact && bExact) return 1;
|
|
return 0;
|
|
});
|
|
|
|
return {
|
|
categories:
|
|
matchingCategories.length > 0
|
|
? matchingCategories
|
|
: availableCategories,
|
|
formats: filteredFormats,
|
|
isFallback: false,
|
|
};
|
|
});
|
|
|
|
const selectOption = (option: string) => {
|
|
selected = option;
|
|
open = false;
|
|
|
|
// save user's selection to dropdownStates for this session
|
|
if (file) {
|
|
dropdownStates.update((states) => {
|
|
const updated = { ...states, [file.name]: option };
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
// find the category of this option if it's not in the current category
|
|
if (
|
|
currentCategory &&
|
|
!categories[currentCategory].formats.includes(option)
|
|
) {
|
|
const formatCategory = Object.keys(categories).find((cat) =>
|
|
categories[cat].formats.includes(option),
|
|
);
|
|
|
|
if (formatCategory) {
|
|
currentCategory = formatCategory;
|
|
}
|
|
}
|
|
|
|
onselect?.(option);
|
|
};
|
|
|
|
const selectCategory = (category: string) => {
|
|
if (!categories[category]) return;
|
|
currentCategory = category;
|
|
};
|
|
|
|
const handleSearch = (event: Event) => {
|
|
const query = (event.target as HTMLInputElement).value;
|
|
searchQuery = query;
|
|
|
|
// find which categories have matching formats & switch
|
|
if (query) {
|
|
const queryLower = query.toLowerCase();
|
|
const categoriesWithMatches = availableCategories.filter((cat) =>
|
|
categories[cat].formats.some((format) =>
|
|
format.toLowerCase().includes(queryLower),
|
|
),
|
|
);
|
|
|
|
if (categoriesWithMatches.length > 0) {
|
|
const currentHasMatches =
|
|
currentCategory &&
|
|
categories[currentCategory].formats.some((format) =>
|
|
format.toLowerCase().includes(queryLower),
|
|
);
|
|
|
|
if (!currentHasMatches) {
|
|
currentCategory = categoriesWithMatches[0];
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const onEnter = (event: KeyboardEvent) => {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
if (filteredData.formats.length > 0) {
|
|
selectOption(filteredData.formats[0]);
|
|
}
|
|
}
|
|
};
|
|
|
|
const clickDropdown = () => {
|
|
open = !open;
|
|
if (!open) return;
|
|
|
|
// keep within viewport
|
|
if (dropdown) {
|
|
const rect = dropdown.getBoundingClientRect();
|
|
const viewportWidth = window.innerWidth;
|
|
|
|
let dropdownWidth: number;
|
|
if (dropdownSize === "large") {
|
|
dropdownWidth = rect.width * 3.2;
|
|
} else if (dropdownSize === "default") {
|
|
dropdownWidth = rect.width * 2.5;
|
|
} else {
|
|
dropdownWidth = rect.width * 1.5;
|
|
}
|
|
|
|
const centerX = rect.left + rect.width / 2;
|
|
const leftEdge = centerX - dropdownWidth / 2;
|
|
const rightEdge = centerX + dropdownWidth / 2;
|
|
|
|
if (leftEdge < 0) {
|
|
dropdownPosition = "left";
|
|
} else if (rightEdge > viewportWidth) {
|
|
dropdownPosition = "right";
|
|
} else {
|
|
dropdownPosition = "center";
|
|
}
|
|
}
|
|
|
|
setTimeout(() => {
|
|
if (!dropdownMenu) return;
|
|
const searchInput = dropdownMenu.querySelector(
|
|
"#format-search",
|
|
) as HTMLInputElement;
|
|
if (searchInput) {
|
|
searchInput.focus();
|
|
searchInput.select();
|
|
}
|
|
}, 0); // let dropdown open first
|
|
};
|
|
|
|
const extract = async () => {
|
|
// extract all files in zip, then add all extracted files to files store
|
|
if (!file) return;
|
|
const { extractZip } = await import("$lib/util/zip");
|
|
const extractedFiles = await extractZip(file.file);
|
|
|
|
if (!Array.isArray(extractedFiles) || extractedFiles.length === 0)
|
|
return;
|
|
|
|
const newFiles = extractedFiles
|
|
.map(({ filename, data }) => {
|
|
try {
|
|
const f = new File([new Uint8Array(data)], filename, {
|
|
type: "application/octet-stream",
|
|
});
|
|
const ext = filename.split(".").pop() ?? "";
|
|
return new VertFile(f, ext);
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
})
|
|
.filter(Boolean);
|
|
|
|
files.files = files.files.filter((f) => f !== file);
|
|
newFiles.forEach((f) => files.add(f));
|
|
};
|
|
|
|
let showSettingsModal = $state(false);
|
|
|
|
const settings = () => {
|
|
if (!file) return;
|
|
showSettingsModal = true;
|
|
};
|
|
|
|
onMount(() => {
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (dropdown && !dropdown.contains(e.target as Node)) {
|
|
open = false;
|
|
}
|
|
};
|
|
|
|
const handleResize = () => {
|
|
if (open) {
|
|
// recalculate dropdown position on resize
|
|
clickDropdown();
|
|
open = true;
|
|
}
|
|
};
|
|
|
|
window.addEventListener("click", handleClickOutside);
|
|
window.addEventListener("resize", handleResize);
|
|
return () => {
|
|
window.removeEventListener("click", handleClickOutside);
|
|
window.removeEventListener("resize", handleResize);
|
|
};
|
|
});
|
|
</script>
|
|
|
|
{#if showSettingsModal && file}
|
|
<SettingsModal {file} onclose={() => (showSettingsModal = false)} />
|
|
{/if}
|
|
|
|
<div
|
|
class="relative w-full min-w-fit text-xl font-medium text-center"
|
|
bind:this={dropdown}
|
|
>
|
|
<button
|
|
class="relative flex items-center justify-center w-full font-display px-3 py-3.5 bg-button rounded-full overflow-hidden cursor-pointer focus:!outline-none
|
|
{disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
|
|
onclick={() => clickDropdown()}
|
|
{disabled}
|
|
>
|
|
<!-- <p>{selected}</p> -->
|
|
<div
|
|
class="grid grid-cols-1 grid-rows-1 w-fit flex-grow-0 max-h-[2.5rem] overflow-hidden"
|
|
>
|
|
{#key selected}
|
|
<p
|
|
in:fade={{
|
|
duration,
|
|
easing: quintOut,
|
|
}}
|
|
out:fade={{
|
|
duration,
|
|
easing: quintOut,
|
|
}}
|
|
class="col-start-1 row-start-1 text-center font-body font-medium truncate max-w-[4rem]"
|
|
>
|
|
{selected || "N/A"}
|
|
</p>
|
|
{/key}
|
|
{#if currentCategory}
|
|
{#each categories[currentCategory].formats as option}
|
|
<p
|
|
class="col-start-1 row-start-1 invisible pointer-events-none truncate max-w-[2.5rem]"
|
|
>
|
|
{option}
|
|
</p>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
<ChevronDown
|
|
class="w-4 h-4 ml-3 mt-0.5 flex-shrink-0"
|
|
style="transform: rotate({open
|
|
? 180
|
|
: 0}deg); transition: transform {duration}ms {transition};"
|
|
/>
|
|
</button>
|
|
{#if open}
|
|
<div
|
|
bind:this={dropdownMenu}
|
|
transition:fade={{
|
|
duration,
|
|
easing: quintOut,
|
|
}}
|
|
class={clsx(
|
|
$isMobile
|
|
? "fixed inset-x-0 bottom-0 w-full z-[200] shadow-xl bg-panel-alt shadow-black/25 rounded-t-2xl overflow-hidden"
|
|
: "min-w-full shadow-xl bg-panel-alt shadow-black/25 absolute top-full mt-2 z-50 rounded-2xl overflow-hidden",
|
|
!$isMobile && {
|
|
"w-[320%]": dropdownSize === "large",
|
|
"w-[250%]": dropdownSize === "default",
|
|
"w-[150%]": dropdownSize === "small",
|
|
},
|
|
!$isMobile && {
|
|
"-translate-x-1/2 left-1/2": dropdownPosition === "center",
|
|
"left-0": dropdownPosition === "left",
|
|
"right-0": dropdownPosition === "right",
|
|
},
|
|
)}
|
|
>
|
|
<!-- search box -->
|
|
<div class="p-3 w-full">
|
|
<div class="relative">
|
|
<input
|
|
type="text"
|
|
placeholder={m["convert.dropdown.placeholder"]()}
|
|
class="flex-grow w-full !pl-11 !pr-3 rounded-lg bg-panel text-foreground {filteredData.isFallback
|
|
? 'opacity-50 cursor-not-allowed'
|
|
: ''}"
|
|
bind:value={searchQuery}
|
|
oninput={handleSearch}
|
|
onkeydown={onEnter}
|
|
onfocus={() => {}}
|
|
id="format-search"
|
|
autocomplete="off"
|
|
disabled={filteredData.isFallback}
|
|
/>
|
|
<span
|
|
class="absolute left-4 top-1/2 -translate-y-1/2 flex items-center {filteredData.isFallback
|
|
? 'opacity-50'
|
|
: ''}"
|
|
>
|
|
<SearchIcon class="w-4 h-4" />
|
|
</span>
|
|
{#if searchQuery}
|
|
<span
|
|
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted"
|
|
style="font-size: 0.7rem;"
|
|
>
|
|
{filteredData.formats.length}
|
|
{filteredData.formats.length === 1
|
|
? "result"
|
|
: "results"}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<!-- fallback message -->
|
|
{#if filteredData.isFallback}
|
|
<div class="pb-4 text-center text-muted text-base">
|
|
{m["convert.dropdown.fallback"]()}
|
|
</div>
|
|
{/if}
|
|
<!-- available categories -->
|
|
<div class="flex items-center justify-between">
|
|
{#each filteredData.categories as category}
|
|
<button
|
|
class="flex-grow text-lg hover:text-muted/20 border-b-[1px] pb-2 capitalize
|
|
{currentCategory === category
|
|
? 'text-accent border-b-accent'
|
|
: 'border-b-separator text-muted'}"
|
|
onclick={() => selectCategory(category)}
|
|
>
|
|
{(m as any)[`convert.dropdown.${category}`]?.()}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
<!-- available formats -->
|
|
<div class="max-h-80 overflow-y-auto grid grid-cols-3 gap-2 p-2">
|
|
{#if filteredData.formats.length > 0}
|
|
{#each filteredData.formats as format}
|
|
<button
|
|
class="w-full p-2 text-center rounded-xl
|
|
{format === selected
|
|
? 'bg-accent text-black'
|
|
: format === from
|
|
? 'bg-separator'
|
|
: 'hover:bg-panel'}"
|
|
onclick={() => selectOption(format)}
|
|
>
|
|
{format}
|
|
</button>
|
|
{/each}
|
|
{:else}
|
|
<div class="col-span-3 text-center p-4 text-muted">
|
|
{searchQuery
|
|
? m["convert.dropdown.no_results"]()
|
|
: m["convert.dropdown.no_formats"]()}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<!-- format options -->
|
|
<!-- TODO: extract zip, image sequence & fps -->
|
|
{#if file?.name.toLowerCase().endsWith(".zip")}
|
|
<div class="border-t border-separator text-base p-2">
|
|
<button
|
|
class="w-full p-2 text-center rounded-lg bg-accent text-black"
|
|
onclick={() => extract()}
|
|
>
|
|
{m["convert.archive_file.extract"]()}
|
|
</button>
|
|
</div>
|
|
{:else if file}
|
|
<div class="border-t border-separator text-base p-2">
|
|
<button
|
|
class="w-full p-2 text-center rounded-lg bg-accent text-black"
|
|
onclick={() => settings()}
|
|
>
|
|
{m["convert.settings.settings"]()}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|