feat: conversion page redesign

This commit is contained in:
not-nullptr 2024-11-14 13:34:51 +00:00
parent b1faad1c8c
commit 549c61a108
7 changed files with 339 additions and 163 deletions

View File

@ -0,0 +1,18 @@
<script lang="ts">
type Props = {
progress: number;
min: number;
max: number;
};
let { progress, min, max }: Props = $props();
const percent = $derived(((progress - min) / (max - min)) * 100);
</script>
<div class="w-full h-1 bg-foreground-muted rounded-full overflow-hidden">
<div
class="h-full bg-accent-background"
style="width: {percent}%; transition: 500ms linear width;"
></div>
</div>

View File

@ -41,15 +41,21 @@ export class VipsConverter extends Converter {
public async convert(input: VertFile, to: string): Promise<VertFile> { public async convert(input: VertFile, to: string): Promise<VertFile> {
log(["converters", this.name], `converting ${input.name} to ${to}`); log(["converters", this.name], `converting ${input.name} to ${to}`);
const res = await this.sendMessage({ const msg = {
type: "convert", type: "convert",
input, input: {
file: input.file,
name: input.name,
to: input.to,
from: input.from,
},
to, to,
}); } as WorkerMessage;
const res = await this.sendMessage(msg);
if (res.type === "finished") { if (res.type === "finished") {
log(["converters", this.name], `converted ${input.name} to ${to}`); log(["converters", this.name], `converted ${input.name} to ${to}`);
return res.output; return new VertFile(new File([res.output], input.name), to);
} }
if (res.type === "error") { if (res.type === "error") {
@ -81,8 +87,13 @@ export class VipsConverter extends Converter {
}, 60000); }, 60000);
this.worker.addEventListener("message", onMessage); this.worker.addEventListener("message", onMessage);
const msg = { ...message, id, worker: null };
this.worker.postMessage({ ...message, id }); log(["converters", this.name], `sending message`, msg);
try {
this.worker.postMessage(msg);
} catch (e) {
console.error(e);
}
}); });
} }
} }

View File

@ -8,7 +8,7 @@ interface ConvertMessage {
interface FinishedMessage { interface FinishedMessage {
type: "finished"; type: "finished";
output: VertFile; output: ArrayBufferLike;
} }
interface LoadedMessage { interface LoadedMessage {

View File

@ -1,3 +1,5 @@
import type { Converter } from "$lib/converters/converter.svelte";
export class VertFile { export class VertFile {
public id: string = Math.random().toString(36).slice(2, 8); public id: string = Math.random().toString(36).slice(2, 8);
@ -10,16 +12,46 @@ export class VertFile {
} }
public progress = $state(0); public progress = $state(0);
// public result: VertFile | null = null;
public result = $state<VertFile | null>(null); public result = $state<VertFile | null>(null);
public to = $state(""); public to = $state("");
public converter: Converter | null = null;
constructor( constructor(
public readonly file: File, public readonly file: File,
to: string, to: string,
converter?: Converter,
public readonly blobUrl?: string, public readonly blobUrl?: string,
) { ) {
this.to = to; this.to = to;
this.converter = converter ?? null;
this.convert = this.convert.bind(this);
this.download = this.download.bind(this);
}
public async convert() {
console.log(this.converter);
if (!this.converter) throw new Error("No converter found");
this.result = null;
this.progress = 0;
const res = await this.converter.convert(this, this.to);
this.result = res;
return res;
}
public async download() {
if (!this.result) throw new Error("No result found");
const blob = URL.createObjectURL(
new Blob([await this.result.file.arrayBuffer()], {
type: this.to.slice(1),
}),
);
const a = document.createElement("a");
a.href = blob;
a.download = `VERT-Converted_${new Date().toISOString()}${this.to}`;
a.click();
URL.revokeObjectURL(blob);
a.remove();
} }
} }

View File

@ -1,8 +1,4 @@
import { import { type WorkerMessage, type OmitBetterStrict } from "$lib/types";
type WorkerMessage,
type OmitBetterStrict,
VertFile,
} from "$lib/types";
import Vips from "wasm-vips"; import Vips from "wasm-vips";
const vipsPromise = Vips({ const vipsPromise = Vips({
@ -32,10 +28,7 @@ const handleMessage = async (
image.delete(); image.delete();
return { return {
type: "finished", type: "finished",
output: new VertFile( output: output.buffer,
new File([output.buffer], message.input.name),
message.to,
),
}; };
} }
} }

View File

@ -2,9 +2,9 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import Uploader from "$lib/components/functional/Uploader.svelte"; import Uploader from "$lib/components/functional/Uploader.svelte";
import { converters } from "$lib/converters"; import { converters } from "$lib/converters";
import { log } from "$lib/logger/index.js"; import { log } from "$lib/logger";
import { files } from "$lib/store/index.svelte"; import { files } from "$lib/store/index.svelte";
import { VertFile } from "$lib/types/file.svelte.js"; import { VertFile } from "$lib/types/file.svelte";
import { Check } from "lucide-svelte"; import { Check } from "lucide-svelte";
const { data } = $props(); const { data } = $props();
@ -48,6 +48,7 @@
new VertFile( new VertFile(
f, f,
to, to,
converter,
URL.createObjectURL(blob!), URL.createObjectURL(blob!),
), ),
); );
@ -58,7 +59,7 @@
}; };
img.onerror = async () => { img.onerror = async () => {
resolve(new VertFile(f, to)); resolve(new VertFile(f, to, converter));
}; };
}, },
); );

