mirror of https://github.com/VERT-sh/VERT.git
feat: zip downloading, file conversion
This commit is contained in:
parent
9d43cb7b03
commit
82046767cd
|
@ -35,6 +35,7 @@
|
|||
"@fontsource/azeret-mono": "^5.1.0",
|
||||
"@fontsource/lexend": "^5.1.1",
|
||||
"@imagemagick/magick-wasm": "^0.0.31",
|
||||
"client-zip": "^2.4.5",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-svelte": "^0.456.0",
|
||||
"svelte-adapter-bun": "^0.5.2",
|
||||
|
|
10
src/app.css
10
src/app.css
|
@ -24,3 +24,13 @@ body {
|
|||
@apply text-foreground bg-background font-body overflow-x-hidden;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
select {
|
||||
@apply appearance-none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply font-display flex items-center justify-center overflow-hidden relative cursor-pointer px-4 border-2 border-solid bg-background border-foreground-muted-alt rounded-xl p-2 focus:!outline-none hover:scale-105 transition-transform duration-200 active:scale-95;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,6 +29,8 @@ const choose = (
|
|||
? outValue
|
||||
: defaultValue;
|
||||
|
||||
type Combination<T extends string, U extends string> = `${T} ${U}`;
|
||||
|
||||
export const blur = (
|
||||
_: HTMLElement,
|
||||
config:
|
||||
|
@ -50,6 +52,10 @@ export const blur = (
|
|||
};
|
||||
delay: number;
|
||||
opacity: boolean;
|
||||
origin: Combination<
|
||||
"top" | "bottom" | "left" | "right" | "center",
|
||||
"top" | "bottom" | "left" | "right" | "center"
|
||||
>;
|
||||
}>
|
||||
| undefined,
|
||||
dir: {
|
||||
|
@ -120,7 +126,7 @@ export const blur = (
|
|||
: ``;
|
||||
return `filter: blur(${(1 - t) * (config?.blurMultiplier || 1)}px); opacity: ${config?.opacity ? t : 1}; transform: ${
|
||||
translate
|
||||
};`;
|
||||
}; ${config?.origin ? `transform-origin: ${config.origin};` : ""}`;
|
||||
},
|
||||
easing: config?.easing,
|
||||
};
|
||||
|
|
|
@ -0,0 +1,128 @@
|
|||
<script lang="ts">
|
||||
import { blur, duration, flip, transition } from "$lib/animation";
|
||||
import { ChevronDown } from "lucide-svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { quintOut } from "svelte/easing";
|
||||
import { fade } from "svelte/transition";
|
||||
|
||||
type Props = {
|
||||
options: string[];
|
||||
selected?: string;
|
||||
onselect?: (option: string) => void;
|
||||
};
|
||||
|
||||
let { options, selected = $bindable(), onselect }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let isUp = $state(false);
|
||||
let selectedWidth = $state(100 - 64);
|
||||
let dropdown = $state<HTMLDivElement>();
|
||||
|
||||
const toggle = () => {
|
||||
open = !open;
|
||||
};
|
||||
|
||||
const select = (option: string) => {
|
||||
const oldIndex = options.indexOf(selected || "");
|
||||
const newIndex = options.indexOf(option);
|
||||
isUp = oldIndex > newIndex;
|
||||
selected = option;
|
||||
onselect?.(option);
|
||||
toggle();
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
selected = selected || options[0];
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const click = (e: MouseEvent) => {
|
||||
if (dropdown && !dropdown.contains(e.target as Node)) {
|
||||
open = false;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("click", click);
|
||||
return () => window.removeEventListener("click", click);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative" bind:this={dropdown}>
|
||||
<button
|
||||
style="width: {selectedWidth + 64}px; transition: width 100ms ease;"
|
||||
class="font-display justify-between overflow-hidden relative cursor-pointer px-3 border-2 border-solid flex items-center bg-background border-foreground-muted-alt rounded-xl p-2 focus:!outline-none"
|
||||
onclick={toggle}
|
||||
>
|
||||
<!-- <p>{selected}</p> -->
|
||||
<div class="grid grid-cols-1 grid-rows-1 w-fit">
|
||||
{#key selected}
|
||||
<p
|
||||
bind:clientWidth={selectedWidth}
|
||||
in:blur={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
blurMultiplier: 6,
|
||||
scale: {
|
||||
start: 0.5,
|
||||
end: 1,
|
||||
},
|
||||
y: {
|
||||
start: isUp ? 50 : -50,
|
||||
end: 0,
|
||||
},
|
||||
}}
|
||||
out:blur={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
blurMultiplier: 6,
|
||||
scale: {
|
||||
start: 1,
|
||||
end: 0.5,
|
||||
},
|
||||
y: {
|
||||
start: 0,
|
||||
end: isUp ? -50 : 50,
|
||||
},
|
||||
}}
|
||||
class="col-start-1 row-start-1"
|
||||
>
|
||||
{selected}
|
||||
</p>
|
||||
{/key}
|
||||
</div>
|
||||
<ChevronDown
|
||||
class="w-4 h-4 ml-4 mt-0.5 flex-shrink-0"
|
||||
style="transform: rotate({open
|
||||
? 180
|
||||
: 0}deg); transition: transform {duration}ms {transition};"
|
||||
/>
|
||||
</button>
|
||||
{#if open}
|
||||
<div
|
||||
transition:blur={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
blurMultiplier: 6,
|
||||
scale: {
|
||||
start: 0.9,
|
||||
end: 1,
|
||||
},
|
||||
y: {
|
||||
start: -10,
|
||||
end: 0,
|
||||
},
|
||||
origin: "top center",
|
||||
}}
|
||||
class="w-full shadow-xl shadow-black/25 absolute overflow-hidden top-full mt-1 left-0 z-50 bg-background border-2 border-solid border-foreground-muted-alt rounded-xl"
|
||||
>
|
||||
{#each options as option}
|
||||
<button
|
||||
class="w-full p-2 px-4 text-left hover:bg-foreground-muted-alt brightness-125"
|
||||
onclick={() => select(option)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
|
@ -4,6 +4,10 @@ import type { IFile, OmitBetterStrict } from "$lib/types";
|
|||
* Base class for all converters.
|
||||
*/
|
||||
export class Converter {
|
||||
/**
|
||||
* The public name of the converter.
|
||||
*/
|
||||
public name: string = "Unknown";
|
||||
/**
|
||||
* List of supported formats.
|
||||
*/
|
||||
|
|
|
@ -7,6 +7,7 @@ import type { VipsWorkerMessage, OmitBetterStrict } from "$lib/types";
|
|||
export class VipsConverter extends Converter {
|
||||
private worker: Worker = browser ? new VipsWorker() : null!;
|
||||
private id = 0;
|
||||
public name = "Vips";
|
||||
public supportedFormats = [
|
||||
".jpg",
|
||||
".jpeg",
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import type { IFile } from "$lib/types";
|
||||
|
||||
class Files {
|
||||
public files = $state<
|
||||
{
|
||||
|
@ -6,6 +8,7 @@ class Files {
|
|||
to: string;
|
||||
blobUrl: string;
|
||||
id: string;
|
||||
result?: (IFile & { blobUrl: string; animating: boolean }) | null;
|
||||
}[]
|
||||
>([]);
|
||||
public conversionTypes = $state<string[]>([]);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { blur, duration, flip, transition } from "$lib/animation";
|
||||
import Dropdown from "$lib/components/functional/Dropdown.svelte";
|
||||
import ProgressiveBlur from "$lib/components/visual/effects/ProgressiveBlur.svelte";
|
||||
import { converters } from "$lib/converters";
|
||||
import { files } from "$lib/store/index.svelte";
|
||||
|
@ -7,6 +8,7 @@
|
|||
import { XIcon } from "lucide-svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { quintOut } from "svelte/easing";
|
||||
import { downloadZip } from "client-zip";
|
||||
|
||||
const reversed = $derived(files.files.slice().reverse());
|
||||
|
||||
|
@ -14,6 +16,8 @@
|
|||
Array.from({ length: files.files.length }, () => false),
|
||||
);
|
||||
|
||||
let converterName = $state(converters[0].name);
|
||||
|
||||
onMount(() => {
|
||||
finisheds.forEach((_, i) => {
|
||||
const duration = 750 + i * 50 - 32;
|
||||
|
@ -23,6 +27,119 @@
|
|||
}, duration);
|
||||
});
|
||||
});
|
||||
|
||||
const convertAll = async () => {
|
||||
// for (let i = 0; i < files.files.length; i++) {
|
||||
// const file = files.files[i];
|
||||
// const to = files.conversionTypes[i];
|
||||
// const converter = converters.find(
|
||||
// (c) => c.name === files.conversionTypes[i],
|
||||
// );
|
||||
// if (!converter) {
|
||||
// console.error("Converter not found");
|
||||
// continue;
|
||||
// }
|
||||
// const converted = await converter.convert({
|
||||
// name: file.file.name,
|
||||
// buffer: await file.file.arrayBuffer(),
|
||||
// }, to);
|
||||
// files.files[i] = {
|
||||
// ...file,
|
||||
// file: new File([converted.buffer], file.file.name, {
|
||||
// type: file.file.type,
|
||||
// }),
|
||||
// blobUrl: URL.createObjectURL(new Blob([converted.buffer], { type: file.file.type })),
|
||||
// };
|
||||
// }
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
for (let i = 0; i < files.files.length; i++) {
|
||||
const file = files.files[i];
|
||||
const to = files.conversionTypes[i];
|
||||
const converter = converters.find((c) => c.name === converterName);
|
||||
if (!converter) {
|
||||
console.error("Converter not found");
|
||||
continue;
|
||||
}
|
||||
promises.push(
|
||||
(async () => {
|
||||
const converted = await converter.convert(
|
||||
{
|
||||
name: file.file.name,
|
||||
buffer: await file.file.arrayBuffer(),
|
||||
},
|
||||
to,
|
||||
);
|
||||
files.files[i] = {
|
||||
...file,
|
||||
result: {
|
||||
...converted,
|
||||
blobUrl: URL.createObjectURL(
|
||||
new Blob([converted.buffer], {
|
||||
type: file.file.type,
|
||||
}),
|
||||
),
|
||||
animating: true,
|
||||
},
|
||||
};
|
||||
await new Promise((r) => setTimeout(r, 750));
|
||||
if (
|
||||
files.files[i].result !== null &&
|
||||
files.files[i].result !== undefined
|
||||
)
|
||||
files.files[i].result!.animating = false;
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
console.log("done");
|
||||
};
|
||||
|
||||
const downloadAll = async () => {
|
||||
const dlFiles: any[] = [];
|
||||
for (let i = 0; i < files.files.length; i++) {
|
||||
const file = files.files[i];
|
||||
const result = file.result;
|
||||
if (!result) {
|
||||
console.error("No result found");
|
||||
continue;
|
||||
}
|
||||
dlFiles.push({
|
||||
name:
|
||||
file.file.name.replace(/\.[^/.]+$/, "") +
|
||||
files.conversionTypes[i],
|
||||
lastModified: Date.now(),
|
||||
input: result.buffer,
|
||||
});
|
||||
}
|
||||
if (files.files.length === 0) return;
|
||||
if (files.files.length === 1) {
|
||||
// download the image only
|
||||
const blob = URL.createObjectURL(
|
||||
new Blob([dlFiles[0].input], {
|
||||
type: files.files[0].file.type,
|
||||
}),
|
||||
);
|
||||
const a = document.createElement("a");
|
||||
a.href = blob;
|
||||
a.download = `${new Date().toISOString()}-vert-converted${
|
||||
files.conversionTypes[0]
|
||||
}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(blob);
|
||||
a.remove();
|
||||
return;
|
||||
}
|
||||
const blob = await downloadZip(dlFiles, "converted.zip").blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${new Date().toISOString()}-vert-converted.zip`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 grid-rows-1 w-full">
|
||||
|
@ -30,98 +147,168 @@
|
|||
<p class="text-foreground-muted col-start-1 row-start-1 text-center">
|
||||
No files uploaded. Head to the Upload tab to begin!
|
||||
</p>
|
||||
{/if}
|
||||
<div
|
||||
class="flex flex-col gap-4 w-full items-center col-start-1 row-start-1"
|
||||
>
|
||||
{#each reversed as file, i (file.id)}
|
||||
{:else}
|
||||
<div
|
||||
class="flex flex-col gap-4 w-full items-center col-start-1 row-start-1"
|
||||
out:blur={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
blurMultiplier: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
animate:flip={{ duration, easing: quintOut }}
|
||||
out:blur={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
blurMultiplier: 16,
|
||||
}}
|
||||
class={clsx(
|
||||
"h-16 px-3 flex relative flex-shrink-0 items-center w-full border-2 border-solid border-foreground-muted-alt rounded-xl",
|
||||
{
|
||||
"initial-fade": !finisheds[i],
|
||||
},
|
||||
)}
|
||||
style="--delay: {i *
|
||||
50}ms; --transition: {transition}; --duration: {duration}ms;"
|
||||
class="w-full px-4 py-3 max-w-screen-lg p-1 border-solid flex-col border-2 rounded-2xl border-foreground-muted-alt flex flex-shrink-0"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between w-full z-50 relative"
|
||||
>
|
||||
<div
|
||||
class="py-2 px-3 bg-background text-foreground rounded-xl"
|
||||
>
|
||||
{file.file.name}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-shrink-0">
|
||||
{#if converters[0].supportedFormats.includes(file.from)}
|
||||
<span>from</span>
|
||||
<span
|
||||
class="py-2 px-3 font-display bg-foreground text-background rounded-xl"
|
||||
>{file.from}</span
|
||||
>
|
||||
<span>to</span>
|
||||
<select
|
||||
class="font-display border-2 border-solid border-foreground-muted-alt rounded-xl p-2 focus:!outline-none"
|
||||
bind:value={files.conversionTypes[i]}
|
||||
>
|
||||
{#each converters[0].supportedFormats as conversionType}
|
||||
<option value={conversionType}
|
||||
>{conversionType}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<span
|
||||
class="py-2 px-3 font-display bg-foreground-failure text-white rounded-xl"
|
||||
>{file.from}</span
|
||||
>
|
||||
|
||||
<span class="text-foreground-failure">
|
||||
is not supported!
|
||||
</span>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => {
|
||||
// delete the file from the list
|
||||
files.files = files.files.filter(
|
||||
(f) => f !== file,
|
||||
);
|
||||
}}
|
||||
class="ml-2 mr-1"
|
||||
>
|
||||
<XIcon size="18" />
|
||||
</button>
|
||||
<h2 class="font-bold text-xl mb-3">Options</h2>
|
||||
<div class="flex flex-col gap-4 mt-2">
|
||||
<div class="w-fit flex flex-col items-center gap-2">
|
||||
<h3 class="mr-5">Converter</h3>
|
||||
<Dropdown
|
||||
options={converters.map(
|
||||
(converter) => converter.name,
|
||||
)}
|
||||
bind:selected={converterName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if converters[0].supportedFormats.includes(file.from)}
|
||||
<!-- god knows why, but setting opacity > 0.98 causes a z-ordering issue in firefox ??? -->
|
||||
<h2 class="font-bold text-xl mb-3 mt-6">Quick Actions</h2>
|
||||
<div class="flex flex-col mb-1 w-full gap-4 mt-2">
|
||||
<div class="flex flex-col gap-4 w-fit">
|
||||
<h3>Set all formats</h3>
|
||||
<Dropdown
|
||||
options={converters[0].supportedFormats}
|
||||
onselect={(o) => {
|
||||
files.conversionTypes = Array.from(
|
||||
{ length: files.files.length },
|
||||
() => o,
|
||||
);
|
||||
|
||||
files.files.forEach((file) => {
|
||||
file.result = null;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<button onclick={convertAll} class="btn flex-grow"
|
||||
>Convert{files.files.length > 1
|
||||
? " All"
|
||||
: ""}</button
|
||||
>
|
||||
<button onclick={downloadAll} class="btn flex-grow"
|
||||
>Download{files.files.length > 1
|
||||
? " All"
|
||||
: ""}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#each reversed as file, i (file.id)}
|
||||
<div
|
||||
class={clsx("w-full rounded-xl", {
|
||||
"finished-anim": file.result?.animating,
|
||||
})}
|
||||
animate:flip={{ duration, easing: quintOut }}
|
||||
out:blur={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
blurMultiplier: 16,
|
||||
}}
|
||||
style="--transition: ease-in-out;"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 -z-50 left-0 w-full h-full rounded-[10px] overflow-hidden opacity-[0.98]"
|
||||
class={clsx(
|
||||
"h-16 px-3 flex relative flex-shrink-0 items-center w-full rounded-xl",
|
||||
{
|
||||
"initial-fade": !finisheds[i],
|
||||
},
|
||||
)}
|
||||
style="--delay: {i *
|
||||
50}ms; --transition: {transition}; --duration: {duration}ms; z-index: {files
|
||||
.files.length - i}; border: solid 3px {file.result
|
||||
? 'var(--accent-bg)'
|
||||
: 'var(--fg-muted-alt)'}; transition: border 1000ms ease;"
|
||||
>
|
||||
<div
|
||||
class="bg-cover bg-center w-full h-full"
|
||||
style="background-image: url({file.blobUrl});"
|
||||
></div>
|
||||
<div class="absolute top-0 right-0 w-5/6 h-full">
|
||||
<ProgressiveBlur
|
||||
direction="right"
|
||||
endIntensity={128}
|
||||
iterations={6}
|
||||
fadeTo="rgba(255, 255, 255, 0.8)"
|
||||
/>
|
||||
class="flex items-center justify-between w-full z-50 relative"
|
||||
>
|
||||
<div
|
||||
class="py-2 px-3 bg-background text-foreground rounded-xl"
|
||||
>
|
||||
{file.file.name}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-shrink-0">
|
||||
{#if converters[0].supportedFormats.includes(file.from)}
|
||||
<span>from</span>
|
||||
<span
|
||||
class="py-2 px-3 font-display bg-foreground text-background rounded-xl"
|
||||
>{file.from}</span
|
||||
>
|
||||
<span>to</span>
|
||||
<!-- <select bind:value={files.conversionTypes[i]}>
|
||||
{#each converters[0].supportedFormats as conversionType}
|
||||
<option value={conversionType}
|
||||
>{conversionType}</option
|
||||
>
|
||||
{/each}
|
||||
</select> -->
|
||||
<Dropdown
|
||||
options={converters[0].supportedFormats}
|
||||
bind:selected={files.conversionTypes[i]}
|
||||
onselect={() => {
|
||||
file.result = null;
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="py-2 px-3 font-display bg-foreground-failure text-white rounded-xl"
|
||||
>{file.from}</span
|
||||
>
|
||||
|
||||
<span class="text-foreground-failure">
|
||||
is not supported!
|
||||
</span>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => {
|
||||
// delete the file from the list
|
||||
files.files = files.files.filter(
|
||||
(f) => f !== file,
|
||||
);
|
||||
}}
|
||||
class="ml-2 mr-1"
|
||||
>
|
||||
<XIcon size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if converters[0].supportedFormats.includes(file.from)}
|
||||
<!-- god knows why, but setting opacity > 0.98 causes a z-ordering issue in firefox ??? -->
|
||||
<div
|
||||
class="absolute top-0 -z-50 left-0 w-full h-full rounded-[10px] overflow-hidden opacity-[0.98]"
|
||||
>
|
||||
<div
|
||||
class="bg-cover bg-center w-full h-full"
|
||||
style="background-image: url({file.blobUrl});"
|
||||
></div>
|
||||
<div
|
||||
class="absolute top-0 right-0 w-5/6 h-full"
|
||||
>
|
||||
<ProgressiveBlur
|
||||
direction="right"
|
||||
endIntensity={128}
|
||||
iterations={6}
|
||||
fadeTo="rgba(255, 255, 255, 0.8)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="w-full h-4 flex-shrink-0"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
@ -139,6 +326,23 @@
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes finished-animation {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.02);
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
.initial-fade {
|
||||
animation: initial-transition 750ms var(--delay) ease-out;
|
||||
animation-timing-function: var(--transition);
|
||||
|
@ -149,4 +353,9 @@
|
|||
animation: none;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.finished-anim {
|
||||
animation: finished-animation 750ms;
|
||||
animation-timing-function: var(--transition);
|
||||
}
|
||||
</style>
|
||||
|
|
Loading…
Reference in New Issue