Merge branch 'nightly'

This commit is contained in:
Realmy 2025-02-09 21:35:18 +01:00
commit 94770db2e7
63 changed files with 6308 additions and 2331 deletions

View File

@ -1,3 +1,3 @@
PUB_HOSTNAME=vert.sh # only gets used for plausible (for now)
PUB_HOSTNAME=localhost:5173 # only gets used for plausible (for now)
PUB_PLAUSIBLE_URL=https://plausible.example.com # can be empty
PUB_ENV=production # "production" or "nightly"
PUB_ENV=development # "production", "development", or "nightly"

4097
bun.lock

File diff suppressed because it is too large Load Diff

View File

@ -12,40 +12,39 @@
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/eslint": "^9.6.0",
"@types/js-cookie": "^3.0.6",
"@poppanator/sveltekit-svg": "^5.0.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@types/eslint": "^9.6.1",
"@types/jsmediatags": "^3.9.6",
"autoprefixer": "^10.4.20",
"eslint": "^9.7.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0",
"globals": "^15.0.0",
"prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.6",
"prettier-plugin-tailwindcss": "^0.6.5",
"sass": "^1.80.7",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^3.4.9",
"typescript": "^5.0.0",
"typescript-eslint": "^8.0.0",
"vite": "^5.0.3"
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^2.46.1",
"globals": "^15.14.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.10",
"sass": "^1.83.4",
"svelte": "^5.19.0",
"svelte-check": "^4.1.4",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0",
"vite": "^5.4.11"
},
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/util": "^0.12.1",
"@fontsource/azeret-mono": "^5.1.0",
"@fontsource/lexend": "^5.1.1",
"@imagemagick/magick-wasm": "^0.0.31",
"client-zip": "^2.4.5",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@fontsource/azeret-mono": "^5.1.1",
"@fontsource/lexend": "^5.1.2",
"@fontsource/radio-canada-big": "^5.1.1",
"@imagemagick/magick-wasm": "^0.0.32",
"client-zip": "^2.4.6",
"clsx": "^2.1.1",
"js-cookie": "^3.0.5",
"jsmediatags": "^3.9.7",
"lucide-svelte": "^0.456.0",
"svelte-adapter-bun": "^0.5.2",
"lucide-svelte": "^0.475.0",
"wasm-vips": "^0.0.11"
},
"patchedDependencies": {

2
src/app.d.ts vendored
View File

@ -1,3 +1,5 @@
import "@poppanator/sveltekit-svg/dist/svg";
type EventPayload = {
readonly n: string;
readonly u: Location["href"];

View File

@ -1,10 +1,44 @@
<!doctype html>
<html lang="en" class="%theme%">
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="icon" href="%sveltekit.assets%/favicon.webp" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<script>
(function () {
// Apply theme before DOM is loaded
let theme = localStorage.getItem("theme");
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
console.log(
`Theme: ${theme || "N/A"}, prefers dark: ${prefersDark}`,
);
if (theme !== "light" && theme !== "dark") {
console.log("Invalid theme, setting to default");
if (!theme) {
console.log("First time visitor, setting theme");
// first time visitor
window.addEventListener("load", () => {
window.plausible("Theme set", {
props: {
theme: prefersDark ? "dark" : "light",
},
});
});
}
// invalid theme or first time visitor, set to default
theme = prefersDark ? "dark" : "light";
localStorage.setItem("theme", theme);
}
console.log(`Applying theme: ${theme}`);
document.documentElement.classList.add(theme);
})();
</script>
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>

View File

@ -2,15 +2,14 @@
@tailwind components;
@tailwind utilities;
@import url(@fontsource/lexend/400.css);
@import url(@fontsource/lexend/500.css);
@import url(@fontsource/azeret-mono/600.css);
@import url(@fontsource/radio-canada-big/600.css);
@import url("$lib/assets/style/host-grotesk.css");
:root {
--font-body: "Lexend", system-ui, -apple-system, BlinkMacSystemFont,
--font-body: "Host Grotesk", system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans",
"Helvetica Neue", sans-serif;
--font-display: "Azeret Mono", var(--font-body);
--font-display: "Radio Canada Big", var(--font-body);
--transition: linear(
0,
0.006,
@ -30,28 +29,171 @@
}
@mixin light {
--accent-bg: hsl(303, 73%, 81%);
--accent-fg: hsl(0, 0, 10%);
--bg: hsl(0, 0%, 100%);
--bg-transparent: hsla(0, 0%, 100%, 0.6);
--fg: hsl(0, 0%, 10%);
--fg-muted: hsl(0, 0%, 50%);
--fg-muted-alt: hsl(0, 0%, 75%);
--fg-highlight: hsl(303, 61%, 47%);
--fg-failure: hsl(0, 67%, 49%);
color-scheme: light;
// general
--accent-pink: hsl(302, 100%, 76%);
--accent-pink-alt: hsl(302, 100%, 50%);
--accent-pink-muted: hsl(302, 98%, 42%);
--accent-red: hsl(348, 100%, 80%);
--accent-red-alt: hsl(348, 100%, 50%);
--accent-purple: hsl(264, 100%, 81%);
--accent-purple-alt: hsl(264, 100%, 50%);
--accent-blue: hsl(220, 100%, 78%);
--accent-blue-alt: hsl(220, 100%, 50%);
--accent: var(--accent-pink);
--accent-alt: var(--accent-pink-alt);
// foregrounds
--fg: hsl(0, 0%, 0%);
--fg-muted: hsla(0, 0%, 0%, 0.6);
--fg-on-accent: hsl(0, 0%, 0%);
--fg-on-badge: hsl(0, 0%, 0%);
// readable version of the accent color
--fg-accent: var(--accent-pink-muted);
--fg-failure: var(--accent-red-alt);
// backgrounds
--bg: hsl(0, 0%, 95%);
--bg-gradient: linear-gradient(
to bottom,
var(--accent-pink),
hsla(303, 100%, 50%, 0) 100%
);
--bg-gradient-pink: linear-gradient(
to bottom,
var(--accent-pink),
hsla(303, 100%, 50%, 0) 25%
);
--bg-gradient-pink-alt: linear-gradient(
to top,
var(--accent-pink),
hsl(303, 100%, 91%) 100%
);
--bg-gradient-red: linear-gradient(
to bottom,
var(--accent-red),
hsla(348, 100%, 50%, 0) 25%
);
--bg-gradient-red-alt: linear-gradient(
to top,
var(--accent-red),
hsl(348, 100%, 91%) 100%
);
--bg-gradient-purple: linear-gradient(
to bottom,
var(--accent-purple),
hsla(264, 100%, 50%, 0) 25%
);
--bg-gradient-purple-alt: linear-gradient(
to top,
var(--accent-purple),
hsl(264, 100%, 91%) 100%
);
--bg-gradient-blue: linear-gradient(
to bottom,
var(--accent-blue),
hsla(220, 100%, 50%, 0) 25%
);
--bg-gradient-blue-alt: linear-gradient(
to top,
var(--accent-blue),
hsl(220, 100%, 91%) 100%
);
--bg-gradient-image: linear-gradient(
to bottom,
hsla(0, 0%, 95%, 0.5),
hsla(0, 0%, 95%, 1) 100%
);
--bg-panel: hsl(0, 0%, 100%);
--bg-panel-highlight: hsl(0, 0%, 92%);
--bg-separator: hsl(0, 0%, 88%);
--bg-button: var(--bg-panel-highlight);
--bg-badge: var(--accent-pink);
--bg-input: #e0e0e0;
--shadow-panel: 0 2px 4px 0 hsla(0, 0%, 0%, 0.15);
}
@mixin dark {
--accent-bg: hsl(304, 41%, 21%);
--accent-fg: hsl(303, 73%, 81%);
--bg: hsl(0, 0%, 8%);
--bg-transparent: hsla(0, 0%, 8%, 0.8);
--fg: hsl(0, 0%, 90%);
--fg-muted: hsl(0, 0%, 50%);
--fg-muted-alt: hsl(0, 0%, 25%);
--fg-highlight: hsl(303, 64%, 65%);
--fg-failure: hsl(0, 67%, 80%);
// general
--accent-pink: hsl(302, 100%, 76%);
--accent-pink-alt: hsl(302, 100%, 50%);
--accent-red: hsl(348, 100%, 80%);
--accent-red-alt: hsl(348, 100%, 50%);
--accent-purple: hsl(264, 100%, 81%);
--accent-purple-alt: hsl(264, 100%, 50%);
--accent-blue: hsl(220, 100%, 78%);
--accent-blue-alt: hsl(220, 100%, 50%);
--accent: var(--accent-pink);
--accent-alt: var(--accent-pink-alt);
// foregrounds
--fg: hsl(0, 0%, 100%);
--fg-muted: hsla(0, 0%, 100%, 0.65);
--fg-on-accent: hsl(0, 0%, 0%);
--fg-on-badge: hsl(0, 0%, 0%);
--fg-accent: var(--accent);
--fg-failure: var(--accent-red);
// backgrounds
--bg: hsl(220, 5%, 15%);
--bg-gradient: linear-gradient(
to bottom,
hsla(303, 100%, 50%, 0.1),
hsla(303, 100%, 50%, 0) 100%
);
--bg-gradient-pink: linear-gradient(
to bottom,
hsla(303, 100%, 50%, 0.1),
hsla(303, 100%, 50%, 0) 25%
);
--bg-gradient-pink-alt: linear-gradient(
to top,
var(--accent-pink),
hsl(303, 100%, 91%) 100%
);
--bg-gradient-red: linear-gradient(
to bottom,
hsla(348, 100%, 50%, 0.1),
hsla(348, 100%, 50%, 0) 25%
);
--bg-gradient-red-alt: linear-gradient(
to top,
var(--accent-red),
hsl(348, 100%, 91%) 100%
);
--bg-gradient-purple: linear-gradient(
to bottom,
hsla(264, 100%, 50%, 0.1),
hsla(264, 100%, 50%, 0) 25%
);
--bg-gradient-purple-alt: linear-gradient(
to top,
var(--accent-purple),
hsl(264, 100%, 91%) 100%
);
--bg-gradient-blue: linear-gradient(
to bottom,
hsla(220, 100%, 50%, 0.1),
hsla(220, 100%, 50%, 0) 25%
);
--bg-gradient-blue-alt: linear-gradient(
to top,
var(--accent-blue),
hsl(220, 100%, 91%) 100%
);
--bg-gradient-image: linear-gradient(
to bottom,
hsla(220, 5%, 12%, 0.5),
hsla(220, 5%, 12%, 1) 100%
);
--bg-panel: hsl(220, 4%, 24%);
--bg-panel-highlight: hsl(220, 2%, 32%);
--bg-separator: hsl(220, 4%, 28%);
--bg-button: hsl(220, 6%, 34%);
--bg-badge: var(--accent-pink);
--shadow-panel: 0 4px 6px 0 hsla(0, 0%, 0%, 0.15);
color-scheme: dark;
}
@ -76,8 +218,31 @@
}
body {
@apply text-foreground bg-background font-body overflow-x-hidden;
@apply text-foreground font-body font-semibold overflow-x-hidden;
width: 100vw;
background-color: var(--bg);
background-size: 100vw 100vh;
}
::selection,
::-moz-selection {
@apply bg-accent-blue text-on-accent;
}
.hoverable {
@apply hover:scale-105 duration-200;
}
.hoverable-md {
@apply hover:scale-110 duration-200;
}
.hoverable-lg {
@apply hover:scale-[1.15] duration-200;
}
.selected {
@apply bg-accent-purple !text-black;
}
@layer components {
@ -86,14 +251,44 @@ body {
}
.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 duration-200 active:scale-95 disabled:opacity-50 disabled:pointer-events-none;
@apply bg-button flex items-center justify-center overflow-hidden relative cursor-pointer px-6 h-14 rounded-full font-medium focus:!outline-none hover:scale-105 duration-200 active:scale-95 disabled:opacity-50 disabled:pointer-events-none hoverable;
transition:
opacity 0.2s ease,
transform 0.2s ease,
background-color 0.2s ease;
}
.btn-highlight {
@apply bg-accent-background text-accent-foreground border-accent-background;
.btn.highlight {
@apply bg-accent text-on-accent;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-display font-semibold;
}
code {
@apply font-mono bg-gray-200 rounded-md px-1 dynadark:bg-panel-alt dynadark:text-white;
}
p a {
@apply text-accent underline;
}
input[type="text"],
select.dropdown {
@apply w-full p-3 rounded-lg bg-panel border-2 border-button pl-3 pr-[4rem];
}
input[type="text"]::placeholder {
@apply text-muted font-normal;
}
input[type="text"]:focus {
@apply outline outline-accent outline-2;
}
}

View File

@ -1,17 +0,0 @@
import type { Handle } from "@sveltejs/kit";
export const handle: Handle = async ({ event, resolve }) => {
let theme = event.cookies.get("theme") ?? "";
if (theme !== "dark" && theme !== "light") {
event.cookies.set("theme", "", {
path: "/",
sameSite: "lax",
expires: new Date(2147483647 * 1000),
});
theme = "";
}
const res = await resolve(event, {
transformPageChunk: ({ html }) => html.replace("%theme%", theme),
});
return res;
};

View File

@ -1,135 +1,41 @@
import type { EasingFunction, TransitionConfig } from "svelte/transition";
import { isMobile, effects } from "$lib/store/index.svelte";
import type { AnimationConfig, FlipParams } from "svelte/animate";
import { cubicOut } from "svelte/easing";
import {
fade as svelteFade,
fly as svelteFly,
type FadeParams,
type FlyParams,
} from "svelte/transition";
// Subscribe to stores
let effectsEnabled = true;
let isMobileDevice = false;
effects.subscribe(value => {
effectsEnabled = value;
});
isMobile.subscribe(value => {
isMobileDevice = value;
});
export const transition =
"linear(0,0.006,0.025 2.8%,0.101 6.1%,0.539 18.9%,0.721 25.3%,0.849 31.5%,0.937 38.1%,0.968 41.8%,0.991 45.7%,1.006 50.1%,1.015 55%,1.017 63.9%,1.001)";
export const duration = 500;
const remap = (
value: number,
low1: number,
high1: number,
low2: number,
high2: number,
) => low2 + ((high2 - low2) * (value - low1)) / (high1 - low1);
export function fade(node: HTMLElement, options: FadeParams) {
if (!effectsEnabled) return {};
const animation = svelteFade(node, options);
return animation;
}
const choose = (
direction: "in" | "out" | "both",
defaultValue: number,
inValue?: number,
outValue?: number,
) =>
direction !== "out"
? typeof inValue === "number"
? inValue
: defaultValue
: typeof outValue === "number"
? outValue
: defaultValue;
type Combination<T extends string, U extends string> = `${T} ${U}`;
export const blur = (
_: HTMLElement,
config:
| Partial<{
blurMultiplier: number;
duration: number;
easing: EasingFunction;
scale: {
start: number;
end: number;
};
x: {
start: number;
end: number;
};
y: {
start: number;
end: number;
};
delay: number;
opacity: boolean;
origin: Combination<
"top" | "bottom" | "left" | "right" | "center",
"top" | "bottom" | "left" | "right" | "center"
> & {};
}>
| undefined,
dir: {
direction: "in" | "out" | "both";
},
): TransitionConfig => {
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
if (typeof config?.opacity === "undefined" && config) config.opacity = true;
const isUsingTranslate = !!config?.x || !!config?.y || !!config?.scale;
return {
delay: config?.delay || 0,
duration: prefersReducedMotion ? 0 : config?.duration || 300,
css: (t) => {
if (prefersReducedMotion) return "";
const translate = isUsingTranslate
? `translate(${remap(
t,
0,
1,
choose(
dir.direction,
0,
config?.x?.start,
config?.x?.end,
),
choose(
dir.direction,
0,
config?.x?.end,
config?.x?.start,
),
)}px, ${remap(
t,
0,
1,
choose(
dir.direction,
0,
config?.y?.start,
config?.y?.end,
),
choose(
dir.direction,
0,
config?.y?.end,
config?.y?.start,
),
)}px) scale(${remap(
t,
0,
1,
choose(
dir.direction,
0.9,
config?.scale?.start,
config?.scale?.end,
),
choose(
dir.direction,
1,
config?.scale?.end,
config?.scale?.start,
),
)})`
: ``;
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,
};
};
export function fly(node: HTMLElement, options: FlyParams) {
if (!effectsEnabled || isMobileDevice) return {};
const animation = svelteFly(node, options);
return animation;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
export function is_function(thing: unknown): thing is Function {
@ -165,7 +71,7 @@ export function flip(
? duration(Math.sqrt(dx * dx + dy * dy))
: duration,
easing,
css: (t, u) => {
css: (_t, u) => {
const x = u * dx;
const y = u * dy;
// const sx = scale ? t + (u * from.width) / to.width : 1;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,42 @@
@font-face {
font-family: "Host Grotesk";
font-style: normal;
font-weight: 400;
src: url("$lib/assets/font/HostGrotesk-Regular.woff2") format("woff2");
}
@font-face {
font-family: "Host Grotesk";
font-style: italic;
font-weight: 400;
src: url("$lib/assets/font/HostGrotesk-Italic.woff2") format("woff2");
}
@font-face {
font-family: "Host Grotesk";
font-style: normal;
font-weight: 500;
src: url("$lib/assets/font/HostGrotesk-Medium.woff2") format("woff2");
}
@font-face {
font-family: "Host Grotesk";
font-style: italic;
font-weight: 500;
src: url("$lib/assets/font/HostGrotesk-MediumItalic.woff2") format("woff2");
}
@font-face {
font-family: "Host Grotesk";
font-style: normal;
font-weight: 600;
src: url("$lib/assets/font/HostGrotesk-SemiBold.woff2") format("woff2");
}
@font-face {
font-family: "Host Grotesk";
font-style: italic;
font-weight: 600;
src: url("$lib/assets/font/HostGrotesk-SemiBoldItalic.woff2")
format("woff2");
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1279 1307" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(2.71986,0,0,2.71986,-2526.51,-3527.95)">
<path d="M1082.77,1777.46L928.913,1297.11L1043.78,1297.11L1191.37,1777.46L1082.77,1777.46ZM1083.57,1776.36L1189.87,1776.36C1181.08,1747.74 1042.96,1298.21 1042.96,1298.21L930.425,1298.21L1083.57,1776.36ZM1188.94,1620.03L1285.35,1297.11L1398.82,1297.11L1261.8,1724.91L1188.94,1620.03ZM1190.15,1619.84L1261.43,1722.45L1397.31,1298.21L1286.17,1298.21L1190.15,1619.84Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 905 B

View File

@ -0,0 +1,45 @@
<script lang="ts">
import { files } from "$lib/store/index.svelte";
import { FolderArchiveIcon, RefreshCw } from "lucide-svelte";
import Panel from "../visual/Panel.svelte";
import Dropdown from "./Dropdown.svelte";
</script>
<Panel
class="w-full h-auto flex items-center justify-between flex-col md:flex-row gap-4"
>
<div class="flex items-center flex-col md:flex-row gap-2.5 max-md:w-full">
<button
onclick={() => files.convertAll()}
class="btn highlight flex gap-3 max-md:w-full"
disabled={!files.ready}
>
<RefreshCw size="24" />
<p>Convert all</p>
</button>
<button
class="btn flex gap-3 max-md:w-full"
disabled={!files.ready || !files.results}
onclick={() => files.downloadAll()}
>
<FolderArchiveIcon size="24" />
<p>Download all as .zip</p>
</button>
</div>
<div class="w-full bg-separator h-0.5 flex md:hidden"></div>
<div class="flex items-center gap-2">
<p class="whitespace-nowrap text-xl">Set all to</p>
{#if files.requiredConverters.length === 1}
<Dropdown
onselect={(r) =>
files.files.forEach((f) => {
f.to = r;
f.result = null;
})}
options={files.files[0]?.converter?.supportedFormats || []}
/>
{:else}
<Dropdown options={["N/A"]} disabled />
{/if}
</div>
</Panel>

View File

@ -1,17 +1,24 @@
<script lang="ts">
import { blur, duration, flip, transition } from "$lib/animation";
import { duration, fade, 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;
disabled?: boolean;
settingsStyle?: boolean;
};
let { options, selected = $bindable(), onselect }: Props = $props();
let {
options,
selected = $bindable(options[0]),
onselect,
disabled,
settingsStyle,
}: Props = $props();
let open = $state(false);
let hover = $state(false);
@ -31,10 +38,6 @@
toggle();
};
$effect(() => {
selected = selected || options[0];
});
onMount(() => {
const click = (e: MouseEvent) => {
if (dropdown && !dropdown.contains(e.target as Node)) {
@ -47,46 +50,44 @@
});
</script>
<div class="relative w-full min-w-fit" bind:this={dropdown}>
<div
class="relative w-full min-w-fit {settingsStyle
? 'font-normal'
: 'text-xl font-medium'} text-center"
bind:this={dropdown}
>
<button
class="font-display w-full min-w-fit 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"
class="font-display w-full {settingsStyle
? 'justify-between'
: 'justify-center'} overflow-hidden relative cursor-pointer {settingsStyle
? 'px-4'
: 'px-3'} py-3.5 bg-button {disabled
? 'opacity-50'
: ''} flex items-center {settingsStyle
? 'rounded-xl'
: 'rounded-full'} focus:!outline-none"
onclick={toggle}
onmouseenter={() => (hover = true)}
onmouseleave={() => (hover = false)}
{disabled}
>
<!-- <p>{selected}</p> -->
<div
class="grid grid-cols-1 grid-rows-1 w-fit text-left flex-grow-0 pr-12"
>
<div class="grid grid-cols-1 grid-rows-1 w-fit flex-grow-0">
{#key selected}
<p
in:blur={{
in:fade={{
duration,
easing: quintOut,
blurMultiplier: 6,
scale: {
start: 0.9,
end: 1,
},
y: {
start: isUp ? -50 : 50,
end: 0,
},
}}
out:blur={{
out:fade={{
duration,
easing: quintOut,
blurMultiplier: 6,
scale: {
start: 1,
end: 0.9,
},
y: {
start: 0,
end: isUp ? 50 : -50,
},
}}
class="col-start-1 row-start-1 text-left"
class="col-start-1 row-start-1 {settingsStyle
? 'text-left'
: 'text-center'} font-body {settingsStyle
? 'font-normal'
: 'font-medium'}"
>
{selected}
</p>
@ -108,26 +109,16 @@
</button>
{#if open}
<div
style={hover ? "will-change: opacity, blur, transform" : ""}
transition:blur={{
style={hover ? "will-change: opacity, fade, transform" : ""}
transition:fade={{
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"
class="w-full shadow-xl bg-panel-alt shadow-black/25 absolute overflow-hidden top-full mt-1 left-0 z-50 bg-background rounded-xl max-h-[30vh] overflow-y-auto"
>
{#each options as option}
<button
class="w-full p-2 px-4 text-left hover:bg-foreground-muted-alt brightness-125"
class="w-full p-2 px-4 text-left hover:bg-panel"
onclick={() => select(option)}
>
{option}

View File

@ -0,0 +1,47 @@
<script lang="ts">
type Props = {
class?: string;
placeholder?: string;
value?: string;
disabled?: boolean;
extension?: string;
prefix?: string;
type?: string;
};
let {
class: className,
placeholder = "",
value = $bindable(),
disabled = false,
extension,
prefix,
type = "text",
}: Props = $props();
</script>
<div class="relative flex w-full {className}">
<input
{type}
bind:value
{placeholder}
{disabled}
class="w-full p-3 rounded-lg bg-panel border-2 border-button
{prefix ? 'pl-[2rem]' : 'pl-3'}
{extension ? 'pr-[4rem]' : 'pr-3'}"
/>
{#if prefix}
<div class="absolute left-0 top-0 bottom-0 flex items-center px-2">
<span class="text-sm text-gray-400 px-2 py-1 rounded"
>{prefix}</span
>
</div>
{/if}
{#if extension}
<div class="absolute right-0 top-0 bottom-0 flex items-center px-4">
<span class="text-sm bg-button text-black dynadark:text-white px-2 py-1 rounded"
>{extension}</span
>
</div>
{/if}
</div>

View File

@ -1,12 +1,11 @@
<script lang="ts">
import { browser } from "$app/environment";
import { page } from "$app/stores";
import { fly } from "svelte/transition";
import { duration } from "$lib/animation";
import { duration, fly } from "$lib/animation";
import clsx from "clsx";
import { onMount, tick } from "svelte";
import { quintOut } from "svelte/easing";
import type { Writable } from "svelte/store";
import clsx from "clsx";
import { browser } from "$app/environment";
import { onMount, tick } from "svelte";
interface Props {
links: {

View File

@ -0,0 +1,143 @@
<script lang="ts">
import { browser } from "$app/environment";
import { page } from "$app/state";
import { duration, fade } from "$lib/animation";
import { effects, setTheme } from "$lib/store/index.svelte";
import clsx from "clsx";
import { MoonIcon, SunIcon } from "lucide-svelte";
import { quintOut } from "svelte/easing";
import Panel from "../visual/Panel.svelte";
import Logo from "../visual/svg/Logo.svelte";
type Props = {
items: {
name: string;
url: string;
activeMatch: (pathname: string) => boolean;
icon: any;
badge?: number;
}[];
};
let { items }: Props = $props();
let links = $state<HTMLAnchorElement[]>([]);
let container = $state<HTMLDivElement>();
let containerRect = $derived(container?.getBoundingClientRect());
$effect(() => {
$inspect(containerRect);
});
const linkRects = $derived(links.map((l) => l.getBoundingClientRect()));
const selectedIndex = $derived(
items.findIndex((i) => i.activeMatch(page.url.pathname)),
);
</script>
{#snippet link(item: (typeof items)[0], index: number)}
{@const Icon = item.icon}
<a
bind:this={links[index]}
href={item.url}
aria-label={item.name}
class={clsx(
"w-16 md:w-32 h-full relative z-10 rounded-xl flex items-center justify-center gap-3 overflow-hidden",
{
"bg-panel-highlight":
item.activeMatch(page.url.pathname) && !browser,
},
)}
draggable={false}
>
<div class="grid grid-rows-1 grid-cols-1">
{#key item.name}
<div
class="w-full row-start-1 col-start-1 h-full flex items-center justify-center gap-3"
in:fade={{
duration,
easing: quintOut,
}}
out:fade={{
duration,
easing: quintOut,
}}
>
<div class="relative">
<Icon />
{#if item.badge}
<div
class="absolute overflow-hidden grid grid-rows-1 grid-cols-1 -top-1 font-display -right-1 w-fit px-1.5 h-4 rounded-full bg-badge text-on-badge font-medium"
style="font-size: 0.7rem;"
transition:fade={{
duration,
easing: quintOut,
}}
>
{#key item.badge}
<div
class="flex items-center justify-center w-full h-full col-start-1 row-start-1"
in:fade={{
duration,
easing: quintOut,
}}
out:fade={{
duration,
easing: quintOut,
}}
>
{item.badge}
</div>
{/key}
</div>
{/if}
</div>
<p class="font-medium hidden md:flex">
{item.name}
</p>
</div>
{/key}
</div>
</a>
{/snippet}
<div bind:this={container}>
<Panel class="max-w-[778px] w-full h-20 flex items-center gap-3 relative">
{#if linkRects[selectedIndex]}
<div
class="absolute bg-panel-highlight rounded-xl"
style="width: {linkRects[selectedIndex]
.width}px; height: {linkRects[selectedIndex]
.height}px; top: {linkRects[selectedIndex].top -
(containerRect?.top || 0)}px; left: {linkRects[
selectedIndex
].left - (containerRect?.left || 0)}px; {$effects
? `transition: left var(--transition) ${duration}ms, top var(--transition) ${duration}ms;`
: ''}"
></div>
{/if}
<a
class="w-28 h-full bg-accent rounded-xl items-center justify-center hidden md:flex"
href="/"
>
<div class="h-5 w-full">
<Logo />
</div>
</a>
{#each items as item, i (item.url)}
{@render link(item, i)}
{/each}
<div class="w-0.5 bg-separator h-full hidden md:flex"></div>
<button
onclick={() => {
const isDark =
document.documentElement.classList.contains("dark");
setTheme(isDark ? "light" : "dark");
}}
class="w-14 h-full items-center justify-center hidden md:flex"
>
<SunIcon class="dynadark:hidden block" />
<MoonIcon class="dynadark:block hidden" />
</button>
</Panel>
</div>

View File

@ -1,121 +1,78 @@
<script lang="ts">
import { Upload } from "lucide-svelte";
import { UploadIcon } from "lucide-svelte";
import Panel from "../visual/Panel.svelte";
import clsx from "clsx";
import { onMount } from "svelte";
import { files } from "$lib/store/index.svelte";
import { converters } from "$lib/converters";
import { goto } from "$app/navigation";
let fileList = $state<FileList>();
let dragBtn = $state<HTMLButtonElement>();
type Props = {
class?: string;
};
interface Props {
files: File[] | undefined;
onupload?: () => void;
isMobile: boolean;
acceptedFormats?: string[];
}
const { class: classList }: Props = $props();
$effect(() => {
if (!fileList) return;
files = Array.from(fileList);
});
let uploaderButton = $state<HTMLButtonElement>();
let fileInput = $state<HTMLInputElement>();
let dragOver = $state(false);
let { files = $bindable(), onupload, isMobile, acceptedFormats }: Props = $props();
function upload() {
if (!fileInput) return;
fileInput.click();
}
const uploadFiles = async () => {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
// filter converters to ones where await converter.valid() is true
const filteredConverters = (
await Promise.all(
converters.map(async (c) => {
if (await c.valid()) return c;
}),
)
).filter((c) => typeof c !== "undefined");
input.accept = filteredConverters
.map((c) => c.supportedFormats.join(","))
.join(",");
input.onchange = (e) => {
const oldLength = files.files.length;
files.add(input.files);
if (oldLength !== files.files.length) goto("/convert");
};
input.click();
};
onMount(() => {
const handler = (e: Event) => e.preventDefault();
if (!dragBtn) return;
dragBtn.addEventListener("dragenter", handler);
dragBtn.addEventListener("dragstart", handler);
dragBtn.addEventListener("dragend", handler);
dragBtn.addEventListener("dragleave", handler);
dragBtn.addEventListener("dragover", handler);
dragBtn.addEventListener("drag", handler);
dragBtn.addEventListener("drop", handler);
const handler = (e: Event) => {
e.preventDefault();
return false;
};
uploaderButton?.addEventListener("dragover", handler);
uploaderButton?.addEventListener("dragenter", handler);
uploaderButton?.addEventListener("dragleave", handler);
uploaderButton?.addEventListener("drop", handler);
return () => {
if (!dragBtn) return;
dragBtn.removeEventListener("dragenter", handler);
dragBtn.removeEventListener("dragstart", handler);
dragBtn.removeEventListener("dragend", handler);
dragBtn.removeEventListener("dragleave", handler);
dragBtn.removeEventListener("dragover", handler);
dragBtn.removeEventListener("drag", handler);
dragBtn.removeEventListener("drop", handler);
uploaderButton?.removeEventListener("dragover", handler);
uploaderButton?.removeEventListener("dragenter", handler);
uploaderButton?.removeEventListener("dragleave", handler);
uploaderButton?.removeEventListener("drop", handler);
};
});
function drop(event: DragEvent) {
event.preventDefault();
dragOver = false;
if (!event.dataTransfer) return;
if (!files) files = Array.from(event.dataTransfer.files);
else files.push(...Array.from(event.dataTransfer.files));
onupload?.();
return true;
}
function addFiles() {
if (!fileInput) return;
if (!fileInput.files) return;
if (!files) files = Array.from(fileInput.files);
else files.push(...Array.from(fileInput.files));
onupload?.();
}
</script>
<button
bind:this={dragBtn}
onclick={upload}
ondragover={() => (dragOver = true)}
ondragleave={() => (dragOver = false)}
class={clsx(
"file-uploader",
"w-full h-80 flex items-center justify-center cursor-pointer",
"border-2 border-solid border-foreground-muted-alt rounded-2xl",
"hover:scale-95 hover:opacity-70 transition-all duration-150 ease-out",
{
"scale-95 opacity-70 blur-xs": dragOver,
},
)}
class:_drag-over={dragOver}
ondrop={drop}
onclick={uploadFiles}
bind:this={uploaderButton}
class={clsx(`hover:scale-105 active:scale-100 duration-200 ${classList}`)}
>
<div
class="file-uploader-center flex items-center justify-center flex-col transition-all duration-150 ease-out px-8"
<Panel
class="flex justify-center items-center w-full h-full flex-col pointer-events-none"
>
<div
class="size-16 rounded-full text-accent-foreground bg-accent-background flex items-center justify-center"
class="w-16 h-16 bg-accent rounded-full flex items-center justify-center p-4"
>
<Upload class="size-8" />
<UploadIcon class="w-full h-full text-on-accent" />
</div>
<h2 class="font-display text-2xl mt-6">
{isMobile ? "Tap" : "Drop or click"} to upload files
<h2 class="text-center text-2xl font-semibold mt-4">
Drop or click to upload
</h2>
<p class="text-foreground-muted mt-4">
All processing is done on your device. No file or size limit.
</p>
</div>
</Panel>
</button>
<input
type="file"
class="hidden"
bind:this={fileInput}
onchange={addFiles}
accept={acceptedFormats?.join(",") ?? "*"}
multiple
/>
<style>
.file-uploader:hover .file-uploader-center,
.file-uploader._drag-over .file-uploader-center {
@apply scale-105;
}
</style>

View File

@ -0,0 +1,36 @@
<script lang="ts">
type Props = {
class: string;
items: { [name: string]: string };
};
const { class: classList, items }: Props = $props();
const year = new Date().getFullYear();
const links = $derived(Object.entries(items));
</script>
<footer class={classList}>
<div
class="w-full h-full flex items-center justify-center text-muted gap-3 relative"
>
<p>© {year} VERT.</p>
{#each links as [name, url] (name)}
<!-- bullet point -->
<p></p>
<a
class="hover:underline font-normal"
href={url}
target={url.startsWith("http") ? "_blank" : "_self"}
>
{name}
</a>
{/each}
</div>
<div
class="absolute bottom-0 left-0 w-full h-24 -z-10"
style="background: linear-gradient(to bottom, transparent, var(--bg) 100%)"
></div>
</footer>

View File

@ -0,0 +1,14 @@
<script lang="ts">
import type { Snippet } from "svelte";
type Props = {
class?: string;
children: Snippet<[]>;
};
const { class: classList, children }: Props = $props();
</script>
<div class="bg-panel {classList} p-3 rounded-2.5xl shadow-panel">
{@render children?.()}
</div>

View File

@ -12,11 +12,9 @@
);
</script>
<div
class="w-full h-1 dynadark:bg-foreground-muted-alt bg-foreground-muted rounded-full overflow-hidden relative"
>
<div class="w-full h-1 bg-panel-alt rounded-full overflow-hidden relative">
<div
class="h-full bg-accent-background dynadark:bg-accent-foreground absolute left-0 top-0"
class="h-full bg-accent absolute left-0 top-0"
class:percentless-animation={progress === null}
style={percent
? `width: ${percent}%; transition: 500ms linear width;`

View File

@ -0,0 +1,78 @@
<script lang="ts">
import { fade, fly } from "$lib/animation";
import {
BanIcon,
CheckIcon,
InfoIcon,
TriangleAlert,
XIcon,
} from "lucide-svelte";
import { quintOut } from "svelte/easing";
import { removeToast } from "$lib/store/ToastProvider";
type Props = {
id: number;
type: "success" | "error" | "info" | "warning";
message: string;
durations: {
enter: number;
stay: number;
exit: number;
};
};
let { id, type, message, durations }: Props = $props();
const color = {
success: "purple",
error: "red",
info: "blue",
warning: "pink",
}[type];
const Icon = {
success: CheckIcon,
error: BanIcon,
info: InfoIcon,
warning: TriangleAlert,
}[type];
// intentionally unused. this is so tailwind can generate the css for these colours as it doesn't detect if it's dynamically loaded
// this would lead to the colours not being generated in the final css file by tailwind
const colourVariants = [
"border-accent-pink-alt",
"border-accent-red-alt",
"border-accent-purple-alt",
"border-accent-blue-alt",
];
</script>
<div
class="flex items-center justify-between w-full max-w-sm p-4 gap-4 bg-accent-{color} border-accent-{color}-alt border-l-4 rounded-lg shadow-md"
in:fly={{
duration: durations.enter,
easing: quintOut,
x: 0,
y: 100,
}}
out:fade={{
duration: durations.exit,
easing: quintOut,
}}
>
<div class="flex items-center gap-4">
<Icon
class="w-6 h-6 text-black flex-shrink-0"
size="32"
stroke="2"
fill="none"
/>
<p class="text-black font-normal">{message}</p>
</div>
<button
class="text-gray-600 hover:text-black"
onclick={() => removeToast(id)}
>
<XIcon size="16" />
</button>
</div>

View File

@ -1,27 +1,16 @@
<svg
width="100%"
height="100%"
viewBox="0 0 404 96"
style="
fill-rule: evenodd;
clip-rule: evenodd;
stroke-linejoin: round;
stroke-miterlimit: 2;
"
viewBox="0 0 300 83"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xml:space="preserve"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"
>
<path
d="M37.166,96l27.291,0l37.303,-96l-27.017,0l-23.726,66.24l-23.588,-66.24l-27.429,0l37.166,96Z"
style="fill-rule: nonzero"
/>
<path
d="M194.057,96l0,-21.669l-58.56,0l0,-16.32l56.64,0l0,-21.668l-56.64,-0l0,-14.674l57.874,-0l0,-21.669l-82.011,-0l0,96l82.697,0Z"
style="fill-rule: nonzero"
/>
<path
d="M209.143,33.463l-0,-33.463l58.697,0c24.686,0 37.029,12.206 37.029,31.68c-0,16.869 -10.286,29.349 -31.132,30.994l33.189,33.326l-34.835,0l-62.948,-62.537Zm71.588,-0.686c0,-6.72 -6.308,-11.52 -15.634,-11.52l-31.817,0l0,23.452l31.817,-0c9.189,-0 15.634,-4.526 15.634,-11.932Zm-71.588,13.692l24.137,49.531l-24.137,0l-0,-49.531Z"
/>
<path
d="M370.971,96l0,-74.194l32.915,-0l-0,-21.806l-89.966,0l0,21.806l32.914,-0l0,74.194l24.137,0Z"
style="fill-rule: nonzero"
/>
<g transform="matrix(0.172257,0,0,0.172257,-160.012,-223.436)">
<path
d="M1082.77,1777.46L928.913,1297.11L1043.78,1297.11L1191.37,1777.46L1082.77,1777.46ZM1188.94,1620.03L1285.35,1297.11L1398.82,1297.11L1261.8,1724.91L1188.94,1620.03ZM1803.99,1777.46L1441.99,1777.46L1441.99,1297.11L1801.21,1297.11L1801.21,1398.05L1549.89,1398.05L1549.89,1485.77L1771.27,1485.77L1771.27,1581.14L1549.89,1581.14L1549.89,1676.52L1803.99,1676.52L1803.99,1777.46ZM1980.12,1615.25L1980.12,1777.46L1872.22,1777.46L1872.22,1297.11L2069.23,1297.11C2127.24,1297.11 2171.57,1311.49 2202.2,1340.27C2232.83,1369.04 2248.14,1407.57 2248.14,1455.83C2248.14,1504.1 2232.83,1542.74 2202.2,1571.74C2187.36,1585.8 2169.3,1596.44 2148.04,1603.69L2261.37,1777.46L2140.24,1777.46L2042.05,1615.25L1980.12,1615.25ZM1980.12,1398.05L1980.12,1514.31L2062.96,1514.31C2089.42,1514.31 2108.56,1509.32 2120.4,1499.34C2132.23,1489.36 2138.15,1474.86 2138.15,1455.83C2138.15,1436.8 2132.23,1422.42 2120.4,1412.67C2108.56,1402.92 2089.42,1398.05 2062.96,1398.05L1980.12,1398.05ZM2422.18,1398.05L2282.95,1398.05L2282.95,1297.11L2668.62,1297.11L2668.62,1398.05L2529.39,1398.05L2529.39,1777.46L2422.18,1777.46L2422.18,1398.05Z"
/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 955 B

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,61 @@
<svg
width="1389"
height="1080"
viewBox="0 0 1389 1080"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M418.719 1080L0.480804 0H2.62554L420.863 1080H418.719Z"
fill="url(#paint0_linear_6_220)"
fill-opacity="0.1"
/>
<path
d="M829.044 1080L412.359 0H410.215L826.9 1080H829.044Z"
fill="url(#paint1_linear_6_220)"
fill-opacity="0.1"
/>
<path
d="M788.673 555.925L987.856 0H989.981L790.985 555.402L1064.61 827.169L1386.13 0H1388.27L1065.37 830.741L788.673 555.925Z"
fill="url(#paint2_linear_6_220)"
fill-opacity="0.1"
/>
<defs>
<linearGradient
id="paint0_linear_6_220"
x1="694.377"
y1="0"
x2="694.377"
y2="1080"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="white" />
<stop offset="0.75" stop-color="white" />
<stop offset="1" stop-color="white" stop-opacity="0" />
</linearGradient>
<linearGradient
id="paint1_linear_6_220"
x1="694.377"
y1="0"
x2="694.377"
y2="1080"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="white" />
<stop offset="0.75" stop-color="white" />
<stop offset="1" stop-color="white" stop-opacity="0" />
</linearGradient>
<linearGradient
id="paint2_linear_6_220"
x1="694.377"
y1="0"
x2="694.377"
y2="1080"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="white" />
<stop offset="0.75" stop-color="white" />
<stop offset="1" stop-color="white" stop-opacity="0" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

12
src/lib/consts.ts Normal file
View File

@ -0,0 +1,12 @@
import { PUB_ENV } from "$env/static/public";
export const GITHUB_URL_VERT = "https://github.com/VERT-sh/VERT";
export const GITHUB_URL_VERTD = "https://github.com/VERT-sh/vertd";
export const GITHUB_API_URL = "https://api.github.com/repos/VERT-sh/VERT";
export const DISCORD_URL = "https://discord.gg/kqevGxYPak";
export const VERT_NAME =
PUB_ENV === "development"
? "VERT Local"
: PUB_ENV === "nightly"
? "VERT Nightly"
: "VERT.sh";

View File

@ -28,4 +28,8 @@ export class Converter {
): Promise<VertFile> {
throw new Error("Not implemented");
}
public async valid(): Promise<boolean> {
return true;
}
}

View File

@ -2,7 +2,8 @@ import { VertFile } from "$lib/types";
import { Converter } from "./converter.svelte";
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { browser } from "$app/environment";
import { log } from "$lib/logger";
import { error, log } from "$lib/logger";
import { addToast } from "$lib/store/ToastProvider";
export class FFmpegConverter extends Converter {
private ffmpeg: FFmpeg = null!;
@ -30,17 +31,25 @@ export class FFmpegConverter extends Converter {
super();
log(["converters", this.name], `created converter`);
if (!browser) return;
this.ffmpeg = new FFmpeg();
(async () => {
const baseURL =
"https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm";
await this.ffmpeg.load({
coreURL: `${baseURL}/ffmpeg-core.js`,
wasmURL: `${baseURL}/ffmpeg-core.wasm`,
});
// this is just to cache the wasm and js for when we actually use it. we're not using this ffmpeg instance
this.ready = true;
})();
try {
this.ffmpeg = new FFmpeg();
(async () => {
const baseURL =
"https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm";
await this.ffmpeg.load({
coreURL: `${baseURL}/ffmpeg-core.js`,
wasmURL: `${baseURL}/ffmpeg-core.wasm`,
});
// this is just to cache the wasm and js for when we actually use it. we're not using this ffmpeg instance
this.ready = true;
})();
} catch (err) {
error(["converters", this.name], `error loading ffmpeg: ${err}`);
addToast(
"error",
`Error loading ffmpeg, some features may not work.`,
);
}
}
public async convert(input: VertFile, to: string): Promise<VertFile> {

View File

@ -1,4 +1,9 @@
import { FFmpegConverter } from "./ffmpeg.svelte";
import { VertdConverter } from "./vertd.svelte";
import { VipsConverter } from "./vips.svelte";
export const converters = [new VipsConverter(), new FFmpegConverter()];
export const converters = [
new VipsConverter(),
new FFmpegConverter(),
new VertdConverter(),
];

View File

@ -0,0 +1,216 @@
import { log } from "$lib/logger";
import { Settings } from "$lib/sections/settings/index.svelte";
import { VertFile } from "$lib/types";
import { Converter } from "./converter.svelte";
interface VertdError {
type: "error";
data: string;
}
interface VertdSuccess<T> {
type: "success";
data: T;
}
type VertdResponse<T> = VertdError | VertdSuccess<T>;
interface UploadResponse {
id: string;
auth: string;
from: string;
to: null;
completed: false;
totalFrames: number;
}
interface RouteMap {
"/api/upload": UploadResponse;
"/api/version": string;
}
const vertdFetch = async <U extends keyof RouteMap>(
url: U,
options: RequestInit,
): Promise<RouteMap[U]> => {
const domain = Settings.instance.settings.vertdURL;
const res = await fetch(`${domain}${url}`, options);
const text = await res.text();
let json: VertdResponse<RouteMap[U]> = null!;
try {
json = JSON.parse(text);
} catch {
throw new Error(text);
}
if (json.type === "error") {
throw new Error(json.data);
}
return json.data as RouteMap[U];
};
// ws types
export type ConversionSpeed =
| "verySlow"
| "slower"
| "slow"
| "medium"
| "fast"
| "ultraFast";
interface StartJobMessage {
type: "startJob";
data: {
token: string;
jobId: string;
to: string;
speed: ConversionSpeed;
};
}
interface ErrorMessage {
type: "error";
data: {
message: string;
};
}
interface ProgressMessage {
type: "progressUpdate";
data: ProgressData;
}
interface CompletedMessage {
type: "jobFinished";
data: {
jobId: string;
};
}
interface FpsProgress {
type: "fps";
data: number;
}
interface FrameProgress {
type: "frame";
data: number;
}
type ProgressData = FpsProgress | FrameProgress;
type VertdMessage =
| StartJobMessage
| ErrorMessage
| ProgressMessage
| CompletedMessage;
export class VertdConverter extends Converter {
public name = "vertd";
public ready = $state(false);
public reportsProgress = true;
public supportedFormats = [".mkv", ".mp4", ".webm", ".avi", ".wmv", ".mov"];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private log: (...msg: any[]) => void = () => {};
constructor() {
super();
this.log = (msg) => log(["converters", this.name], msg);
this.log("created converter");
this.log("not rly sure how to implement this :P");
this.ready = true;
}
public async convert(input: VertFile, to: string): Promise<VertFile> {
if (to.startsWith(".")) to = to.slice(1);
// POST http://localhost:8080/api/upload
// multipart body, key is "file", value is the file
const formData = new FormData();
formData.append("file", input.file, input.name);
// const uploadRes = await fetch("http://localhost:8080/api/upload", {
// method: "POST",
// body: formData,
// });
const uploadRes = await vertdFetch("/api/upload", {
method: "POST",
body: formData,
});
return new Promise((resolve, reject) => {
const apiUrl = Settings.instance.settings.vertdURL;
const ws = new WebSocket(
`ws://${apiUrl.replace("http://", "").replace("https://", "")}/api/ws`,
);
ws.onopen = () => {
const speed = Settings.instance.settings.vertdSpeed;
this.log("opened ws connection to vertd");
const msg: StartJobMessage = {
type: "startJob",
data: {
jobId: uploadRes.id,
token: uploadRes.auth,
to,
speed,
},
};
ws.send(JSON.stringify(msg));
this.log("sent startJob message");
};
ws.onmessage = async (e) => {
const msg: VertdMessage = JSON.parse(e.data);
this.log(`received message ${msg.type}`);
switch (msg.type) {
case "progressUpdate": {
const data = msg.data;
if (data.type !== "frame") break;
const frame = data.data;
input.progress = (frame / uploadRes.totalFrames) * 100;
break;
}
case "jobFinished": {
this.log("job finished");
ws.close();
const url = `${apiUrl}/api/download/${msg.data.jobId}/${uploadRes.auth}`;
this.log(`downloading from ${url}`);
const res = await fetch(url).then((res) => res.blob());
resolve(
new VertFile(
new File([res], input.name),
to,
this,
undefined,
),
);
break;
}
case "error": {
this.log(`error: ${msg.data.message}`);
reject(msg.data.message);
}
}
};
});
}
public async valid(): Promise<boolean> {
if (!Settings.instance.settings.vertdURL) {
return false;
}
try {
await vertdFetch("/api/version", {
method: "GET",
});
return true;
} catch (e) {
this.log(e as unknown as string);
return false;
}
}
}

View File

@ -1,9 +1,10 @@
import { VertFile } from "$lib/types";
import { Converter } from "./converter.svelte";
import VipsWorker from "$lib/workers/vips?worker&url";
import { browser } from "$app/environment";
import type { WorkerMessage, OmitBetterStrict } from "$lib/types";
import { log } from "$lib/logger";
import { error, log } from "$lib/logger";
import { addToast } from "$lib/store/ToastProvider";
import type { OmitBetterStrict, WorkerMessage } from "$lib/types";
import { VertFile } from "$lib/types";
import VipsWorker from "$lib/workers/vips?worker&url";
import { Converter } from "./converter.svelte";
export class VipsConverter extends Converter {
private worker: Worker = browser
@ -44,7 +45,15 @@ export class VipsConverter extends Converter {
this.worker.onmessage = (e) => {
const message: WorkerMessage = e.data;
log(["converters", this.name], `received message ${message.type}`);
if (message.type === "loaded") this.ready = true;
if (message.type === "loaded") {
this.ready = true;
} else if (message.type === "error") {
error(["converters", this.name], `error in worker: ${message.error}`);
addToast("error", `Error in VIPS worker, some features may not work.`);
throw new Error(message.error);
} else {
error(["converters", this.name], `unknown message type: ${message.type}`);
}
};
}
@ -103,7 +112,7 @@ export class VipsConverter extends Converter {
try {
this.worker.postMessage(msg);
} catch (e) {
console.error(e);
error(["converters", this.name], e);
}
});
}

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { browser } from "$app/environment";
const randomColorFromStr = (str: string) => {
@ -20,7 +21,6 @@ const whiteOrBlack = (hsl: string) => {
return l > 70 ? "black" : "white";
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const log = (prefix: string | string[], ...args: any[]) => {
const prefixes = Array.isArray(prefix) ? prefix : [prefix];
if (!browser)
@ -40,3 +40,23 @@ export const log = (prefix: string | string[], ...args: any[]) => {
...args,
);
};
export const error = (prefix: string | string[], ...args: any[]) => {
const prefixes = Array.isArray(prefix) ? prefix : [prefix];
if (!browser)
return console.error(prefixes.map((p) => `[${p}]`).join(" "), ...args);
const prefixesWithMeta = prefixes.map((p) => ({
prefix: p,
bgColor: randomColorFromStr(p),
textColor: whiteOrBlack(randomColorFromStr(p)),
}));
console.error(
`%c${prefixesWithMeta.map(({ prefix }) => prefix).join(" %c")}`,
...prefixesWithMeta.map(
({ bgColor, textColor }, i) =>
`color: ${textColor}; background-color: ${bgColor}; margin-left: ${i === 0 ? 0 : -6}px; padding: 0px 4px 0 4px; border-radius: 0px 9999px 9999px 0px;`,
),
...args,
);
}

View File

@ -0,0 +1,98 @@
<script lang="ts">
import Panel from "$lib/components/visual/Panel.svelte";
import { HeartHandshakeIcon } from "lucide-svelte";
import { GITHUB_URL_VERT } from "$lib/consts";
let { mainContribs, ghContribs } = $props();
</script>
{#snippet contributor(
name: string,
github: string,
avatar: string,
role?: string,
)}
<div class="flex items-center gap-4">
<a
href={github}
target="_blank"
rel="noopener noreferrer"
class="flex-shrink-0"
>
<img
src={avatar}
alt={name}
title={name}
class="{role
? 'w-14 h-14 hoverable-md'
: 'w-10 h-10 hoverable-lg'} rounded-full"
/>
</a>
{#if role}
<div class="flex flex-col gap-1">
<p class="text-xl font-semibold">{name}</p>
<p class="text-sm font-normal text-muted">{role}</p>
</div>
{/if}
</div>
{/snippet}
<Panel class="flex flex-col gap-8 p-6">
<h2 class="text-2xl font-bold flex items-center">
<div class="rounded-full bg-blue-300 p-2 inline-block mr-3 w-10 h-10">
<HeartHandshakeIcon color="black" />
</div>
Credits
</h2>
<!-- Main contributors -->
<div class="flex flex-col gap-4">
<div class="flex flex-row flex-wrap gap-2">
{#each mainContribs as contrib}
{@const { name, github, avatar, role } = contrib}
{@render contributor(name, github, avatar, role)}
{/each}
</div>
</div>
<!-- GitHub contributors -->
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<h2 class="text-base font-bold">GitHub contributors</h2>
{#if ghContribs && ghContribs.length > 0}
<p class="text-base text-muted font-normal">
Big thanks to all these people for helping out!
<a
class="text-blue-500 font-normal hover:underline"
href={GITHUB_URL_VERT}
target="_blank"
rel="noopener noreferrer"
>
Want to help too?
</a>
</p>
{:else}
<p class="text-base text-muted font-normal italic">
Seems like no one has contributed yet...
<a
class="text-blue-500 font-normal hover:underline"
href={GITHUB_URL_VERT}
target="_blank"
rel="noopener noreferrer"
>
be the first to contribute!
</a>
</p>
{/if}
</div>
{#if ghContribs && ghContribs.length > 0}
<div class="flex flex-row flex-wrap gap-2">
{#each ghContribs as contrib}
{@const { name, github, avatar } = contrib}
{@render contributor(name, github, avatar)}
{/each}
</div>
{/if}
</div>
</Panel>

View File

@ -0,0 +1,121 @@
<script lang="ts">
import FancyInput from "$lib/components/functional/FancyInput.svelte";
import Panel from "$lib/components/visual/Panel.svelte";
import {
CalendarHeartIcon,
HandCoinsIcon,
HeartIcon,
WalletIcon,
} from "lucide-svelte";
let { donors } = $props();
</script>
{#snippet donor(name: string, amount: number | string, avatar: string)}
<div class="flex items-center bg-button rounded-full overflow-hidden">
<img
src={avatar}
alt={name}
title={name}
class="w-9 h-9 rounded-full"
/>
<p class="text-sm text-black dynadark:text-white px-4">${amount}</p>
</div>
{/snippet}
<Panel class="flex flex-col gap-8 p-6">
<div class="flex flex-col gap-3">
<h2 class="text-2xl font-bold flex items-center">
<div
class="rounded-full bg-accent-red p-2 inline-block mr-3 w-10 h-10"
>
<HeartIcon color="black" />
</div>
Donate to VERT
</h2>
<p class="text-base font-normal">
With your support, we can keep maintaining and improving VERT.
</p>
</div>
<div class="flex flex-col gap-3 w-full">
<div class="flex gap-3 w-full">
<button
class="btn flex-1 p-4 rounded-lg bg-accent-red text-black flex items-center justify-center"
>
<HandCoinsIcon size="24" class="inline-block mr-2" />
One-time
</button>
<button
class="btn flex-1 p-4 rounded-lg bg-button text-black dynadark:text-white flex items-center justify-center"
>
<CalendarHeartIcon size="24" class="inline-block mr-2" />
Monthly
</button>
</div>
<div class="flex gap-3 w-full">
<button class="btn bg-accent-red text-black p-4 rounded-lg flex-1"
>$1 USD</button
>
<button
class="btn bg-button text-black dynadark:text-white p-4 rounded-lg flex-1"
>$5 USD</button
>
<button
class="btn bg-button text-black dynadark:text-white p-4 rounded-lg flex-1"
>$10 USD</button
>
<!-- <div class="relative flex items-center flex-[2]">
<span class="absolute left-3 text-gray-500">$</span>
<input
type="number"
class="pl-8 pr-2 rounded-lg border border-gray-300 dynadark:border-gray-500 w-full h-full bg-button text-black dynadark:text-white"
placeholder="Custom"
/>
</div> -->
<div class="flex-[2] flex items-center justify-center">
<FancyInput placeholder="Custom" prefix="$" type="number" />
</div>
</div>
</div>
<div class="flex flex-row justify-center w-full">
<p class="text-muted text-sm flex-[4] flex items-center">
Payments and subscription management <br /> are handled through Liberapay
</p>
<button
class="btn flex-1 p-3 rounded-3xl bg-accent-red text-black flex items-center justify-center"
>
<WalletIcon size="24" class="inline-block mr-2" />
Pay now
</button>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<h2 class="text-base font-bold">Our top donors</h2>
{#if donors && donors.length > 0}
<p class="text-base text-muted font-normal">
People like these fuel the things we love to do. Thank you
so much!
</p>
{:else}
<p class="text-base text-muted font-normal italic">
Seems like no one has donated yet... so if you do, you will
pop up here!
</p>
{/if}
</div>
{#if donors && donors.length > 0}
<div class="flex flex-row flex-wrap gap-2">
{#each donors as dono}
{@const { name, amount, avatar } = dono}
{@render donor(name, amount, avatar)}
{/each}
</div>
{/if}
</div>
</Panel>

View File

@ -0,0 +1,36 @@
<script lang="ts">
import Panel from "$lib/components/visual/Panel.svelte";
import { DISCORD_URL, GITHUB_URL_VERT } from "$lib/consts";
import { GithubIcon, LinkIcon, MessageCircleMoreIcon } from "lucide-svelte";
</script>
<Panel class="flex flex-col gap-4 p-6">
<h2 class="text-2xl font-bold flex items-center">
<div
class="rounded-full bg-accent-purple p-2 inline-block mr-3 w-10 h-10"
>
<LinkIcon color="black" />
</div>
Resources
</h2>
<div class="flex gap-3">
<a
href={DISCORD_URL}
target="_blank"
rel="noopener noreferrer"
class="btn flex-1 gap-2 p-4 rounded-full bg-button text-black dynadark:text-white flex items-center justify-center"
>
<MessageCircleMoreIcon size="24" class="inline-block mr-2" />
Discord
</a>
<a
href={GITHUB_URL_VERT}
target="_blank"
rel="noopener noreferrer"
class="btn flex-1 gap-2 p-4 rounded-full bg-button text-black dynadark:text-white flex items-center justify-center"
>
<GithubIcon size="24" class="inline-block mr-2" />
Source
</a>
</div>
</Panel>

View File

@ -0,0 +1,25 @@
<script lang="ts">
import Panel from "$lib/components/visual/Panel.svelte";
import { MessageCircleQuestionIcon } from "lucide-svelte";
</script>
<Panel class="flex flex-col gap-3 p-6">
<h2 class="text-2xl font-bold flex items-center">
<div
class="rounded-full bg-accent-pink p-2 inline-block mr-3 w-10 h-10"
>
<MessageCircleQuestionIcon color="black" />
</div>
Why VERT?
</h2>
<p class="text-lg font-normal">
<b>File converters have always disappointed us.</b> They're ugly,
riddled with ads, and most importantly; slow. We decided to solve this
problem once and for all by making an alternative that solves all those
problems, and more.<br />
<br />
All your files are converted completely on-device; this means that there's
no delay between sending and receiving the files from a server, and we never
get to snoop on the files you convert.
</p>
</Panel>

View File

@ -0,0 +1,4 @@
export { default as Credits } from "./Credits.svelte";
export { default as Donate } from "./Donate.svelte";
export { default as Resources } from "./Resources.svelte";
export { default as Why } from "./Why.svelte";

View File

@ -0,0 +1,132 @@
<script lang="ts">
import Panel from "$lib/components/visual/Panel.svelte";
import {
theme,
effects,
setEffects,
setTheme,
} from "$lib/store/index.svelte";
import {
MoonIcon,
PaletteIcon,
PauseIcon,
PlayIcon,
SunIcon,
} from "lucide-svelte";
import { onMount, onDestroy } from "svelte";
let lightElement: HTMLButtonElement;
let darkElement: HTMLButtonElement;
let enableEffectsElement: HTMLButtonElement;
let disableEffectsElement: HTMLButtonElement;
let effectsUnsubscribe: () => void;
let themeUnsubscribe: () => void;
const updateEffectsClasses = (value: boolean) => {
if (value) {
enableEffectsElement.classList.add("selected");
disableEffectsElement.classList.remove("selected");
} else {
disableEffectsElement.classList.add("selected");
enableEffectsElement.classList.remove("selected");
}
};
const updateThemeClasses = (value: string) => {
document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(value);
if (value === "dark") {
darkElement.classList.add("selected");
lightElement.classList.remove("selected");
} else {
lightElement.classList.add("selected");
darkElement.classList.remove("selected");
}
};
onMount(() => {
effectsUnsubscribe = effects.subscribe(updateEffectsClasses);
themeUnsubscribe = theme.subscribe(updateThemeClasses);
});
onDestroy(() => {
if (effectsUnsubscribe) effectsUnsubscribe();
if (themeUnsubscribe) themeUnsubscribe();
});
</script>
<Panel class="flex flex-col gap-8 p-6">
<div class="flex flex-col gap-3">
<h2 class="text-2xl font-bold">
<PaletteIcon
size="40"
class="inline-block -mt-1 mr-2 bg-accent-purple p-2 rounded-full"
color="black"
/>
Appearance
</h2>
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<p class="text-base font-bold">Brightness theme</p>
<p class="text-sm text-muted font-normal italic">
Want a sunny flash-bang, or a quiet lonely night?
</p>
</div>
<div class="flex flex-col gap-3 w-full">
<div class="flex gap-3 w-full">
<button
bind:this={lightElement}
onclick={() => setTheme("light")}
class="btn flex-1 p-4 rounded-lg text-black dynadark:text-white flex items-center justify-center"
>
<SunIcon size="24" class="inline-block mr-2" />
Light
</button>
<button
bind:this={darkElement}
onclick={() => setTheme("dark")}
class="btn flex-1 p-4 rounded-lg text-black flex items-center justify-center"
>
<MoonIcon size="24" class="inline-block mr-2" />
Dark
</button>
</div>
</div>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<p class="text-base font-bold">Effect settings</p>
<p class="text-sm text-muted font-normal italic">
Would you like fancy effects, or a more static
experience?
</p>
</div>
<div class="flex flex-col gap-3 w-full">
<div class="flex gap-3 w-full">
<button
bind:this={enableEffectsElement}
onclick={() => setEffects(true)}
class="btn flex-1 p-4 rounded-lg text-black dynadark:text-white flex items-center justify-center"
>
<PlayIcon size="24" class="inline-block mr-2" />
Enable
</button>
<button
bind:this={disableEffectsElement}
onclick={() => setEffects(false)}
class="btn flex-1 p-4 rounded-lg text-black dynadark:text-white flex items-center justify-center"
>
<PauseIcon size="24" class="inline-block mr-2" />
Disable
</button>
</div>
</div>
</div>
</div>
</div>
</Panel>

View File

@ -0,0 +1,48 @@
<script lang="ts">
import FancyTextInput from "$lib/components/functional/FancyInput.svelte";
import Panel from "$lib/components/visual/Panel.svelte";
import { RefreshCwIcon } from "lucide-svelte";
import type { ISettings } from "./index.svelte";
const { settings }: { settings: ISettings } = $props();
</script>
<Panel class="flex flex-col gap-8 p-6">
<div class="flex flex-col gap-3">
<h2 class="text-2xl font-bold">
<RefreshCwIcon
size="40"
class="inline-block -mt-1 mr-2 bg-accent p-2 rounded-full"
color="black"
/>
Conversion
</h2>
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<p class="text-base font-bold">File name format</p>
<p class="text-sm text-muted font-normal">
This will determine the name of the file on download, <span
class="font-bold italic"
>not including the file extension.</span
>
You can put these following templates in the format, which
will be replaced with the relevant information:
<span class="font-bold">%name%</span>
for the original file name,
<span class="font-bold">%extension%</span>
for the original file extension, and
<span class="font-bold">%date%</span>
for a date string of when the file was converted.
</p>
</div>
<FancyTextInput
placeholder="VERT_%name%"
bind:value={settings.filenameFormat}
extension=".ext"
type="text"
/>
</div>
</div>
</div>
</Panel>

View File

@ -0,0 +1,154 @@
<script lang="ts">
import Panel from "$lib/components/visual/Panel.svelte";
import { GITHUB_URL_VERTD } from "$lib/consts";
import { ServerIcon } from "lucide-svelte";
import type { ISettings } from "./index.svelte";
import clsx from "clsx";
import Dropdown from "$lib/components/functional/Dropdown.svelte";
let vertdCommit = $state<string | null>(null);
let abortController: AbortController | null = null;
const { settings }: { settings: ISettings } = $props();
$effect(() => {
if (settings.vertdURL) {
if (abortController) abortController.abort();
abortController = new AbortController();
const { signal } = abortController;
vertdCommit = "loading";
fetch(`${settings.vertdURL}/api/version`, { signal })
.then((res) => {
if (!res.ok) throw new Error("bad response");
return res.json();
})
.then((data) => {
vertdCommit = data.data;
})
.catch((err) => {
if (err.name !== "AbortError") vertdCommit = null;
});
} else {
if (abortController) abortController.abort();
vertdCommit = null;
}
return () => {
if (abortController) abortController.abort();
};
});
</script>
<Panel class="flex flex-col gap-8 p-6">
<div class="flex flex-col gap-3">
<h2 class="text-2xl font-bold">
<ServerIcon
size="40"
class="inline-block -mt-1 mr-2 bg-accent-red p-2 rounded-full overflow-visible"
color="black"
/>
Converting Video
</h2>
<p
class={clsx("text-sm font-normal", {
"text-failure": vertdCommit === null,
"text-green-700 dynadark:text-green-300": vertdCommit !== null,
"!text-muted": vertdCommit === "loading",
})}
>
status: {vertdCommit
? vertdCommit === "loading"
? "loading..."
: `available, commit id ${vertdCommit}`
: "unavailable (is the url right?)"}
</p>
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<p class="text-sm text-muted font-normal">
The <code>vertd</code> project is a server wrapper for FFmpeg.
This allows you to convert videos through the convenience of
VERT's web interface, while still being able to harness the power
of your GPU to do it as quickly as possible.
</p>
<p class="text-sm text-muted font-normal">
We currently don't provide a hosted instance due to the
upkeep costs. However, it's quite easy to host one on your
own PC or server if you know what you're doing. You can
download the server binaries <a
href={GITHUB_URL_VERTD}
target="_blank">here</a
>. The process of setting this up will become easier in the
future, so stay tuned!
</p>
<div class="flex flex-col gap-2">
<p class="text-base font-bold">Instance URL</p>
<input
type="text"
placeholder="Example: http://localhost:24153"
bind:value={settings.vertdURL}
/>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<p class="text-base font-bold">Conversion speed</p>
<p class="text-sm text-muted font-normal">
This describes the tradeoff between speed and
quality. Faster speeds will result in lower quality,
but will get the job done quicker.
</p>
</div>
<Dropdown
options={[
"Very Slow",
"Slower",
"Slow",
"Medium",
"Fast",
"Ultra Fast",
]}
settingsStyle
selected={(() => {
switch (settings.vertdSpeed) {
case "verySlow":
return "Very Slow";
case "slower":
return "Slower";
case "slow":
return "Slow";
case "medium":
return "Medium";
case "fast":
return "Fast";
case "ultraFast":
return "Ultra Fast";
}
})()}
onselect={(selected) => {
switch (selected) {
case "Very Slow":
settings.vertdSpeed = "verySlow";
break;
case "Slower":
settings.vertdSpeed = "slower";
break;
case "Slow":
settings.vertdSpeed = "slow";
break;
case "Medium":
settings.vertdSpeed = "medium";
break;
case "Fast":
settings.vertdSpeed = "fast";
break;
case "Ultra Fast":
settings.vertdSpeed = "ultraFast";
break;
}
}}
/>
</div>
</div>
</div>
</div>
</Panel>

View File

@ -0,0 +1,35 @@
import type { ConversionSpeed } from "$lib/converters/vertd.svelte";
export { default as Appearance } from "./Appearance.svelte";
export { default as Conversion } from "./Conversion.svelte";
export { default as Vertd } from "./Vertd.svelte";
export interface ISettings {
filenameFormat: string;
vertdURL: string;
vertdSpeed: ConversionSpeed;
}
export class Settings {
public static instance = new Settings();
public settings: ISettings = $state({
filenameFormat: "VERT_%name%",
vertdURL: "",
vertdSpeed: "slow",
});
public save() {
localStorage.setItem("settings", JSON.stringify(this.settings));
}
public load() {
const ls = localStorage.getItem("settings");
if (!ls) return;
const settings: ISettings = JSON.parse(ls);
this.settings = {
...this.settings,
...settings,
};
}
}

View File

@ -0,0 +1,68 @@
import { writable } from "svelte/store";
export type ToastType = "success" | "error" | "info" | "warning";
export interface Toast {
id: number;
type: ToastType;
message: string;
disappearing: boolean;
durations: {
enter: number;
stay: number;
exit: number;
};
}
const toasts = writable<Toast[]>([]);
let toastId = 0;
function addToast(
type: ToastType,
message: string,
disappearing?: boolean,
durations?: { enter: number; stay: number; exit: number },
) {
const id = toastId++;
durations = durations ?? {
enter: 300,
stay: disappearing || disappearing === undefined ? 5000 : 86400000, // 24h cause why not
exit: 500,
};
// if "disappearing" not set, default error/warning to infinite duration
if (disappearing === undefined) {
switch (type) {
case "error":
case "warning":
durations.stay = 86400000; // 24h cause why not
break;
}
}
const newToast: Toast = {
id,
type,
message,
disappearing: disappearing ?? true,
durations,
};
toasts.update((currentToasts) => [...currentToasts, newToast]);
setTimeout(
() => {
removeToast(id);
},
durations.enter + durations.stay + durations.exit,
);
}
function removeToast(id: number) {
toasts.update((currentToasts) =>
currentToasts.filter((toast) => toast.id !== id),
);
}
export { toasts, addToast, removeToast };

View File

@ -1,29 +1,235 @@
import { browser } from "$app/environment";
import { log } from "$lib/logger";
import { converters } from "$lib/converters";
import { error, log } from "$lib/logger";
import { VertFile } from "$lib/types";
import JSCookie from "js-cookie";
import jsmediatags from "jsmediatags";
import type { TagType } from "jsmediatags/types";
import { writable } from "svelte/store";
class Files {
public files = $state<VertFile[]>([]);
}
class Theme {
public dark = $state(false);
public toggle = () => {
this.dark = !this.dark;
JSCookie.set("theme", this.dark ? "dark" : "light", {
path: "/",
sameSite: "lax",
expires: 2147483647,
});
log(["theme"], `set to ${this.dark ? "dark" : "light"}`);
if (browser) {
window.plausible("Theme set", {
props: { theme: theme.dark ? "dark" : "light" },
});
public requiredConverters = $derived(
Array.from(new Set(files.files.map((f) => f.converter))),
);
public ready = $derived(
this.files.length === 0
? false
: this.requiredConverters.every((f) => f?.ready) &&
this.files.every((f) => !f.processing),
);
public results = $derived(
this.files.length === 0 ? false : this.files.every((f) => f.result),
);
private _addThumbnail = async (file: VertFile) => {
const isAudio = converters
.find((c) => c.name === "ffmpeg")
?.supportedFormats?.includes(file.from.toLowerCase());
const isVideo = converters
.find((c) => c.name === "vertd")
?.supportedFormats?.includes(file.from.toLowerCase());
try {
if (isAudio) {
// try to get the thumbnail from the audio via jsmmediatags
const tags = await new Promise<TagType>((resolve, reject) => {
jsmediatags.read(file.file, {
onSuccess: (tag) => resolve(tag),
onError: (error) => reject(error),
});
});
if (tags.tags.picture) {
const blob = new Blob(
[new Uint8Array(tags.tags.picture.data)],
{
type: tags.tags.picture.format,
},
);
const url = URL.createObjectURL(blob);
file.blobUrl = url;
}
} else if (isVideo) {
// video
file.blobUrl = await this._generateThumbnailFromMedia(
file.file,
true,
);
} else {
// image
file.blobUrl = await this._generateThumbnailFromMedia(
file.file,
false,
);
}
} catch (e) {
error(["files"], e);
}
};
private async _generateThumbnailFromMedia(
file: File,
isVideo: boolean,
): Promise<string | undefined> {
const maxSize = 180;
const mediaElement = isVideo
? document.createElement("video")
: new Image();
mediaElement.src = URL.createObjectURL(file);
await new Promise((resolve) => {
if (isVideo) {
(mediaElement as HTMLVideoElement).onloadeddata = resolve;
} else {
(mediaElement as HTMLImageElement).onload = resolve;
}
});
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return undefined;
const width = isVideo
? (mediaElement as HTMLVideoElement).videoWidth
: (mediaElement as HTMLImageElement).width;
const height = isVideo
? (mediaElement as HTMLVideoElement).videoHeight
: (mediaElement as HTMLImageElement).height;
const scale = Math.max(maxSize / width, maxSize / height);
canvas.width = width * scale;
canvas.height = height * scale;
ctx.drawImage(mediaElement, 0, 0, canvas.width, canvas.height);
const url = canvas.toDataURL();
canvas.remove();
return url;
}
private _add(file: VertFile | File) {
if (file instanceof VertFile) {
this.files.push(file);
this._addThumbnail(file);
} else {
const format = "." + file.name.split(".").pop()?.toLowerCase();
if (!format) {
log(["files"], `no extension found for ${file.name}`);
return;
}
const converter = converters.find((c) =>
c.supportedFormats.includes(
format || ".somenonexistentextension",
),
);
if (!converter) {
log(["files"], `no converter found for ${file.name}`);
this.files.push(new VertFile(file, format, null));
return;
}
const to = converter.supportedFormats.find((f) => f !== format);
if (!to) {
log(["files"], `no output format found for ${file.name}`);
return;
}
const vf = new VertFile(file, to, converter);
this.files.push(vf);
this._addThumbnail(vf);
}
}
public add(file: VertFile | null | undefined): void;
public add(file: File | null | undefined): void;
public add(file: File[] | null | undefined): void;
public add(file: VertFile[] | null | undefined): void;
public add(file: FileList | null | undefined): void;
public add(
file:
| VertFile
| File
| VertFile[]
| File[]
| FileList
| null
| undefined,
) {
if (!file) return;
if (Array.isArray(file) || file instanceof FileList) {
for (const f of file) {
this._add(f);
}
} else {
this._add(file);
}
}
public async convertAll() {
await Promise.all(this.files.map((f) => f.convert()));
}
public async downloadAll() {
if (files.files.length === 0) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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(),
});
}
const { downloadZip } = await import("client-zip");
const blob = await downloadZip(dlFiles, "converted.zip").blob();
const url = URL.createObjectURL(blob);
const settings = JSON.parse(localStorage.getItem("settings") ?? "{}");
const filenameFormat = settings.filenameFormat ?? "VERT_%name%";
const format = (name: string) => {
const date = new Date().toISOString();
return name
.replace(/%date%/g, date)
.replace(/%name%/g, "Multi")
.replace(/%extension%/g, "");
};
const a = document.createElement("a");
a.href = url;
a.download = `${format(filenameFormat)}.zip`;
a.click();
URL.revokeObjectURL(url);
a.remove();
}
}
export function setTheme(themeTo: "light" | "dark") {
document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(themeTo);
localStorage.setItem("theme", themeTo);
window.plausible("Theme set", {
props: { theme: themeTo },
});
log(["theme"], `set to ${themeTo}`);
theme.set(themeTo);
}
export function setEffects(effectsEnabled: boolean) {
localStorage.setItem("effects", effectsEnabled.toString());
window.plausible("Effects set", {
props: { effects: effectsEnabled },
});
log(["effects"], `set to ${effectsEnabled}`);
effects.set(effectsEnabled);
}
export const files = new Files();
export const theme = new Theme();
export const showGradient = writable(true);
export const gradientColor = writable("");
export const isMobile = writable(false);
export const effects = writable(true);
export const theme = writable<"light" | "dark">("light");

View File

@ -1,4 +1,5 @@
import type { Converter } from "$lib/converters/converter.svelte";
import { addToast } from "$lib/store/ToastProvider";
export class VertFile {
public id: string = Math.random().toString(36).slice(2, 8);
@ -18,12 +19,14 @@ export class VertFile {
public blobUrl = $state<string>();
public processing = $state(false);
public converter: Converter | null = null;
constructor(
public readonly file: File,
to: string,
converter?: Converter,
converter?: Converter | null,
blobUrl?: string,
) {
this.to = to;
@ -37,13 +40,36 @@ export class VertFile {
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;
this.processing = true;
let res;
try {
res = await this.converter.convert(this, this.to);
this.result = res;
} catch (err) {
console.error(err);
addToast("error", `Error converting file: ${this.file.name}`);
this.result = null;
}
this.processing = false;
return res;
}
public async download() {
if (!this.result) throw new Error("No result found");
const settings = JSON.parse(localStorage.getItem("settings") ?? "{}");
const filenameFormat = settings.filenameFormat ?? "VERT_%name%";
const format = (name: string) => {
const date = new Date().toISOString();
const baseName = this.file.name.replace(/\.[^/.]+$/, "");
const originalExtension = this.file.name.split(".").pop()!;
return name
.replace(/%date%/g, date)
.replace(/%name%/g, baseName)
.replace(/%extension%/g, originalExtension);
};
const blob = URL.createObjectURL(
new Blob([await this.result.file.arrayBuffer()], {
type: this.to.slice(1),
@ -51,7 +77,7 @@ export class VertFile {
);
const a = document.createElement("a");
a.href = blob;
a.download = `VERT-Converted_${new Date().toISOString()}${this.to}`;
a.download = `${format(filenameFormat)}${this.to}`;
// force it to not open in a new tab
a.target = "_blank";
a.style.display = "none";

View File

@ -1,20 +0,0 @@
export const load = ({ url, request, cookies }) => {
// if the "theme" cookie isn't "dark" or "light", reset it
const theme = cookies.get("theme") ?? "";
if (theme !== "dark" && theme !== "light") {
cookies.set("theme", "", {
path: "/",
sameSite: "lax",
expires: new Date(0),
});
}
const { pathname } = url;
const ua = request.headers.get("user-agent");
const isMobile = /mobile/i.test(ua || "");
const isFirefox = /firefox/i.test(ua || "");
return {
pathname,
isMobile,
isFirefox,
};
};

View File

@ -1,285 +1,362 @@
<script lang="ts">
import "../app.scss";
import { goto } from "$app/navigation";
import { blur, duration } from "$lib/animation";
import { quintOut } from "svelte/easing";
import { files, theme } from "$lib/store/index.svelte";
import Logo from "$lib/components/visual/svg/Logo.svelte";
import { page } from "$app/state";
import { beforeNavigate, goto } from "$app/navigation";
import { PUB_HOSTNAME, PUB_PLAUSIBLE_URL } from "$env/static/public";
import { duration, fly } from "$lib/animation";
import VertVBig from "$lib/assets/vert-bg.svg?component";
import featuredImage from "$lib/assets/VERT_Feature.webp";
import Navbar from "$lib/components/functional/Navbar.svelte";
import Footer from "$lib/components/visual/Footer.svelte";
import Logo from "$lib/components/visual/svg/Logo.svelte";
import { fade } from "$lib/animation";
import {
PUB_ENV,
PUB_HOSTNAME,
PUB_PLAUSIBLE_URL,
} from "$env/static/public";
import FancyMenu from "$lib/components/functional/FancyMenu.svelte";
import { writable } from "svelte/store";
import { MoonIcon, SunIcon } from "lucide-svelte";
import { browser } from "$app/environment";
import JSCookie from "js-cookie";
files,
gradientColor,
isMobile,
effects,
showGradient,
theme,
} from "$lib/store/index.svelte";
import {
InfoIcon,
RefreshCw,
SettingsIcon,
UploadIcon,
} from "lucide-svelte";
import { onMount } from "svelte";
let { children, data } = $props();
import { quintOut } from "svelte/easing";
import "../app.scss";
import { DISCORD_URL, GITHUB_URL_VERT, VERT_NAME } from "$lib/consts";
import { type Toast as ToastType, toasts } from "$lib/store/ToastProvider";
import Toast from "$lib/components/visual/Toast.svelte";
import { Settings } from "$lib/sections/settings/index.svelte";
let { children } = $props();
let shouldGoBack = writable(false);
let navbar = $state<HTMLDivElement>();
let hover = $state(false);
let dropping = $state(false);
let goingLeft = $state(false);
let toastList = $state<ToastType[]>([]);
const links = $derived<
toasts.subscribe((value) => {
toastList = value as ToastType[];
});
const items = $derived<
{
name: string;
url: string;
activeMatch: (pathname: string) => boolean;
icon: any;
badge?: number;
}[]
>([
{
name: "Upload",
url: "/",
activeMatch: (pathname) => pathname === "/",
icon: UploadIcon,
},
{
name:
files.files.length > 0
? `Convert (${files.files.length})`
: `Convert`,
name: "Convert",
url: "/convert",
activeMatch: (pathname) => pathname === "/convert",
icon: RefreshCw,
badge: files.files.length,
},
{
name: "Settings",
url: "/settings",
activeMatch: (pathname) => pathname.startsWith("/settings"),
icon: SettingsIcon,
},
{
name: "About",
url: "/about",
activeMatch: (pathname) => pathname.startsWith("/about"),
icon: InfoIcon,
},
]);
const maybeNavToHome = (e: DragEvent) => {
if (e.dataTransfer?.types.includes("Files")) {
e.preventDefault();
goto("/");
}
const dropFiles = (e: DragEvent) => {
e.preventDefault();
dropping = false;
const oldLength = files.files.length;
files.add(e.dataTransfer?.files);
if (oldLength !== files.files.length) goto("/convert");
};
$effect(() => {
if (!browser) return;
if (theme.dark) {
document.documentElement.classList.add("dark");
document.documentElement.classList.remove("light");
JSCookie.set("theme", "dark", {
path: "/",
sameSite: "lax",
expires: 2147483647,
});
} else {
document.documentElement.classList.add("light");
document.documentElement.classList.remove("dark");
JSCookie.set("theme", "light", {
path: "/",
sameSite: "lax",
expires: 2147483647,
});
}
});
const handleDrag = (e: DragEvent, drag: boolean) => {
e.preventDefault();
dropping = drag;
};
onMount(() => {
const mouseEnter = () => {
hover = true;
};
isMobile.set(window.innerWidth <= 768);
window.addEventListener("resize", () => {
isMobile.set(window.innerWidth <= 768);
});
const mouseLeave = () => {
hover = false;
};
effects.set(localStorage.getItem("effects") !== "false"); // defaults to true if not set
theme.set(
(localStorage.getItem("theme") as "light" | "dark") || "light",
);
navbar?.addEventListener("mouseenter", mouseEnter);
navbar?.addEventListener("mouseleave", mouseLeave);
Settings.instance.load();
});
beforeNavigate((e) => {
const oldIndex = items.findIndex((i) =>
i.activeMatch(e.from?.url.pathname || ""),
);
const newIndex = items.findIndex((i) =>
i.activeMatch(e.to?.url.pathname || ""),
);
if (newIndex < oldIndex) {
goingLeft = true;
} else {
goingLeft = false;
}
});
</script>
<svelte:head>
<title>VERT.sh</title>
<title>{VERT_NAME}</title>
<meta name="theme-color" content="#F2ABEE" />
<meta
name="title"
content="{VERT_NAME} — Free, fast, and awesome file convert"
/>
<meta
name="description"
content="With VERT you can convert image and audio files to and from PNG, JPG, WEBP, MP3, WAV, FLAC, 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_NAME} — Free, fast, and awesome file convert"
/>
<meta
property="og:description"
content="With VERT you can convert image and audio files to and from PNG, JPG, WEBP, MP3, WAV, FLAC, and more. No ads, no tracking, open source, and all processing is done on your device."
/>
<meta property="og:image" content={featuredImage} />
<meta property="twitter:card" content="summary_large_image" />
<meta
property="twitter:title"
content="{VERT_NAME} — Free, fast, and awesome file convert"
/>
<meta
property="twitter:description"
content="With VERT you can convert image and audio files to and from PNG, JPG, WEBP, MP3, WAV, FLAC, and more. No ads, no tracking, open source, and all processing is done on your device."
/>
<meta property="twitter:image" content={featuredImage} />
{#if PUB_PLAUSIBLE_URL}<script
defer
data-domain={PUB_HOSTNAME || "vert.sh"}
src="{PUB_PLAUSIBLE_URL}/js/script.pageview-props.tagged-events.js"
></script>{/if}
<script src="/coi-serviceworker.min.js"></script>
</svelte:head>
<div
role="main"
class="w-full h-full max-w-screen-lg mx-auto p-4"
ondragenter={maybeNavToHome}
class="flex flex-col min-h-screen h-full"
ondrop={dropFiles}
ondragenter={(e) => handleDrag(e, true)}
ondragover={(e) => handleDrag(e, true)}
ondragleave={(e) => handleDrag(e, false)}
role="region"
>
<div class="flex justify-center mb-5 lg:hidden">
<a
href="/"
class="px-4 relative h-14 mr-3 justify-center items-center bg-accent-background fill-accent-foreground rounded-xl md:hidden flex"
>
<div class="h-6 relative w-24 items-center flex justify-center">
<Logo />
{#if PUB_ENV === "nightly"}
<div
class="absolute -top-6 -left-10 px-2 py-1 w-fit bg-foreground-highlight text-accent-background rotate-[-10deg] rounded-xl"
style="font-family: Comic Sans MS, sans-serif;"
>
NIGHTLY
</div>
{/if}
</div>
</a>
</div>
{#if dropping}
<div
class="fixed w-screen h-screen opacity-40 dynadark:opacity-20 z-[100] pointer-events-none blur-2xl {$effects
? 'dragoverlay'
: 'bg-accent-blue'}"
class:_dragover={dropping && $effects}
transition:fade={{
duration,
easing: quintOut,
}}
></div>
{/if}
<div
class="w-full max-w-screen-md p-1 border-solid border-2 rounded-2xl border-foreground-muted-alt flex mb-10 mx-auto lg:mt-5"
bind:this={navbar}
>
<div class="md:p-1">
<!-- FIXME: if user resizes between desktop/mobile, highlight of page disappears (only shows on original size) -->
<div>
<!-- Mobile logo -->
<div class="flex md:hidden justify-center items-center pb-8 pt-4">
<a
class="flex items-center justify-center bg-panel p-2 rounded-[20px] shadow-panel"
href="/"
class="px-3 relative w-full h-full mr-3 justify-center items-center bg-accent-background fill-accent-foreground rounded-xl md:flex hidden"
>
<div class="h-6 w-24 items-center flex justify-center relative">
<Logo />
{#if PUB_ENV === "nightly"}
<div
class="absolute -top-6 -left-10 px-2 py-1 w-fit bg-foreground-highlight text-accent-background rotate-[-10deg] rounded-xl"
style="font-family: Comic Sans MS, sans-serif;"
>
NIGHTLY
</div>
{/if}
<div
class="h-14 bg-accent rounded-[14px] flex items-center justify-center"
>
<div class="w-28 h-5">
<Logo />
</div>
</div>
</a>
</div>
<FancyMenu {links} {shouldGoBack} />
<div class="h-16 px-4 flex items-center">
<button onclick={theme.toggle} class="grid-cols-1 grid-rows-1 grid">
<!-- {#if theme.dark}
<div
class="w-full h-full flex items-center justify-center row-start-1 col-start-1"
>
<MoonIcon />
</div>
{:else}
<div
class="w-full h-full flex items-center justify-center row-start-1 col-start-1"
>
<SunIcon />
</div>
{/if} -->
{#if browser}
{#if theme.dark}
<div
in:blur={{
blurMultiplier: 1,
duration,
easing: quintOut,
scale: {
start: 0.5,
end: 1,
},
}}
out:blur={{
blurMultiplier: 1,
duration,
easing: quintOut,
scale: {
start: 1,
end: 1.5,
},
}}
class="w-full h-full flex items-center justify-center row-start-1 col-start-1"
>
<MoonIcon class="w-8" />
</div>
{:else}
<div
in:blur={{
blurMultiplier: 1,
duration,
easing: quintOut,
scale: {
start: 0.5,
end: 1,
},
}}
out:blur={{
blurMultiplier: 1,
duration,
easing: quintOut,
scale: {
start: 1,
end: 1.5,
},
}}
class="w-full h-full flex items-center justify-center row-start-1 col-start-1"
>
<SunIcon class="w-8" />
</div>
{/if}
{:else}
<div
class="w-full h-full flex items-center justify-center row-start-1 col-start-1 dynadark:hidden"
>
<SunIcon class="w-8" />
</div>
<div
class="w-full h-full hidden items-center justify-center row-start-1 col-start-1 dynadark:flex"
>
<MoonIcon class="w-8" />
</div>
{/if}
</button>
<!-- Desktop navbar -->
<div class="hidden md:flex p-8 w-screen justify-center">
<Navbar {items} />
</div>
</div>
<div class="w-full max-w-screen-lg grid grid-cols-1 grid-rows-1 relative">
{#key data.pathname}
<div class="w-full">
<div class="grid grid-rows-1 grid-cols-1 h-full flex-grow">
{#key page.url.pathname}
<div
class="row-start-1 col-start-1"
in:fly={{
x: goingLeft ? -window.innerWidth : window.innerWidth,
duration,
easing: quintOut,
delay: 25,
}}
out:fly={{
x: goingLeft ? window.innerWidth : -window.innerWidth,
duration,
easing: quintOut,
}}
>
<div
class="absolute top-0 left-0 w-full"
style={hover ? "will-change: opacity, blur, transform" : ""}
in:blur={{
class="flex flex-col h-full pb-32"
in:fade={{
duration,
easing: quintOut,
blurMultiplier: 12,
x: {
start: !$shouldGoBack ? 250 : -250,
end: 0,
},
y: {
start: 100,
end: 0,
},
scale: {
start: 0.75,
end: 1,
},
origin: "top center",
delay: $isMobile ? 0 : 100,
}}
out:blur={{
out:fade={{
duration,
easing: quintOut,
blurMultiplier: 12,
x: {
start: 0,
end: !$shouldGoBack ? -250 : 250,
},
y: {
start: 0,
end: 100,
},
scale: {
start: 1,
end: 0.75,
},
origin: "top center",
delay: $isMobile ? 0 : 200,
}}
>
<div class="pb-20">
{@render children()}
</div>
{@render children()}
</div>
</div>
{/key}
</div>
<div class="fixed bottom-28 md:bottom-0 right-0 p-4 space-y-4 z-50">
{#each toastList as { id, type, message, durations }}
<Toast {id} {type} {message} {durations} />
{/each}
</div>
<div>
<div
class="hidden md:block w-full h-14 border-t border-separator fixed bottom-0 mt-12"
>
<Footer
class="w-full h-full"
items={{
//"Privacy policy": "#",
"Source code": GITHUB_URL_VERT,
"Discord server": DISCORD_URL,
}}
/>
</div>
<!-- Mobile navbar -->
<div
class="fixed md:hidden bottom-0 left-0 w-screen p-8 justify-center z-50"
>
<div class="flex flex-col justify-center items-center">
<Navbar {items} />
</div>
</div>
</div>
</div>
<!-- Gradients placed here to prevent it overlapping in transitions -->
{#if page.url.pathname === "/"}
<div
class="fixed -z-30 top-0 left-0 w-screen h-screen flex items-center justify-center overflow-hidden"
>
<VertVBig
class="fill-[--fg] opacity-10 dynadark:opacity-5 scale-[200%] md:scale-[80%]"
/>
</div>
<div
id="gradient-bg"
class="fixed top-0 left-0 w-screen h-screen -z-40 pointer-events-none"
style="background: var(--bg-gradient);"
></div>
{:else if page.url.pathname === "/convert" && $showGradient}
<div
id="gradient-bg"
class="fixed top-0 left-0 w-screen h-screen -z-40 pointer-events-none"
style="background: var(--bg-gradient-{$gradientColor || 'pink'});"
></div>
{:else if page.url.pathname === "/convert" && files.files.length === 1 && files.files[0].blobUrl}
<div
class="fixed w-screen h-screen opacity-75 overflow-hidden top-0 left-0 -z-50 pointer-events-none grid grid-cols-1 grid-rows-1 scale-105"
>
<div
class="w-full relative"
transition:fade={{
duration,
easing: quintOut,
}}
>
<img
class="object-cover w-full h-full blur-md"
src={files.files[0].blobUrl}
alt={files.files[0].name}
/>
<div
class="absolute top-0 left-0 w-full h-full"
style="background: var(--bg-gradient-image);"
></div>
<!-- <div class="absolute bottom-0 left-0 w-full h-full">
<ProgressiveBlur
direction="bottom"
endIntensity={256}
iterations={8}
fadeTo="var(--bg)"
/>
</div> -->
</div>
</div>
{:else if page.url.pathname === "/settings"}
<div
id="gradient-bg"
class="fixed top-0 left-0 w-screen h-screen -z-40 pointer-events-none"
style="background: var(--bg-gradient-blue);"
></div>
{:else if page.url.pathname === "/about"}
<div
id="gradient-bg"
class="fixed top-0 left-0 w-screen h-screen -z-40 pointer-events-none"
style="background: var(--bg-gradient-pink);"
></div>
{/if}
<style>
.dragoverlay {
animation: dragoverlay-animation 3s infinite linear;
}
@keyframes dragoverlay-animation {
0% {
@apply bg-accent-pink;
}
25% {
@apply bg-accent-blue;
}
50% {
@apply bg-accent-purple;
}
75% {
@apply bg-accent-red;
}
100% {
@apply bg-accent-pink;
}
}
</style>

View File

@ -1,6 +1,4 @@
import { browser } from "$app/environment";
import { theme } from "$lib/store/index.svelte";
import JSCookie from "js-cookie";
export const load = ({ data }) => {
if (!browser) return;
@ -11,20 +9,7 @@ export const load = ({ data }) => {
status: 200,
});
});
const themeStr = JSCookie.get("theme");
if (typeof themeStr === "undefined") {
theme.dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
JSCookie.set("theme", theme.dark ? "dark" : "light", {
sameSite: "strict",
path: "/",
expires: 2147483647,
});
} else {
theme.dark = themeStr === "dark";
}
theme.dark = JSCookie.get("theme") === "dark";
window.plausible("Theme set", {
props: { theme: theme.dark ? "dark" : "light" },
});
return data;
};
export const prerender = true;

View File

@ -1,251 +1,87 @@
<script lang="ts">
import { goto } from "$app/navigation";
// 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
// that definitely happened
// -- JovannMC
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");
};
</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
class="w-screen px-2 md:px-8 h-full flex items-start justify-center overflow-hidden max-h-screen"
>
<div class="content w-screen flex items-center justify-center flex-grow">
<div class="max-w-5xl w-full">
<div
class="flex items-center h-auto gap-12 md:gap-24 md:flex-row flex-col"
>
<div class="flex-grow w-full text-center md:text-left">
<h1
class="text-4xl px-12 md:p-0 md:text-6xl flex-wrap tracking-tight leading-tight md:leading-[72px] mb-4 md:mb-6"
>
The file converter you'll love.
</h1>
<p
class="font-normal px-5 md:p-0 text-lg md:text-xl text-black text-muted dynadark:text-muted"
>
All processing is done on your device. No file size
limit, no ads, and completely open source.
</p>
</div>
{/each}
<div class="flex-grow w-11/12 md:w-full h-72">
<Uploader class="w-full h-full" />
</div>
</div>
</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);
/* Centers content on most screen sizes, excluding mobile */
@media screen and (min-width: 768px) {
.content {
padding-top: 10vh;
padding-bottom: 10vh;
}
}
@keyframes picker-fly {
from {
opacity: 0;
transform: translateY(48px);
filter: blur(18px);
}
to {
opacity: 1;
transform: translateY(0);
filter: blur(0);
@media screen and (min-width: 768px) and (min-height: 576px) {
.content {
padding-top: 15vh;
padding-bottom: 15vh;
}
}
@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);
@media screen and (min-width: 768px) and (min-height: 720px) {
.content {
padding-top: 20vh;
padding-bottom: 20vh;
}
}
.header-fly-in {
animation: header-fly-in var(--transition) 750ms forwards;
opacity: 0;
@media screen and (min-width: 768px) and (min-height: 1080px) {
.content {
padding-top: 25vh;
padding-bottom: 25vh;
}
}
.fly-in {
animation: fly-in var(--transition) 750ms var(--delay) forwards;
opacity: 0;
@media screen and (min-width: 768px) and (min-height: 1440px) {
.content {
padding-top: 30vh;
padding-bottom: 30vh;
}
}
.picker-fly {
animation: picker-fly var(--transition) 750ms forwards;
opacity: 0;
@media screen and (min-width: 768px) and (min-height: 2160px) {
.content {
padding-top: 35vh;
padding-bottom: 35vh;
}
}
</style>

View File

@ -1,159 +1,132 @@
<script lang="ts">
import { error, log } from "$lib/logger";
import * as About from "$lib/sections/about";
import { InfoIcon } from "lucide-svelte";
import { onMount } from "svelte";
import avatarNullptr from "$lib/assets/avatars/nullptr.jpg";
import avatarRealmy from "$lib/assets/avatars/realmy.jpg";
import avatarJovannMC from "$lib/assets/avatars/jovannmc.jpg";
import { GITHUB_API_URL } from "$lib/consts";
import { addToast } from "$lib/store/ToastProvider";
const multiplier = 50;
/* interface Donator {
name: string;
amount?: string | number;
avatar: string;
} */
const credits = [
interface Contributor {
name: string;
github: string;
avatar: string;
role?: string;
}
// const donors: Donator[] = [];
const mainContribs: Contributor[] = [
{
name: "nullptr",
github: "https://github.com/not-nullptr",
role: "Lead developer; conversion backend, UI implementation",
avatar: avatarNullptr,
url: "https://nullp.tr",
description: "conversion backend, UI, animations, promotion",
},
{
name: "Realmy",
github: "https://github.com/RealmyTheMan",
role: "Lead designer; logo and branding, user interface design",
avatar: avatarRealmy,
url: "https://realmy.net",
description: "idea, UI, branding, operational costs",
},
{
name: "JovannMC",
github: "https://github.com/JovannMC",
role: "Developer; UI implementation",
avatar: avatarJovannMC,
},
];
let ghContribs: Contributor[] = [];
onMount(async () => {
// Check if the data is already in sessionStorage
const cachedContribs = sessionStorage.getItem("ghContribs");
if (cachedContribs) {
ghContribs = JSON.parse(cachedContribs);
log(["about"], "loaded GitHub contributors from cache");
return;
}
// Fetch GitHub contributors
try {
const response = await fetch(`${GITHUB_API_URL}/contributors`);
if (!response.ok) {
addToast("error", "Error fetching GitHub contributors");
throw new Error(`HTTP error, status: ${response.status}`);
}
const allContribs = await response.json();
// Filter out main contributors
const mainContribNames = mainContribs.map((contrib) =>
contrib.github.split("/").pop(),
);
const filteredContribs = allContribs.filter(
(contrib: { login: string }) =>
!mainContribNames.includes(contrib.login),
);
// Fetch and cache avatar images as Base64
const fetchAvatar = async (url: string) => {
const res = await fetch(url);
const blob = await res.blob();
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
};
ghContribs = await Promise.all(
filteredContribs.map(
async (contrib: {
login: string;
avatar_url: string;
html_url: string;
}) => ({
name: contrib.login,
avatar: await fetchAvatar(contrib.avatar_url),
github: contrib.html_url,
}),
),
);
// Cache the data in sessionStorage
sessionStorage.setItem("ghContribs", JSON.stringify(ghContribs));
} catch (e) {
error(["general"], `Error fetching GitHub contributors: ${e}`);
}
});
</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
<div class="flex flex-col h-full items-center">
<h1 class="hidden md:block text-[40px] tracking-tight leading-[72px] mb-6">
<InfoIcon size="40" class="inline-block -mt-2 mr-2" />
About
</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"
<div
class="w-full max-w-[1280px] flex flex-col md:flex-row gap-4 p-4 md:px-4 md:py-0"
>
🖼️ 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>
<!-- Why VERT? & Credits -->
<div class="flex flex-col gap-4 flex-1">
<About.Why />
</div>
<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}
<!-- Resources & Donate to VERT -->
<div class="flex flex-col gap-4 flex-1">
<About.Resources />
<About.Credits {mainContribs} {ghContribs} />
<!-- <About.Donate /> -->
</div>
</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 +1,199 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { blur, duration, flip } from "$lib/animation";
import ConversionPanel from "$lib/components/functional/ConversionPanel.svelte";
import Dropdown from "$lib/components/functional/Dropdown.svelte";
import ProgressiveBlur from "$lib/components/visual/effects/ProgressiveBlur.svelte";
import Uploader from "$lib/components/functional/Uploader.svelte";
import Panel from "$lib/components/visual/Panel.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";
files,
gradientColor,
showGradient,
} from "$lib/store/index.svelte";
import { VertFile } from "$lib/types";
import {
AudioLines,
DownloadIcon,
FileMusicIcon,
FileQuestionIcon,
FileVideo2,
FilmIcon,
ImageIcon,
ImageOffIcon,
RotateCwIcon,
XIcon,
} from "lucide-svelte";
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);
$effect(() => {
if (files.files.length === 1 && files.files[0].blobUrl) {
showGradient.set(false);
} else {
showGradient.set(true);
}
// Set gradient color depending on the file types
// TODO: if more file types added, add a "fileType" property to the file object
const allAudio = files.files.every(
(file) => file.converter?.name === "ffmpeg",
);
const allImages = files.files.every(
(file) =>
file.converter?.name !== "ffmpeg" &&
file.converter?.name !== "vertd",
);
const allVideos = files.files.every(
(file) => file.converter?.name === "vertd",
);
if (
files.files.length === 0 ||
(!allAudio && !allImages && !allVideos)
) {
gradientColor.set("");
} else {
gradientColor.set(allAudio ? "purple" : allVideos ? "red" : "blue");
}
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"
{#snippet fileItem(file: VertFile, index: number)}
{@const isAudio = file.converter?.name === "ffmpeg"}
{@const isVideo = file.converter?.name === "vertd"}
<Panel class="p-5 flex flex-col min-w-0 gap-4 relative">
<div class="flex-shrink-0 h-8 w-full flex items-center gap-2">
{#if !file.converter}
<FileQuestionIcon size="24" class="flex-shrink-0" />
{:else if isAudio}
<AudioLines size="24" class="flex-shrink-0" />
{:else if isVideo}
<FilmIcon size="24" class="flex-shrink-0" />
{:else}
<ImageIcon size="24" class="flex-shrink-0" />
{/if}
<div class="flex-grow overflow-hidden">
{#if file.processing}
<ProgressBar
min={0}
max={100}
progress={file.converter?.reportsProgress
? file.progress
: null}
/>
{:else}
<h2
class="text-xl font-body overflow-hidden text-ellipsis whitespace-nowrap"
>
{file.name}
</h2>
{/if}
</div>
<button
class="flex-shrink-0 w-8 rounded-full hover:bg-panel-alt h-full flex items-center justify-center"
onclick={() =>
(files.files = files.files.filter((_, i) => i !== index))}
>
<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>
<XIcon size="24" class="text-muted" />
</button>
</div>
{/if}
{#if !file.converter}
<div
class="h-full flex flex-col text-center justify-center text-failure"
>
<p class="font-body font-bold">We can't convert this file.</p>
<p class="font-normal">
Only image, video, and audio files are supported
</p>
</div>
{:else}
<div class="flex flex-row justify-between">
<div
class="flex gap-4 w-full h-[152px] overflow-hidden relative"
>
<div class="w-1/2 h-full overflow-hidden rounded-xl">
{#if file.blobUrl}
<img
class="object-cover w-full h-full"
src={file.blobUrl}
alt={file.name}
/>
{:else}
<div
class="w-full h-full flex items-center justify-center text-black"
style="background: var({isAudio
? '--bg-gradient-purple-alt'
: isVideo
? '--bg-gradient-red-alt'
: '--bg-gradient-blue-alt'})"
>
{#if isAudio}
<FileMusicIcon size="56" />
{:else if isVideo}
<FileVideo2 size="56" />
{:else}
<ImageOffIcon size="56" />
{/if}
</div>
{/if}
</div>
</div>
<div
class="absolute top-16 right-0 mr-4 pl-2 h-[calc(100%-83px)] w-[calc(50%-38px)] pr-4 pb-1 flex items-center justify-center aspect-square"
>
<div
class="w-[122px] h-fit flex flex-col gap-2 items-center justify-center"
>
<Dropdown
options={file.converter?.supportedFormats || []}
bind:selected={file.to}
onselect={() => file.result && (file.result = null)}
/>
<div class="w-full flex items-center justify-between">
<button
class="btn p-0 w-14 h-14 text-black {isAudio
? 'bg-accent-purple'
: isVideo
? 'bg-accent-red'
: 'bg-accent-blue'}"
disabled={!files.ready}
onclick={file.convert}
>
<RotateCwIcon size="24" />
</button>
<button
class="btn p-0 w-14 h-14"
onclick={file.download}
disabled={!file.result}
>
<DownloadIcon size="24" />
</button>
</div>
</div>
</div>
</div>
{/if}
</Panel>
{/snippet}
<div class="flex flex-col justify-center items-center gap-8 -mt-4 px-4 md:p-0">
<div class="max-w-[778px] w-full">
<ConversionPanel />
</div>
<div
class="w-full max-w-[778px] grid grid-cols-1 md:grid-cols-2 auto-rows-[240px] gap-4 md:p-0"
>
{#each files.files as file, i (file.id)}
{#if files.files.length >= 2 && i === 1}
<Uploader
class="w-full h-full col-start-1 row-start-1 md:col-start-2"
/>
{/if}
{@render fileItem(file, i)}
{#if files.files.length < 2}
<Uploader class="w-full h-full" />
{/if}
{/each}
{#if files.files.length === 0}
<Uploader class="w-full h-full col-span-2" />
{/if}
</div>
</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>

View File

@ -0,0 +1,66 @@
<script lang="ts">
import { browser } from "$app/environment";
import { log } from "$lib/logger";
import * as Settings from "$lib/sections/settings/index.svelte";
import { addToast } from "$lib/store/ToastProvider";
import { SettingsIcon } from "lucide-svelte";
import { onMount } from "svelte";
let settings = $state(Settings.Settings.instance.settings);
let isInitial = $state(true);
$effect(() => {
if (!browser) return;
if (isInitial) {
isInitial = false;
return;
}
settings;
const savedSettings = localStorage.getItem("settings");
if (savedSettings) {
const parsedSettings = JSON.parse(savedSettings);
if (parsedSettings === settings) return;
}
log(["settings"], "saving settings");
try {
Settings.Settings.instance.settings = settings;
Settings.Settings.instance.save();
} catch (error) {
log(["settings", "error"], `failed to save settings: ${error}`);
addToast("error", "Failed to save settings!");
}
});
onMount(() => {
const savedSettings = localStorage.getItem("settings");
if (savedSettings) {
const parsedSettings = JSON.parse(savedSettings);
Settings.Settings.instance.settings = {
...Settings.Settings.instance.settings,
...parsedSettings,
};
}
});
</script>
<div class="flex flex-col h-full items-center">
<h1 class="hidden md:block text-[40px] tracking-tight leading-[72px] mb-6">
<SettingsIcon size="40" class="inline-block -mt-2 mr-2" />
Settings
</h1>
<div
class="w-full max-w-[1280px] flex flex-col md:flex-row gap-4 p-4 md:px-4 md:py-0"
>
<div class="flex flex-col gap-4 flex-1">
<Settings.Conversion {settings} />
<Settings.Vertd {settings} />
</div>
<div class="flex flex-col gap-4 flex-1">
<Settings.Appearance />
</div>
</div>
</div>

2
static/coi-serviceworker.min.js vendored Normal file
View File

@ -0,0 +1,2 @@
/*! coi-serviceworker v0.1.7 - Guido Zuidhof and contributors, licensed under MIT */
let coepCredentialless=!1;"undefined"==typeof window?(self.addEventListener("install",(()=>self.skipWaiting())),self.addEventListener("activate",(e=>e.waitUntil(self.clients.claim()))),self.addEventListener("message",(e=>{e.data&&("deregister"===e.data.type?self.registration.unregister().then((()=>self.clients.matchAll())).then((e=>{e.forEach((e=>e.navigate(e.url)))})):"coepCredentialless"===e.data.type&&(coepCredentialless=e.data.value))})),self.addEventListener("fetch",(function(e){const o=e.request;if("only-if-cached"===o.cache&&"same-origin"!==o.mode)return;const s=coepCredentialless&&"no-cors"===o.mode?new Request(o,{credentials:"omit"}):o;e.respondWith(fetch(s).then((e=>{if(0===e.status)return e;const o=new Headers(e.headers);return o.set("Cross-Origin-Embedder-Policy",coepCredentialless?"credentialless":"require-corp"),coepCredentialless||o.set("Cross-Origin-Resource-Policy","cross-origin"),o.set("Cross-Origin-Opener-Policy","same-origin"),new Response(e.body,{status:e.status,statusText:e.statusText,headers:o})})).catch((e=>console.error(e))))}))):(()=>{const e=window.sessionStorage.getItem("coiReloadedBySelf");window.sessionStorage.removeItem("coiReloadedBySelf");const o="coepdegrade"==e,s={shouldRegister:()=>!e,shouldDeregister:()=>!1,coepCredentialless:()=>!0,coepDegrade:()=>!0,doReload:()=>window.location.reload(),quiet:!1,...window.coi},r=navigator,t=r.serviceWorker&&r.serviceWorker.controller;t&&!window.crossOriginIsolated&&window.sessionStorage.setItem("coiCoepHasFailed","true");const i=window.sessionStorage.getItem("coiCoepHasFailed");if(t){const e=s.coepDegrade()&&!(o||window.crossOriginIsolated);r.serviceWorker.controller.postMessage({type:"coepCredentialless",value:!(e||i&&s.coepDegrade())&&s.coepCredentialless()}),e&&(!s.quiet&&console.log("Reloading page to degrade COEP."),window.sessionStorage.setItem("coiReloadedBySelf","coepdegrade"),s.doReload("coepdegrade")),s.shouldDeregister()&&r.serviceWorker.controller.postMessage({type:"deregister"})}!1===window.crossOriginIsolated&&s.shouldRegister()&&(window.isSecureContext?r.serviceWorker?r.serviceWorker.register(window.document.currentScript.src).then((e=>{!s.quiet&&console.log("COOP/COEP Service Worker registered",e.scope),e.addEventListener("updatefound",(()=>{!s.quiet&&console.log("Reloading page to make use of updated COOP/COEP Service Worker."),window.sessionStorage.setItem("coiReloadedBySelf","updatefound"),s.doReload()})),e.active&&!r.serviceWorker.controller&&(!s.quiet&&console.log("Reloading page to make use of COOP/COEP Service Worker."),window.sessionStorage.setItem("coiReloadedBySelf","notcontrolling"),s.doReload())}),(e=>{!s.quiet&&console.error("COOP/COEP Service Worker failed to register:",e)})):!s.quiet&&console.error("COOP/COEP Service Worker not registered, perhaps due to private mode."):!s.quiet&&console.log("COOP/COEP Service Worker not registered, a secure context is required."))})();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
static/favicon.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

View File

@ -1,4 +1,4 @@
import adapter from "svelte-adapter-bun";
import adapter from "@sveltejs/adapter-static";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */

View File

@ -5,15 +5,40 @@ export default {
content: ["./src/**/*.{html,js,svelte,ts}"],
theme: {
extend: {
colors: {
background: "var(--bg)",
backgroundColor: {
panel: "var(--bg-panel)",
"panel-highlight": "var(--bg-panel-highlight)",
separator: "var(--bg-separator)",
button: "var(--bg-button)",
"panel-alt": "var(--bg-button)",
badge: "var(--bg-badge)",
},
borderColor: {
separator: "var(--bg-separator)",
button: "var(--bg-button)",
},
textColor: {
foreground: "var(--fg)",
"foreground-muted": "var(--fg-muted)",
"foreground-muted-alt": "var(--fg-muted-alt)",
"foreground-failure": "var(--fg-failure)",
"foreground-highlight": "var(--fg-highlight)",
"accent-background": "var(--accent-bg)",
"accent-foreground": "var(--accent-fg)",
muted: "var(--fg-muted)",
accent: "var(--fg-accent)",
failure: "var(--fg-failure)",
"on-accent": "var(--fg-on-accent)",
"on-badge": "var(--fg-on-badge)",
},
colors: {
accent: "var(--accent)",
"accent-alt": "var(--accent-alt)",
"accent-pink": "var(--accent-pink)",
"accent-pink-alt": "var(--accent-pink-alt)",
"accent-red": "var(--accent-red)",
"accent-red-alt": "var(--accent-red-alt)",
"accent-purple-alt": "var(--accent-purple-alt)",
"accent-purple": "var(--accent-purple)",
"accent-blue": "var(--accent-blue)",
"accent-blue-alt": "var(--accent-blue-alt)",
},
boxShadow: {
panel: "var(--shadow-panel)",
},
fontFamily: {
display: "var(--font-display)",
@ -22,6 +47,9 @@ export default {
blur: {
xs: "2px",
},
borderRadius: {
"2.5xl": "1.25rem",
},
},
},

View File

@ -1,5 +1,6 @@
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
import svg from "@poppanator/sveltekit-svg";
export default defineConfig({
plugins: [
@ -17,6 +18,19 @@ export default defineConfig({
});
},
},
svg({
includePaths: ["./src/lib/assets"],
svgoOptions: {
multipass: true,
plugins: [
{
name: "preset-default",
params: { overrides: { removeViewBox: false } },
},
{ name: "removeAttrs", params: { attrs: "(fill|stroke)" } },
],
},
}),
],
optimizeDeps: {
exclude: [