Merge branch 'jovannmc/redesign' into nightly

This commit is contained in:
JovannMC 2025-02-08 20:11:51 +03:00
commit 40129d4a03
No known key found for this signature in database
56 changed files with 2326 additions and 1563 deletions

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": "^9.18.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-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",
"@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.31",
"client-zip": "^2.4.5",
"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",
"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,165 @@
}
@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-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%);
// 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-accented: hsl(0, 0%, 92%);
--bg-separator: hsl(0, 0%, 88%);
--bg-button: var(--bg-panel-accented);
--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.6);
--fg-on-accent: hsl(0, 0%, 0%);
--fg-on-badge: hsl(0, 0%, 0%);
// backgrounds
--bg: hsl(220, 5%, 12%);
--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%, 18%);
--bg-panel-accented: color-mix(in srgb, var(--accent) 12%, transparent);
--bg-separator: hsl(220, 4%, 28%);
--bg-button: hsl(220, 6%, 28%);
--bg-badge: var(--accent-pink);
--shadow-panel: 0 4px 6px 0 hsla(0, 0%, 0%, 0.15);
color-scheme: dark;
}
@ -76,8 +212,26 @@
}
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;
}
.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 +240,23 @@ 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;
}
}

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, motion } 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 motionEnabled = true;
let isMobileDevice = false;
motion.subscribe(value => {
motionEnabled = 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 (!motionEnabled) 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 (!motionEnabled || 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,22 @@
<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;
};
let { options, selected = $bindable(), onselect }: Props = $props();
let {
options,
selected = $bindable(options[0]),
onselect,
disabled,
}: Props = $props();
let open = $state(false);
let hover = $state(false);
@ -31,10 +36,6 @@
toggle();
};
$effect(() => {
selected = selected || options[0];
});
onMount(() => {
const click = (e: MouseEvent) => {
if (dropdown && !dropdown.contains(e.target as Node)) {
@ -47,46 +48,32 @@
});
</script>
<div class="relative w-full min-w-fit" bind:this={dropdown}>
<div
class="relative w-full min-w-fit 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 justify-center overflow-hidden relative cursor-pointer px-3 py-3.5 bg-button {disabled
? 'opacity-50'
: ''} flex items-center 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 text-center font-body font-medium"
>
{selected}
</p>
@ -108,26 +95,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 { motion, 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-accented":
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-accented 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; {$motion
? `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,84 @@
<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 dropping = $state(false);
let uploaderButton = $state<HTMLButtonElement>();
let fileInput = $state<HTMLInputElement>();
let dragOver = $state(false);
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");
};
let { files = $bindable(), onupload, isMobile, acceptedFormats }: Props = $props();
function upload() {
if (!fileInput) return;
fileInput.click();
}
const uploadFiles = () => {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.accept = converters
.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}
ondragenter={() => (dropping = true)}
ondragleave={() => (dropping = false)}
ondrop={dropFiles}
onclick={uploadFiles}
bind:this={uploaderButton}
class={clsx(`hover:scale-105 active:scale-100 duration-200 ${classList}`, {
"scale-105": dropping,
})}
>
<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,34 @@
<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}
style="background: linear-gradient(to bottom, transparent, var(--bg) 100%)"
>
<div
class="w-full h-full flex items-center justify-center text-muted gap-3"
>
<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>
</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

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

@ -0,0 +1,4 @@
export const GITHUB_URL = 'https://github.com/VERT-sh/VERT';
export const GITHUB_API_URL = 'https://api.github.com/repos/VERT-sh/VERT';
export const DISCORD_URL = 'https://discord.gg/kqevGxYPak';

View File

@ -1,9 +1,9 @@
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 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 +44,12 @@ 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}`);
throw new Error(message.error);
}
};
}
@ -103,7 +108,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 } 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}
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}
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 } 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}
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,
motion,
setMotion,
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 enableMotionElement: HTMLButtonElement;
let disableMotionElement: HTMLButtonElement;
let motionUnsubscribe: () => void;
let themeUnsubscribe: () => void;
const updateMotionClasses = (value: boolean) => {
if (value) {
enableMotionElement.classList.add("selected");
disableMotionElement.classList.remove("selected");
} else {
disableMotionElement.classList.add("selected");
enableMotionElement.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(() => {
motionUnsubscribe = motion.subscribe(updateMotionClasses);
themeUnsubscribe = theme.subscribe(updateThemeClasses);
});
onDestroy(() => {
if (motionUnsubscribe) motionUnsubscribe();
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">Motion settings</p>
<p class="text-sm text-muted font-normal italic">
Would you like fancy animations, 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={enableMotionElement}
onclick={() => setMotion(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={disableMotionElement}
onclick={() => setMotion(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,83 @@
<script lang="ts">
import { browser } from "$app/environment";
import FancyTextInput from "$lib/components/functional/FancyInput.svelte";
import Panel from "$lib/components/visual/Panel.svelte";
import { log } from "$lib/logger";
import { RefreshCwIcon, SaveAllIcon } from "lucide-svelte";
import { onMount } from "svelte";
let filenameFormat = "VERT_%name%";
function save() {
log(["settings"], "Saving settings");
if (!browser) return;
localStorage.setItem("filenameFormat", filenameFormat);
log(["settings"], `Saving filename format: ${filenameFormat}`);
}
onMount(() => {
const format = localStorage.getItem("filenameFormat");
if (format) filenameFormat = format;
});
</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={filenameFormat}
extension=".ext"
type="text"
/>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<p class="text-base font-bold">Second option</p>
<p class="text-sm text-muted font-normal">
This is just a sample option. This should not show up on
the live website. JOVANN, DO NOT ADD THIS TO THE LIVE
WEBSITE. PLEASE. JOVANN!!!!
</p>
</div>
</div>
<div class="flex justify-end">
<button
onclick={save}
class="w-fit btn px-6 py-4 bg-accent text-black flex items-center justify-center"
>
<SaveAllIcon size="24" class="inline-block mr-2" />
Save
</button>
</div>
</div>
</div>
</Panel>

View File

@ -0,0 +1,2 @@
export { default as Appearance } from "./Appearance.svelte";
export { default as Conversion } from "./Conversion.svelte";

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 : Infinity,
exit: 500,
};
// if "disappearing" not set, default error/warning to infinite duration
if (disappearing === undefined) {
switch (type) {
case "error":
case "warning":
durations.stay = Infinity;
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,202 @@
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) => {
try {
if (
converters
.find((c) => c.name === "ffmpeg")
?.supportedFormats?.includes(file.from.toLowerCase())
) {
// 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 {
const img = new Image();
img.src = URL.createObjectURL(file.file);
await new Promise((resolve) => {
img.onload = resolve;
});
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return;
const maxSize = 180;
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);
const url = canvas.toDataURL();
file.blobUrl = url;
canvas.remove();
}
} catch (e) {
error(["files"], e);
}
};
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 filenameFormat =
localStorage.getItem("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 setMotion(motionEnabled: boolean) {
localStorage.setItem("motion", motionEnabled.toString());
window.plausible("Motion set", {
props: { motion: motionEnabled },
});
log(["motion"], `set to ${motionEnabled}`);
motion.set(motionEnabled);
}
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 motion = writable(true);
export const theme = writable<"light" | "dark">("light");

View File

@ -18,12 +18,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 +39,29 @@ export class VertFile {
if (!this.converter) throw new Error("No converter found");
this.result = null;
this.progress = 0;
this.processing = true;
const res = await this.converter.convert(this, this.to);
this.processing = false;
this.result = res;
return res;
}
public async download() {
if (!this.result) throw new Error("No result found");
const filenameFormat =
localStorage.getItem("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 +69,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,52 +1,80 @@
<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,
motion,
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 { writable } from "svelte/store";
import { DISCORD_URL, GITHUB_URL } from "$lib/consts";
import { type Toast as ToastType, toasts } from "$lib/store/ToastProvider";
import Toast from "$lib/components/visual/Toast.svelte";
let { children } = $props();
let shouldGoBack = writable(false);
let navbar = $state<HTMLDivElement>();
let hover = $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,
},
]);
@ -57,27 +85,6 @@
}
};
$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,
});
}
});
onMount(() => {
const mouseEnter = () => {
hover = true;
@ -89,197 +96,226 @@
navbar?.addEventListener("mouseenter", mouseEnter);
navbar?.addEventListener("mouseleave", mouseLeave);
isMobile.set(window.innerWidth <= 768);
window.addEventListener("resize", () => {
isMobile.set(window.innerWidth <= 768);
});
motion.set(localStorage.getItem("motion") !== "false"); // defaults to true if not set
theme.set(
(localStorage.getItem("theme") as "light" | "dark") || "light",
);
});
let goingLeft = $state(false);
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>
<meta name="theme-color" content="#F2ABEE" />
<meta
name="title"
content="VERT.sh — 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.sh — 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.sh — 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}
>
<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>
<div class="flex flex-col min-h-screen h-full">
<!-- FIXME: if user resizes between desktop/mobile, highlight of page disappears (only shows on original size) -->
<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">
<div>
<!-- Mobile logo -->
<div class="flex md:hidden justify-center items-center p-8">
<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 z-50">
<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-36 md:pb-0"
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-0 right-0 p-4 z-50 space-y-4">
{#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 relative mt-12"
>
<Footer
class="w-full h-full"
items={{
//"Privacy policy": "#",
"Source code": GITHUB_URL,
"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}

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,84 @@
<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
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-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,130 @@
<script lang="ts">
import { error } 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";
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);
console.log("Loaded GitHub contributors from cache");
return;
}
// Fetch GitHub contributors
try {
const response = await fetch(`${GITHUB_API_URL}/contributors`);
if (!response.ok) {
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,180 @@
<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,
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",
);
if (files.files.length === 0 || (!allAudio && !allImages)) {
gradientColor.set("");
} else {
gradientColor.set(allAudio ? "purple" : "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"}
<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}
<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-red-600"
>
<p class="font-body font-bold">We can't convert this file.</p>
<p class="font-normal">
Only image 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'
: '--bg-gradient-blue-alt'})"
>
{#if isAudio}
<FileMusicIcon 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'
: '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" />
{/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,31 @@
<script lang="ts">
import * as Settings from "$lib/sections/settings";
import { addToast } from "$lib/store/ToastProvider";
import { SettingsIcon } from "lucide-svelte";
function showToast() {
addToast("success", "This is a success message!");
}
</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"
>
<!-- Why VERT? & Credits -->
<div class="flex flex-col gap-4 flex-1">
<Settings.Conversion />
</div>
<div class="flex flex-col gap-4 flex-1">
<Settings.Appearance />
</div>
</div>
<button onclick={showToast}>Show Toast</button>
</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,38 @@ export default {
content: ["./src/**/*.{html,js,svelte,ts}"],
theme: {
extend: {
colors: {
background: "var(--bg)",
backgroundColor: {
panel: "var(--bg-panel)",
"panel-accented": "var(--bg-panel-accented)",
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)",
"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 +45,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: [