feat: remove old pages, add proof

This commit is contained in:
not-nullptr 2024-11-15 16:02:50 +00:00
parent 69a4d928d0
commit 7f27ee0ff6
3 changed files with 6 additions and 976 deletions

View File

@ -1,251 +1,8 @@
<script lang="ts">
import { goto } from "$app/navigation";
import Uploader from "$lib/components/functional/Uploader.svelte";
import { converters } from "$lib/converters";
import { log } from "$lib/logger";
import { files } from "$lib/store/index.svelte";
import { VertFile } from "$lib/types/file.svelte";
import { Check } from "lucide-svelte";
import jsmediatags from "jsmediatags";
import type { TagType } from "jsmediatags/types/index.js";
const { data } = $props();
let ourFiles = $state<File[]>();
const runUpload = async () => {
const newFilePromises = (ourFiles || []).map(async (f) => {
return new Promise<(typeof files.files)[0] | void>(
(resolve, reject) => {
const from =
"." + f.name.toLowerCase().split(".").slice(-1);
const converter = converters.find((c) =>
c.supportedFormats.includes(from.toLowerCase()),
);
if (!converter) resolve();
const to =
converter?.supportedFormats.find((f) => f !== from) ||
converters[0].supportedFormats[0];
log(
["uploader", "converter"],
`converting ${from} to ${to} using ${converter?.name || "... no converter??"}`,
);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.src = URL.createObjectURL(f);
const maxSize = 512;
img.onload = () => {
const scale = Math.max(
maxSize / img.width,
maxSize / img.height,
);
canvas.width = img.width * scale;
canvas.height = img.height * scale;
ctx?.drawImage(img, 0, 0, canvas.width, canvas.height);
// get the blob
canvas.toBlob(
async (blob) => {
resolve(
new VertFile(
f,
to,
converter,
URL.createObjectURL(blob!),
),
);
},
"image/jpeg",
0.75,
);
};
img.onerror = async () => {
// resolve(new VertFile(f, to, converter));
const reader = new FileReader();
const file = new VertFile(f, to, converter);
resolve(file);
reader.onload = async (e) => {
const tags = await new Promise<TagType>(
(resolve, reject) => {
jsmediatags.read(
new Blob([
new Uint8Array(
e.target?.result as ArrayBuffer,
),
]),
{
onSuccess: (tag) => resolve(tag),
onError: (error) => reject(error),
},
);
},
);
const picture = tags.tags.picture;
if (!picture) return;
const blob = new Blob(
[new Uint8Array(picture.data)],
{
type: picture.format,
},
);
const url = URL.createObjectURL(blob);
file.blobUrl = url;
};
reader.readAsArrayBuffer(f);
};
},
);
});
let oldLen = files.files.length;
files.files = [
...files.files,
...(await Promise.all(newFilePromises)).filter(
(f) => typeof f !== "undefined",
),
];
let newLen = files.files.length;
log(["uploader"], `handled ${newLen - oldLen} files`);
ourFiles = [];
if (files.files.length > 0) goto("/convert");
};
// this comment was written on 15/11/2024 at 16:01 GMT.
// i bet to myself that i could complete this whole redesign implementation
// by the time realmy got started on it. i guess we'll see how that goes
//
// ship fast n break things !!
// -- nullptr
</script>
<svelte:head>
<title>VERT.sh — Free, fast, and awesome file convert</title>
<meta
name="title"
content="VERT.sh — Free, fast, and awesome file convert"
/>
<meta
name="description"
content="With VERT you can convert images to PNG, JPG, WEBP, GIF, AVIF, and more. No ads, no tracking, open source, and all processing is done on your device."
/>
<meta property="og:type" content="website" />
<meta
property="og:title"
content="VERT.sh — Free, fast, and awesome file convert"
/>
<meta
property="og:description"
content="With VERT you can convert images to PNG, JPG, WEBP, GIF, AVIF, and more. No ads, no tracking, open source, and all processing is done on your device."
/>
<meta property="twitter:card" content="summary_large_image" />
<meta
property="twitter:title"
content="VERT.sh — Free, fast, and awesome file convert"
/>
<meta
property="twitter:description"
content="With VERT you can convert images to PNG, JPG, WEBP, GIF, AVIF, and more. No ads, no tracking, open source, and all processing is done on your device."
/>
</svelte:head>
{#snippet sellingPoint(text: string)}
<li
class="grid items-center gap-4"
style="grid-template-columns: 2rem auto"
>
<div
class="h-8 w-8 bg-accent-background text-accent-foreground rounded-full flex items-center justify-center"
>
<Check />
</div>
<span class="text-lg">{text}</span>
</li>
{/snippet}
<div class="[@media(max-height:768px)]:block mt-10 picker-fly">
<Uploader
isMobile={data.isMobile || false}
bind:files={ourFiles}
onupload={runUpload}
acceptedFormats={[
...new Set(converters.flatMap((c) => c.supportedFormats)),
]}
/>
</div>
<div class="mt-20">
<h1 class="text-3xl text-center font-display header-fly-in">
Free, fast, and awesome file converting <span
class="px-2 py-1 text-xl bg-accent-background text-accent-foreground rounded-lg"
>BETA</span
>
</h1>
<div class="flex justify-center mt-10">
<div class="grid gap-4">
<!-- {@render sellingPoint("Very fast, all processing done on device")}
{@render sellingPoint("No ads, and open source")}
{@render sellingPoint("Beautiful and straightforward UI")} -->
{#each ["Very fast, all processing done on device", "No file or size limit", "No ads, and open source", "Beautiful and straightforward UI"] as text, i}
<div class="fly-in" style="--delay: {i * 50}ms;">
{@render sellingPoint(text)}
</div>
{/each}
</div>
</div>
</div>
<style>
/* for this page specifically */
:global(html, body) {
height: 100%;
}
@keyframes fly-in {
from {
opacity: 0;
transform: translateY(50px);
filter: blur(18px);
}
to {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}
@keyframes picker-fly {
from {
opacity: 0;
transform: translateY(48px);
filter: blur(18px);
}
to {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}
@keyframes header-fly-in {
from {
opacity: 0;
transform: translateY(30px) scale(0.9);
filter: blur(18px);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
}
.header-fly-in {
animation: header-fly-in var(--transition) 750ms forwards;
opacity: 0;
}
.fly-in {
animation: fly-in var(--transition) 750ms var(--delay) forwards;
opacity: 0;
}
.picker-fly {
animation: picker-fly var(--transition) 750ms forwards;
opacity: 0;
}
</style>

View File

@ -1,159 +0,0 @@
<script lang="ts">
import avatarNullptr from "$lib/assets/avatars/nullptr.jpg";
import avatarRealmy from "$lib/assets/avatars/realmy.jpg";
const multiplier = 50;
const credits = [
{
name: "nullptr",
avatar: avatarNullptr,
url: "https://nullp.tr",
description: "conversion backend, UI, animations, promotion",
},
{
name: "Realmy",
avatar: avatarRealmy,
url: "https://realmy.net",
description: "idea, UI, branding, operational costs",
},
];
</script>
<svelte:head>
<title>About VERT</title>
<meta name="title" content="About VERT — VERT.sh" />
<meta property="og:title" content="About VERT — VERT.sh" />
<meta property="twitter:title" content="About VERT — VERT.sh" />
</svelte:head>
<div class="text-lg mx-auto max-w-screen-md">
<h1
class="font-display text-3xl text-transition"
style="--delay: {0 * multiplier}ms"
>
⁉️ about VERT
</h1>
<p class="mt-6 text-transition" style="--delay: {1 * multiplier}ms">
You know what sucks? File converters! They're usually riddled with ads,
and take an ungodly amount of time to complete. <b
>So we made a better one!</b
>
</p>
<p class="mt-4 text-transition" style="--delay: {2 * multiplier}ms">
VERT is a file converter that's open source, completely ad free, and
much much faster than you're used to. All the converting is done on your
device, which makes it both private and very speedy. And it of course
has a beautiful UI! ✨
</p>
<h2
class="font-display text-3xl mt-12 text-transition"
style="--delay: {3 * multiplier}ms"
>
🖼️ supported formats
</h2>
<p class="mt-6 text-transition" style="--delay: {4 * multiplier}ms">
As of right now, VERT supports image and audio conversion of most
popular formats. We'll add support for more formats in the future!
</p>
<h2
class="font-display text-3xl mt-12 text-transition"
style="--delay: {5 * multiplier}ms"
>
🔗 resources
</h2>
<ul class="list-disc list-inside mt-6">
<li class="text-transition" style="--delay: {6 * multiplier}ms">
<a
href="https://github.com/not-nullptr/VERT"
class="text-foreground-highlight hover:underline">Source code</a
> (hosted on GitHub, licensed under AGPL-3.0)
</li>
<li class="text-transition" style="--delay: {7 * multiplier}ms">
<a
href="https://discord.gg/8XXZ7TFFrK"
class="text-foreground-highlight hover:underline"
>Discord server</a
> (for chit-chat, suggestions, and support)
</li>
</ul>
<h2
class="font-display text-3xl mt-12 text-transition"
style="--delay: {8 * multiplier}ms"
>
🎨 credits
</h2>
<div class="flex gap-4 mt-8">
{#each credits as credit, i}
<div class="hover:scale-105 w-56 transition-transform">
<div
class="border-2 credit-transition border-solid border-foreground-muted-alt rounded-2xl overflow-hidden"
style="--delay: {i * 50 + multiplier * 9}ms;"
>
<a class="w-48" href={credit.url} target="_blank">
<img src={credit.avatar} alt="{credit.name}'s avatar" />
<div class="text-center py-4 px-2">
<p class="font-display text-xl">{credit.name}</p>
<p class="text-sm text-foreground-muted mt-2">
{credit.description}
</p>
</div>
</a>
</div>
</div>
{/each}
</div>
<p
class="text-foreground-muted text-base mt-10 text-transition"
style="--delay: {10 * multiplier}ms"
>
(obviously inspired by <a
href="https://cobalt.tools"
class="hover:underline">cobalt.tools</a
>)
</p>
</div>
<style>
@keyframes credit-transition {
from {
opacity: 0;
transform: translateX(60px);
filter: blur(18px);
}
to {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}
@keyframes text-transition {
from {
opacity: 0;
transform: translateY(60px);
filter: blur(18px);
}
to {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}
.credit-transition {
animation: credit-transition 750ms var(--transition) var(--delay)
forwards;
opacity: 0;
}
.text-transition {
animation: text-transition 750ms var(--transition) var(--delay) forwards;
opacity: 0;
}
</style>

View File

@ -1,568 +0,0 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { blur, duration, flip } from "$lib/animation";
import Dropdown from "$lib/components/functional/Dropdown.svelte";
import ProgressiveBlur from "$lib/components/visual/effects/ProgressiveBlur.svelte";
import ProgressBar from "$lib/components/visual/ProgressBar.svelte";
import { converters } from "$lib/converters";
import type { Converter } from "$lib/converters/converter.svelte";
import { log } from "$lib/logger";
import { files } from "$lib/store/index.svelte";
import type { VertFile } from "$lib/types";
import clsx from "clsx";
import { ArrowRight, Disc2Icon, FileAudioIcon, XIcon } from "lucide-svelte";
import { onMount } from "svelte";
import { quintOut } from "svelte/easing";
import {
fade,
type EasingFunction,
type TransitionConfig,
} from "svelte/transition";
const { data } = $props();
const reversedFiles = $derived(files.files.slice().reverse());
let finisheds = $state(
Array.from({ length: files.files.length }, () => false),
);
let processings = $state<boolean[]>([]);
const convertersRequired = $derived.by(() => {
const required: Converter[] = [];
for (let i = 0; i < files.files.length; i++) {
const file = files.files[i];
const converter = converters.find(
(c) =>
c.supportedFormats.includes(file.from.toLowerCase()) &&
c.supportedFormats.includes(file.to.toLowerCase()),
);
if (!converter) throw new Error("No converter found");
required.push(converter);
}
return Array.from(new Set(required));
});
const multipleConverters = $derived(convertersRequired.length > 1);
const noMultConverter = $derived(
multipleConverters ? null : convertersRequired[0],
);
const allConvertersReady = $derived(
convertersRequired.every((c) => c.ready),
);
let disabled = $derived(files.files.some((f) => !f.result));
onMount(() => {
finisheds.forEach((_, i) => {
const duration = 575 + i * 50 - 32;
setTimeout(() => {
finisheds[i] = true;
}, duration);
});
});
const convertAll = async () => {
const perf = performance.now();
files.files.forEach((f) => (f.result = null));
const promises: Promise<void>[] = [];
for (let i = 0; i < files.files.length; i++) {
promises.push(
(async (i) => {
window.plausible("Convert", {
props: {
"Convert from": files.files[i].from.toLowerCase(),
"Convert to": files.files[i].to.toLowerCase(),
Conversion: `${files.files[i].from.toLowerCase()} to ${files.files[i].to.toLowerCase()}`,
},
});
await convert(files.files[i], i);
})(i),
);
}
await Promise.all(promises);
const ms = performance.now() - perf;
const seconds = (ms / 1000).toFixed(2);
log(["converter"], `converted all files in ${seconds}s`);
};
const convert = async (file: VertFile, index: number) => {
file.progress = 0;
processings[index] = true;
await file.convert();
processings[index] = false;
};
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(/\.[^/.]+$/, "") + file.to,
lastModified: Date.now(),
input: await result.file.arrayBuffer(),
});
}
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].to.slice(1),
}),
);
const a = document.createElement("a");
a.href = blob;
a.download = `VERT-Converted_${new Date().toISOString()}${
files.files[0].to
}`;
a.click();
URL.revokeObjectURL(blob);
a.remove();
return;
}
const { downloadZip } = await import("client-zip");
const blob = await downloadZip(dlFiles, "converted.zip").blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `VERT-Converted_${new Date().toISOString()}.zip`;
a.click();
URL.revokeObjectURL(url);
a.remove();
};
const deleteAll = () => {
files.files = [];
goto("/");
};
export const progBlur = (
_: HTMLElement,
config:
| Partial<{
duration: number;
easing: EasingFunction;
}>
| undefined,
dir: {
direction: "in" | "out" | "both";
},
): TransitionConfig => {
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
if (!config) config = {};
if (!config.duration) config.duration = 300;
if (!config.easing) config.easing = quintOut;
return {
duration: prefersReducedMotion ? 0 : config?.duration || 300,
css: (t) => {
return "--blur-amount: " + (dir.direction !== "in" ? t : 1 - t);
},
easing: config?.easing,
};
};
</script>
<svelte:head>
<title>Your Conversions</title>
<meta name="title" content="Your Conversions — VERT.sh" />
<meta property="og:title" content="Your Conversions — VERT.sh" />
<meta property="twitter:title" content="Your Conversions — VERT.sh" />
</svelte:head>
<div class="grid grid-cols-1 grid-rows-1 w-full">
{#if files.files.length === 0}
<p class="text-foreground-muted col-start-1 row-start-1 text-center">
No files uploaded. Head to the Upload tab to begin!
</p>
{:else}
<div
class="flex flex-col gap-4 w-full col-start-1 row-start-1"
out:blur={{
duration,
easing: quintOut,
blurMultiplier: 16,
}}
>
<div
class="w-full p-4 max-w-screen-lg border-solid flex-col border-2 rounded-2xl border-foreground-muted-alt flex flex-shrink-0"
>
<h2 class="font-bold text-xl mb-1">Options</h2>
<div class="flex flex-col w-full gap-4 mt-2">
<div class="flex flex-col gap-3 w-fit">
<h3>Set all target formats</h3>
<div class="grid grid-rows-1 grid-cols-1">
{#if !multipleConverters && noMultConverter}
<div
transition:blur={{
blurMultiplier: 8,
duration,
easing: quintOut,
}}
class="row-start-1 col-start-1 w-fit"
>
<Dropdown
options={noMultConverter.supportedFormats}
onselect={(o) => {
// files.conversionTypes = Array.from(
// { length: files.files.length },
// () => o,
// );
files.files.forEach((file) => {
file.result = null;
file.to = o;
});
}}
/>
</div>
{:else}
<div
class="italic w-fit text-foreground-muted-alt flex items-center row-start-1 col-start-1"
transition:blur={{
blurMultiplier: 8,
duration,
easing: quintOut,
}}
>
The listed files require different
converters, so you can't set them in bulk.
</div>
{/if}
</div>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-3 mt-4">
<button
onclick={convertAll}
class={clsx("btn flex-grow", {
"btn-highlight":
disabled && !processings.some((p) => p),
})}
disabled={!allConvertersReady ||
processings.some((p) => p)}
>
{#if allConvertersReady}
Convert {files.files.length > 1 ? "All" : ""}
{:else}
Loading...
{/if}
</button>
<button
onclick={downloadAll}
class={clsx("btn flex-grow", {
"btn-highlight": !disabled,
})}
{disabled}
>Download {files.files.length > 1 ? "All" : ""}</button
>
<button
onclick={deleteAll}
disabled={processings.some((p) => p)}
class="btn flex-grow"
>
Delete All
</button>
</div>
</div>
<div class="w-full gap-4 grid md:grid-cols-2">
{#each reversedFiles as file, i (file.id)}
{@const converter = (() => {
return converters.find((c) =>
c.supportedFormats.includes(
file.from.toLowerCase(),
),
);
})()}
<div
class="relative"
animate:flip={{ duration, easing: quintOut }}
out:blur={{
duration,
easing: quintOut,
blurMultiplier: 16,
}}
>
<div
class={clsx(
"flex relative flex-shrink-0 items-center w-full rounded-xl h-72",
{
"initial-fade": !finisheds[i],
},
)}
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="flex h-full flex-col items-center w-full z-50 relative"
>
<div class="w-full flex-shrink-0">
<div
class={clsx(
"py-3 dynadark:[--transparency:50%] [--transparency:25%] px-4 w-full flex transition-colors duration-300 flex-shrink text-left border-b-2 border-solid border-foreground-muted-alt rounded-tl-[9.5px] rounded-tr-[10px] overflow-hidden",
{
"text-accent-foreground":
file.result,
"text-foreground": !file.result,
},
)}
style="background-color: color-mix(in srgb, var(--{file.result
? 'accent-bg'
: 'bg'}), transparent var(--transparency)); backdrop-filter: blur({data.isFirefox
? 0
: 18}px);"
>
<div
class="w-full grid grid-cols-1 grid-rows-1"
>
{#if processings[files.files.length - i - 1]}
<div
class="w-full row-start-1 col-start-1 h-full flex items-center pr-4"
transition:blur={{
blurMultiplier: 6,
duration,
easing: quintOut,
scale: {
start: 0.9,
end: 1,
},
}}
>
<ProgressBar
min={0}
max={100}
progress={file.converter
?.reportsProgress
? file.result
? 100
: file.progress
: null}
/>
</div>
{:else}
<h3
class="row-start-1 col-start-1 whitespace-nowrap overflow-hidden text-ellipsis font-medium"
transition:blur={{
blurMultiplier: 6,
duration,
easing: quintOut,
scale: {
start: 0.9,
end: 1,
},
}}
>
{file.file.name}
</h3>
{/if}
</div>
<button
onclick={() => {
// delete the file from the list
files.files =
files.files.filter(
(f) => f !== file,
);
if (files.files.length === 0)
goto("/");
}}
class="ml-2 mr-1 flex-shrink-0"
>
<XIcon size="24" />
</button>
</div>
</div>
<div
class="flex gap-3 justify-normal flex-grow w-full h-full"
>
<div
class="flex flex-col items-end gap-3 w-full"
>
<div
class="flex items-end gap-3 w-full h-full px-5"
>
<div
class="flex items-center justify-center gap-3 w-full pb-4"
>
{#if converter && converter.supportedFormats.includes(file.from.toLowerCase())}
<span>from</span>
<span
class="py-2 px-3 font-display bg-foreground text-background rounded-xl"
>{file.from}</span
>
<span>to</span>
<div class="inline-flex">
<Dropdown
options={converter.supportedFormats}
bind:selected={files
.files[
files.files
.length -
i -
1
].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"
>
is not supported!
</span>
{/if}
</div>
</div>
<!-- <div
class="hidden lg:flex gap-4 w-full"
>
<button
class="btn flex-grow flex-shrink-0"
onclick={() => convert(file)}
>
Convert
</button>
<button
class="btn flex-grow flex-shrink-0"
disabled={!file.result}
onclick={file.download}
>
Download
</button>
</div> -->
</div>
</div>
</div>
{#if converter && converter.supportedFormats.includes(file.from.toLowerCase())}
<!-- god knows why, but setting opacity > 0.98 causes a z-ordering issue in firefox ??? -->
<div
class="absolute top-[0px] -z-50 left-0 w-full h-full opacity-[0.98] rounded-xl overflow-hidden"
>
{#if file.blobUrl}
<div
class="bg-cover bg-center w-full h-full"
style="background-image: url({file.blobUrl})"
in:blur={{
blurMultiplier: 24,
scale: {
start: 1.1,
end: 1,
},
duration,
easing: quintOut,
}}
></div>
<div
class="absolute left-0 top-0 pt-[50px] h-full w-full"
transition:progBlur={{
duration,
easing: quintOut,
}}
>
<ProgressiveBlur
direction="bottom"
endIntensity={64}
iterations={8}
fadeTo="var(--bg-transparent)"
/>
</div>
{:else}
<div
class="w-full h-full flex items-center justify-center"
>
<FileAudioIcon
size="96"
strokeWidth="1.5"
color="var(--fg)"
opacity="0.9"
/>
</div>
{/if}
</div>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
<style>
@keyframes initial-transition {
0% {
transform: translateY(50px);
filter: blur(16px);
opacity: 0;
}
100% {
transform: translateY(0);
filter: blur(0);
opacity: 1;
}
}
.initial-fade {
animation: initial-transition 600ms var(--delay) var(--transition);
opacity: 0;
}
.initial-fade.finished {
animation: none;
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 {
animation: processing 2000ms infinite;
pointer-events: none;
}
.file-list {
transition:
filter 500ms var(--transition),
transform 500ms var(--transition);
}
</style>