View File

@ -3,10 +3,12 @@
import { blur, duration, flip } from "$lib/animation"; import { blur, duration, flip } from "$lib/animation";
import Dropdown from "$lib/components/functional/Dropdown.svelte"; import Dropdown from "$lib/components/functional/Dropdown.svelte";
import ProgressiveBlur from "$lib/components/visual/effects/ProgressiveBlur.svelte"; import ProgressiveBlur from "$lib/components/visual/effects/ProgressiveBlur.svelte";
import ProgressBar from "$lib/components/visual/ProgressBar.svelte";
import { converters } from "$lib/converters"; import { converters } from "$lib/converters";
import type { Converter } from "$lib/converters/converter.svelte"; import type { Converter } from "$lib/converters/converter.svelte";
import { log } from "$lib/logger"; import { log } from "$lib/logger";
import { files } from "$lib/store/index.svelte"; import { files } from "$lib/store/index.svelte";
import type { VertFile } from "$lib/types";
import clsx from "clsx"; import clsx from "clsx";
import { ArrowRight, XIcon } from "lucide-svelte"; import { ArrowRight, XIcon } from "lucide-svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
@ -19,6 +21,7 @@
); );
let isSm = $state(false); let isSm = $state(false);
let isLg = $state(false);
let processings = $state<boolean[]>([]); let processings = $state<boolean[]>([]);
@ -49,8 +52,10 @@
onMount(() => { onMount(() => {
isSm = window.innerWidth < 640; isSm = window.innerWidth < 640;
isLg = window.innerWidth > 1024;
window.addEventListener("resize", () => { window.addEventListener("resize", () => {
isSm = window.innerWidth < 640; isSm = window.innerWidth < 640;
isLg = window.innerWidth > 1024;
}); });
}); });
@ -71,20 +76,9 @@
const promises: Promise<void>[] = []; const promises: Promise<void>[] = [];
for (let i = 0; i < files.files.length; i++) { for (let i = 0; i < files.files.length; i++) {
promises.push( promises.push(
(async () => { (async (i) => {
const file = files.files[i]; await convert(files.files[i]);
const converter = converters.find( })(i),
(c) =>
c.supportedFormats.includes(file.from) &&
c.supportedFormats.includes(file.to),
);
if (!converter) throw new Error("No converter found");
const to = file.to;
processings[i] = true;
const converted = await converter.convert(file, to);
file.result = converted;
processings[i] = false;
})(),
); );
} }
@ -94,6 +88,14 @@
log(["converter"], `converted all files in ${seconds}s`); log(["converter"], `converted all files in ${seconds}s`);
}; };
const convert = async (file: VertFile) => {
file.progress = 0;
const index = files.files.findIndex((f) => f === file);
processings[index] = true;
await file.convert();
processings[index] = false;
};
const downloadAll = async () => { const downloadAll = async () => {
const dlFiles: any[] = []; const dlFiles: any[] = [];
for (let i = 0; i < files.files.length; i++) { for (let i = 0; i < files.files.length; i++) {
@ -233,149 +235,249 @@
> >
</div> </div>
</div> </div>
{#each reversedFiles as file, i (file.id)} <div
{@const converter = (() => { class="w-full lg:grid flex flex-col lg:gap-8 gap-4"
return converters.find((c) => style="grid-template-columns: 1fr 1fr;"
c.supportedFormats.includes(file.from), >
); {#each reversedFiles as file, i (file.id)}
})()} {@const converter = (() => {
<div return converters.find((c) =>
class="w-full rounded-xl relative" c.supportedFormats.includes(file.from),
animate:flip={{ duration, easing: quintOut }} );
out:blur={{ })()}
duration,
easing: quintOut,
blurMultiplier: 16,
}}
>
<div <div
class={clsx( class="w-full rounded-xl relative lg:h-48"
"sm:h-16 sm:py-0 py-4 px-3 flex relative overflow-hidden flex-shrink-0 items-center w-full rounded-xl", animate:flip={{ duration, easing: quintOut }}
{ out:blur={{
"initial-fade": !finisheds[i], duration,
processing: easing: quintOut,
processings[files.files.length - i - 1], blurMultiplier: 16,
}, }}
)}
style="--delay: {i * 50}ms; z-index: {files.files
.length - i}; border: solid 3px {file.result
? 'var(--accent-bg)'
: 'var(--fg-muted-alt)'}; transition: border 1000ms ease; transition: filter {duration}ms var(--transition), transform {duration}ms var(--transition);"
> >
<!-- <div <div
class={clsx(
"sm:h-full lg:py-0 py-4 px-3 flex relative flex-shrink-0 items-center w-full rounded-xl",
{
"initial-fade": !finisheds[i],
processing: processings[i] && !isLg,
},
)}
style="--delay: {i * 50}ms; z-index: {files.files
.length - i}; border: solid 2px {file.result
? 'var(--accent-bg)'
: 'var(--fg-muted-alt)'}; transition: border 1000ms ease; transition: filter {duration}ms var(--transition), transform {duration}ms var(--transition);"
>
<!-- <div
class="absolute top-0 left-0 bg-red-500 h-full" class="absolute top-0 left-0 bg-red-500 h-full"
style="width: {file.progress}%; transition: width 500ms linear;" style="width: {file.progress}%; transition: width 500ms linear;"
></div> --> ></div> -->
<div
class="flex gap-8 sm:gap-0 sm:flex-row flex-col items-center justify-between w-full z-50 relative sm:h-fit h-full"
>
<div <div
class={clsx( class="flex gap-8 lg:flex-grow lg:h-full sm:gap-0 lg:py-4 lg:justify-normal sm:flex-row lg:flex-col flex-col items-center justify-between w-full z-50 relative sm:h-fit h-full"
"py-2 px-3 rounded-xl transition-colors duration-300 sm:w-fit w-full sm:text-left text-center",
{
"bg-accent-background text-accent-foreground":
file.result,
"bg-background text-foreground":
!file.result,
},
)}
> >
{file.file.name} <div class="w-full lg:flex-grow">
</div>
<div
class="flex items-center gap-3 sm:justify-normal w-full sm:w-fit flex-shrink-0"
>
{#if converter && converter.supportedFormats.includes(file.from)}
<span class="sm:block hidden">from</span>
<span
class="py-2 px-3 font-display bg-foreground text-background rounded-xl sm:block hidden"
>{file.from}</span
>
<span class="sm:block hidden">to</span>
<div class="sm:block hidden">
<Dropdown
options={converter.supportedFormats}
bind:selected={files.files[
files.files.length - i - 1
].to}
onselect={() => {
file.result = null;
}}
/>
</div>
<div class="w-full sm:hidden block h-11">
<div
class="py-2 px-3 font-display bg-foreground text-background rounded-xl"
>
{file.from}
</div>
</div>
<div <div
class="w-full sm:hidden h-full flex justify-center items-center" class={clsx(
"py-2 px-3 lg:w-full lg:flex rounded-xl transition-colors duration-300 sm:w-fit w-full flex-shrink sm:text-left text-center",
{
"bg-accent-background text-accent-foreground":
file.result,
"bg-background text-foreground":
!file.result,
},
)}
> >
<ArrowRight <h3
class="w-6 h-6 text-accent-foreground" class="lg:flex-grow flex-shrink whitespace-nowrap overflow-hidden text-ellipsis sm:max-w-96 lg:max-w-none"
/> >
</div> {file.file.name}
<div class="w-full sm:hidden block h-full"> </h3>
<Dropdown <button
options={converter.supportedFormats} onclick={() => {
bind:selected={files.files[ // delete the file from the list
files.files.length - 1 - i files.files =
].to} files.files.filter(
onselect={() => { (f) => f !== file,
file.result = null; );
if (files.files.length === 0)
goto("/");
}} }}
/> class="ml-2 mr-1 lg:block hidden flex-shrink-0"
>
<XIcon size="18" />
</button>
</div> </div>
{:else} </div>
<span <div
class="py-2 px-3 font-display bg-foreground-failure text-white rounded-xl" class="flex items-center gap-3 sm:justify-normal w-full sm:w-fit flex-shrink-0 lg:w-full"
>{file.from}</span >
<div
class="flex flex-col items-center gap-3 w-full"
> >
{#if processings[i]}
<div
class="w-full lg:block hidden"
transition:blur={{
blurMultiplier: 6,
duration,
easing: quintOut,
scale: {
start: 0.9,
end: 1,
},
}}
>
<ProgressBar
min={0}
max={100}
progress={file.result
? 100
: file.progress}
/>
</div>
{/if}
<div
class="flex items-center gap-3 w-full"
>
{#if converter && converter.supportedFormats.includes(file.from)}
<span
class="sm:block hidden lg:hidden"
>from</span
>
<span
class="py-2 lg:hidden px-3 font-display bg-foreground text-background rounded-xl sm:block hidden"
>{file.from}</span
>
<span
class="sm:block lg:hidden hidden"
>to</span
>
<span
class="hidden lg:block whitespace-nowrap"
>Convert to</span
>
<div
class="sm:block hidden lg:w-full"
>
<Dropdown
options={converter.supportedFormats}
bind:selected={files
.files[
files.files.length -
i -
1
].to}
onselect={() => {
file.result = null;
}}
/>
</div>
<div
class="w-full sm:hidden block h-11"
>
<div
class="py-2 px-3 font-display bg-foreground text-background rounded-xl"
>
{file.from}
</div>
</div>
<div
class="w-full sm:hidden h-full flex justify-center items-center"
>
<ArrowRight
class="w-6 h-6 text-accent-foreground"
/>
</div>
<div
class="w-full sm:hidden block h-full"
>
<Dropdown
options={converter.supportedFormats}
bind:selected={files
.files[
files.files.length -
1 -
i
].to}
onselect={() => {
file.result = null;
}}
/>
</div>
{:else}
<span
class="py-2 px-3 font-display bg-foreground-failure text-white rounded-xl"
>{file.from}</span
>
<span class="text-foreground-failure"> <span
is not supported! class="text-foreground-failure"
</span> >
{/if} is not supported!
<button </span>
onclick={() => { {/if}
// delete the file from the list </div>
files.files = files.files.filter( <!-- <div
(f) => f !== file, class="hidden lg:flex gap-4 w-full"
); >
if (files.files.length === 0) goto("/"); <button
}} class="btn flex-grow flex-shrink-0"
class="ml-2 mr-1 sm:block hidden" onclick={() => convert(file)}
> >
<XIcon size="18" /> Convert
</button> </button>
</div> <button
</div> class="btn flex-grow flex-shrink-0"
{#if converter && converter.supportedFormats.includes(file.from)} disabled={!file.result}
<!-- god knows why, but setting opacity > 0.98 causes a z-ordering issue in firefox ??? --> onclick={file.download}
<div >
class="absolute top-0 -z-50 left-0 w-full h-full rounded-[10px] overflow-hidden opacity-[0.98]" Download
> </button>
<div </div> -->
class="bg-cover bg-center w-full h-full" </div>
style="background-image: url({file.blobUrl});" <button
></div> onclick={() => {
<div // delete the file from the list
class="absolute sm:top-0 bottom-0 sm:right-0 sm:w-5/6 h-5/6 w-full sm:h-full" files.files = files.files.filter(
> (f) => f !== file,
<ProgressiveBlur );
direction={isSm ? "bottom" : "right"} if (files.files.length === 0)
endIntensity={128} goto("/");
iterations={6} }}
fadeTo="var(--bg-transparent)" class="ml-2 mr-1 sm:block hidden lg:hidden"
/> >
<XIcon size="18" />
</button>
</div> </div>
</div> </div>
{/if} {#if converter && converter.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 sm:top-0 bottom-0 sm:left-0 h-5/6 w-full sm:h-full"
>
<ProgressiveBlur
direction={isSm
? "bottom"
: isLg
? "bottom"
: "right"}
endIntensity={isLg ? 64 : 128}
iterations={6}
fadeTo="var(--bg-transparent)"
/>
</div>
</div>
{/if}
</div>
</div> </div>
</div> {/each}
{/each} </div>
<div class="w-full h-4 flex-shrink-0"></div> <div class="w-full h-4 flex-shrink-0"></div>
</div> </div>
{/if} {/if}
@ -406,9 +508,28 @@
opacity: 1 !important; opacity: 1 !important;
} }
@keyframes processing {
0% {
transform: scale(1);
filter: blur(0px);
animation-timing-function: ease-in-out;
}
50% {
transform: scale(1.05);
filter: blur(4px);
animation-timing-function: ease-in-out;
}
100% {
transform: scale(1);
filter: blur(0px);
animation-timing-function: ease-in-out;
}
}
.processing { .processing {
transform: scale(1.05); animation: processing 2000ms infinite;
filter: blur(4px);
pointer-events: none; pointer-events: none;
} }