feat: zip downloading, file conversion

This commit is contained in:
not-nullptr 2024-11-12 11:04:40 +00:00
parent 9d43cb7b03
commit 82046767cd
9 changed files with 446 additions and 84 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -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",

View File

@ -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;
}
}

View File

@ -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,
};

View File

@ -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>

View File

@ -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.
*/

View File

@ -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",

View File

@ -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[]>([]);

View File

@ -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>