Merge branch 'nightly'
|
|
@ -1,3 +1,3 @@
|
|||
PUB_HOSTNAME=vert.sh # only gets used for plausible (for now)
|
||||
PUB_HOSTNAME=localhost:5173 # only gets used for plausible (for now)
|
||||
PUB_PLAUSIBLE_URL=https://plausible.example.com # can be empty
|
||||
PUB_ENV=production # "production" or "nightly"
|
||||
PUB_ENV=development # "production", "development", or "nightly"
|
||||
55
package.json
|
|
@ -12,40 +12,39 @@
|
|||
"lint": "prettier --check . && eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@types/eslint": "^9.6.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@poppanator/sveltekit-svg": "^5.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/jsmediatags": "^3.9.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.7.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.36.0",
|
||||
"globals": "^15.0.0",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"sass": "^1.80.7",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^5.0.3"
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
"globals": "^15.14.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.10",
|
||||
"sass": "^1.83.4",
|
||||
"svelte": "^5.19.0",
|
||||
"svelte-check": "^4.1.4",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "^5.4.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ffmpeg/ffmpeg": "^0.12.10",
|
||||
"@ffmpeg/util": "^0.12.1",
|
||||
"@fontsource/azeret-mono": "^5.1.0",
|
||||
"@fontsource/lexend": "^5.1.1",
|
||||
"@imagemagick/magick-wasm": "^0.0.31",
|
||||
"client-zip": "^2.4.5",
|
||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||
"@ffmpeg/util": "^0.12.2",
|
||||
"@fontsource/azeret-mono": "^5.1.1",
|
||||
"@fontsource/lexend": "^5.1.2",
|
||||
"@fontsource/radio-canada-big": "^5.1.1",
|
||||
"@imagemagick/magick-wasm": "^0.0.32",
|
||||
"client-zip": "^2.4.6",
|
||||
"clsx": "^2.1.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsmediatags": "^3.9.7",
|
||||
"lucide-svelte": "^0.456.0",
|
||||
"svelte-adapter-bun": "^0.5.2",
|
||||
"lucide-svelte": "^0.475.0",
|
||||
"wasm-vips": "^0.0.11"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import "@poppanator/sveltekit-svg/dist/svg";
|
||||
|
||||
type EventPayload = {
|
||||
readonly n: string;
|
||||
readonly u: Location["href"];
|
||||
|
|
|
|||
38
src/app.html
|
|
@ -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>
|
||||
|
|
|
|||
251
src/app.scss
|
|
@ -2,15 +2,14 @@
|
|||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import url(@fontsource/lexend/400.css);
|
||||
@import url(@fontsource/lexend/500.css);
|
||||
@import url(@fontsource/azeret-mono/600.css);
|
||||
@import url(@fontsource/radio-canada-big/600.css);
|
||||
@import url("$lib/assets/style/host-grotesk.css");
|
||||
|
||||
:root {
|
||||
--font-body: "Lexend", system-ui, -apple-system, BlinkMacSystemFont,
|
||||
--font-body: "Host Grotesk", system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans",
|
||||
"Helvetica Neue", sans-serif;
|
||||
--font-display: "Azeret Mono", var(--font-body);
|
||||
--font-display: "Radio Canada Big", var(--font-body);
|
||||
--transition: linear(
|
||||
0,
|
||||
0.006,
|
||||
|
|
@ -30,28 +29,171 @@
|
|||
}
|
||||
|
||||
@mixin light {
|
||||
--accent-bg: hsl(303, 73%, 81%);
|
||||
--accent-fg: hsl(0, 0, 10%);
|
||||
--bg: hsl(0, 0%, 100%);
|
||||
--bg-transparent: hsla(0, 0%, 100%, 0.6);
|
||||
--fg: hsl(0, 0%, 10%);
|
||||
--fg-muted: hsl(0, 0%, 50%);
|
||||
--fg-muted-alt: hsl(0, 0%, 75%);
|
||||
--fg-highlight: hsl(303, 61%, 47%);
|
||||
--fg-failure: hsl(0, 67%, 49%);
|
||||
color-scheme: light;
|
||||
// general
|
||||
--accent-pink: hsl(302, 100%, 76%);
|
||||
--accent-pink-alt: hsl(302, 100%, 50%);
|
||||
--accent-pink-muted: hsl(302, 98%, 42%);
|
||||
--accent-red: hsl(348, 100%, 80%);
|
||||
--accent-red-alt: hsl(348, 100%, 50%);
|
||||
--accent-purple: hsl(264, 100%, 81%);
|
||||
--accent-purple-alt: hsl(264, 100%, 50%);
|
||||
--accent-blue: hsl(220, 100%, 78%);
|
||||
--accent-blue-alt: hsl(220, 100%, 50%);
|
||||
--accent: var(--accent-pink);
|
||||
--accent-alt: var(--accent-pink-alt);
|
||||
|
||||
// foregrounds
|
||||
--fg: hsl(0, 0%, 0%);
|
||||
--fg-muted: hsla(0, 0%, 0%, 0.6);
|
||||
--fg-on-accent: hsl(0, 0%, 0%);
|
||||
--fg-on-badge: hsl(0, 0%, 0%);
|
||||
// readable version of the accent color
|
||||
--fg-accent: var(--accent-pink-muted);
|
||||
--fg-failure: var(--accent-red-alt);
|
||||
|
||||
// backgrounds
|
||||
--bg: hsl(0, 0%, 95%);
|
||||
--bg-gradient: linear-gradient(
|
||||
to bottom,
|
||||
var(--accent-pink),
|
||||
hsla(303, 100%, 50%, 0) 100%
|
||||
);
|
||||
--bg-gradient-pink: linear-gradient(
|
||||
to bottom,
|
||||
var(--accent-pink),
|
||||
hsla(303, 100%, 50%, 0) 25%
|
||||
);
|
||||
--bg-gradient-pink-alt: linear-gradient(
|
||||
to top,
|
||||
var(--accent-pink),
|
||||
hsl(303, 100%, 91%) 100%
|
||||
);
|
||||
--bg-gradient-red: linear-gradient(
|
||||
to bottom,
|
||||
var(--accent-red),
|
||||
hsla(348, 100%, 50%, 0) 25%
|
||||
);
|
||||
--bg-gradient-red-alt: linear-gradient(
|
||||
to top,
|
||||
var(--accent-red),
|
||||
hsl(348, 100%, 91%) 100%
|
||||
);
|
||||
--bg-gradient-purple: linear-gradient(
|
||||
to bottom,
|
||||
var(--accent-purple),
|
||||
hsla(264, 100%, 50%, 0) 25%
|
||||
);
|
||||
--bg-gradient-purple-alt: linear-gradient(
|
||||
to top,
|
||||
var(--accent-purple),
|
||||
hsl(264, 100%, 91%) 100%
|
||||
);
|
||||
--bg-gradient-blue: linear-gradient(
|
||||
to bottom,
|
||||
var(--accent-blue),
|
||||
hsla(220, 100%, 50%, 0) 25%
|
||||
);
|
||||
--bg-gradient-blue-alt: linear-gradient(
|
||||
to top,
|
||||
var(--accent-blue),
|
||||
hsl(220, 100%, 91%) 100%
|
||||
);
|
||||
--bg-gradient-image: linear-gradient(
|
||||
to bottom,
|
||||
hsla(0, 0%, 95%, 0.5),
|
||||
hsla(0, 0%, 95%, 1) 100%
|
||||
);
|
||||
--bg-panel: hsl(0, 0%, 100%);
|
||||
--bg-panel-highlight: hsl(0, 0%, 92%);
|
||||
--bg-separator: hsl(0, 0%, 88%);
|
||||
--bg-button: var(--bg-panel-highlight);
|
||||
--bg-badge: var(--accent-pink);
|
||||
--bg-input: #e0e0e0;
|
||||
|
||||
--shadow-panel: 0 2px 4px 0 hsla(0, 0%, 0%, 0.15);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
--accent-bg: hsl(304, 41%, 21%);
|
||||
--accent-fg: hsl(303, 73%, 81%);
|
||||
--bg: hsl(0, 0%, 8%);
|
||||
--bg-transparent: hsla(0, 0%, 8%, 0.8);
|
||||
--fg: hsl(0, 0%, 90%);
|
||||
--fg-muted: hsl(0, 0%, 50%);
|
||||
--fg-muted-alt: hsl(0, 0%, 25%);
|
||||
--fg-highlight: hsl(303, 64%, 65%);
|
||||
--fg-failure: hsl(0, 67%, 80%);
|
||||
// general
|
||||
--accent-pink: hsl(302, 100%, 76%);
|
||||
--accent-pink-alt: hsl(302, 100%, 50%);
|
||||
--accent-red: hsl(348, 100%, 80%);
|
||||
--accent-red-alt: hsl(348, 100%, 50%);
|
||||
--accent-purple: hsl(264, 100%, 81%);
|
||||
--accent-purple-alt: hsl(264, 100%, 50%);
|
||||
--accent-blue: hsl(220, 100%, 78%);
|
||||
--accent-blue-alt: hsl(220, 100%, 50%);
|
||||
--accent: var(--accent-pink);
|
||||
--accent-alt: var(--accent-pink-alt);
|
||||
|
||||
// foregrounds
|
||||
--fg: hsl(0, 0%, 100%);
|
||||
--fg-muted: hsla(0, 0%, 100%, 0.65);
|
||||
--fg-on-accent: hsl(0, 0%, 0%);
|
||||
--fg-on-badge: hsl(0, 0%, 0%);
|
||||
--fg-accent: var(--accent);
|
||||
--fg-failure: var(--accent-red);
|
||||
|
||||
// backgrounds
|
||||
--bg: hsl(220, 5%, 15%);
|
||||
--bg-gradient: linear-gradient(
|
||||
to bottom,
|
||||
hsla(303, 100%, 50%, 0.1),
|
||||
hsla(303, 100%, 50%, 0) 100%
|
||||
);
|
||||
--bg-gradient-pink: linear-gradient(
|
||||
to bottom,
|
||||
hsla(303, 100%, 50%, 0.1),
|
||||
hsla(303, 100%, 50%, 0) 25%
|
||||
);
|
||||
--bg-gradient-pink-alt: linear-gradient(
|
||||
to top,
|
||||
var(--accent-pink),
|
||||
hsl(303, 100%, 91%) 100%
|
||||
);
|
||||
--bg-gradient-red: linear-gradient(
|
||||
to bottom,
|
||||
hsla(348, 100%, 50%, 0.1),
|
||||
hsla(348, 100%, 50%, 0) 25%
|
||||
);
|
||||
--bg-gradient-red-alt: linear-gradient(
|
||||
to top,
|
||||
var(--accent-red),
|
||||
hsl(348, 100%, 91%) 100%
|
||||
);
|
||||
--bg-gradient-purple: linear-gradient(
|
||||
to bottom,
|
||||
hsla(264, 100%, 50%, 0.1),
|
||||
hsla(264, 100%, 50%, 0) 25%
|
||||
);
|
||||
--bg-gradient-purple-alt: linear-gradient(
|
||||
to top,
|
||||
var(--accent-purple),
|
||||
hsl(264, 100%, 91%) 100%
|
||||
);
|
||||
--bg-gradient-blue: linear-gradient(
|
||||
to bottom,
|
||||
hsla(220, 100%, 50%, 0.1),
|
||||
hsla(220, 100%, 50%, 0) 25%
|
||||
);
|
||||
--bg-gradient-blue-alt: linear-gradient(
|
||||
to top,
|
||||
var(--accent-blue),
|
||||
hsl(220, 100%, 91%) 100%
|
||||
);
|
||||
--bg-gradient-image: linear-gradient(
|
||||
to bottom,
|
||||
hsla(220, 5%, 12%, 0.5),
|
||||
hsla(220, 5%, 12%, 1) 100%
|
||||
);
|
||||
--bg-panel: hsl(220, 4%, 24%);
|
||||
--bg-panel-highlight: hsl(220, 2%, 32%);
|
||||
--bg-separator: hsl(220, 4%, 28%);
|
||||
--bg-button: hsl(220, 6%, 34%);
|
||||
--bg-badge: var(--accent-pink);
|
||||
|
||||
--shadow-panel: 0 4px 6px 0 hsla(0, 0%, 0%, 0.15);
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
|
|
@ -76,8 +218,31 @@
|
|||
}
|
||||
|
||||
body {
|
||||
@apply text-foreground bg-background font-body overflow-x-hidden;
|
||||
@apply text-foreground font-body font-semibold overflow-x-hidden;
|
||||
width: 100vw;
|
||||
background-color: var(--bg);
|
||||
background-size: 100vw 100vh;
|
||||
}
|
||||
|
||||
::selection,
|
||||
::-moz-selection {
|
||||
@apply bg-accent-blue text-on-accent;
|
||||
}
|
||||
|
||||
.hoverable {
|
||||
@apply hover:scale-105 duration-200;
|
||||
}
|
||||
|
||||
.hoverable-md {
|
||||
@apply hover:scale-110 duration-200;
|
||||
}
|
||||
|
||||
.hoverable-lg {
|
||||
@apply hover:scale-[1.15] duration-200;
|
||||
}
|
||||
|
||||
.selected {
|
||||
@apply bg-accent-purple !text-black;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
|
|
@ -86,14 +251,44 @@ body {
|
|||
}
|
||||
|
||||
.btn {
|
||||
@apply font-display flex items-center justify-center overflow-hidden relative cursor-pointer px-4 border-2 border-solid bg-background border-foreground-muted-alt rounded-xl p-2 focus:!outline-none hover:scale-105 duration-200 active:scale-95 disabled:opacity-50 disabled:pointer-events-none;
|
||||
@apply bg-button flex items-center justify-center overflow-hidden relative cursor-pointer px-6 h-14 rounded-full font-medium focus:!outline-none hover:scale-105 duration-200 active:scale-95 disabled:opacity-50 disabled:pointer-events-none hoverable;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-highlight {
|
||||
@apply bg-accent-background text-accent-foreground border-accent-background;
|
||||
.btn.highlight {
|
||||
@apply bg-accent text-on-accent;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply font-display font-semibold;
|
||||
}
|
||||
|
||||
code {
|
||||
@apply font-mono bg-gray-200 rounded-md px-1 dynadark:bg-panel-alt dynadark:text-white;
|
||||
}
|
||||
|
||||
p a {
|
||||
@apply text-accent underline;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
select.dropdown {
|
||||
@apply w-full p-3 rounded-lg bg-panel border-2 border-button pl-3 pr-[4rem];
|
||||
}
|
||||
|
||||
input[type="text"]::placeholder {
|
||||
@apply text-muted font-normal;
|
||||
}
|
||||
|
||||
input[type="text"]:focus {
|
||||
@apply outline outline-accent outline-2;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -1,135 +1,41 @@
|
|||
import type { EasingFunction, TransitionConfig } from "svelte/transition";
|
||||
import { isMobile, effects } from "$lib/store/index.svelte";
|
||||
import type { AnimationConfig, FlipParams } from "svelte/animate";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import {
|
||||
fade as svelteFade,
|
||||
fly as svelteFly,
|
||||
type FadeParams,
|
||||
type FlyParams,
|
||||
} from "svelte/transition";
|
||||
|
||||
// Subscribe to stores
|
||||
let effectsEnabled = true;
|
||||
let isMobileDevice = false;
|
||||
|
||||
effects.subscribe(value => {
|
||||
effectsEnabled = value;
|
||||
});
|
||||
|
||||
isMobile.subscribe(value => {
|
||||
isMobileDevice = value;
|
||||
});
|
||||
|
||||
export const transition =
|
||||
"linear(0,0.006,0.025 2.8%,0.101 6.1%,0.539 18.9%,0.721 25.3%,0.849 31.5%,0.937 38.1%,0.968 41.8%,0.991 45.7%,1.006 50.1%,1.015 55%,1.017 63.9%,1.001)";
|
||||
|
||||
export const duration = 500;
|
||||
|
||||
const remap = (
|
||||
value: number,
|
||||
low1: number,
|
||||
high1: number,
|
||||
low2: number,
|
||||
high2: number,
|
||||
) => low2 + ((high2 - low2) * (value - low1)) / (high1 - low1);
|
||||
export function fade(node: HTMLElement, options: FadeParams) {
|
||||
if (!effectsEnabled) return {};
|
||||
const animation = svelteFade(node, options);
|
||||
return animation;
|
||||
}
|
||||
|
||||
const choose = (
|
||||
direction: "in" | "out" | "both",
|
||||
defaultValue: number,
|
||||
inValue?: number,
|
||||
outValue?: number,
|
||||
) =>
|
||||
direction !== "out"
|
||||
? typeof inValue === "number"
|
||||
? inValue
|
||||
: defaultValue
|
||||
: typeof outValue === "number"
|
||||
? outValue
|
||||
: defaultValue;
|
||||
|
||||
type Combination<T extends string, U extends string> = `${T} ${U}`;
|
||||
|
||||
export const blur = (
|
||||
_: HTMLElement,
|
||||
config:
|
||||
| Partial<{
|
||||
blurMultiplier: number;
|
||||
duration: number;
|
||||
easing: EasingFunction;
|
||||
scale: {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
x: {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
y: {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
delay: number;
|
||||
opacity: boolean;
|
||||
origin: Combination<
|
||||
"top" | "bottom" | "left" | "right" | "center",
|
||||
"top" | "bottom" | "left" | "right" | "center"
|
||||
> & {};
|
||||
}>
|
||||
| undefined,
|
||||
dir: {
|
||||
direction: "in" | "out" | "both";
|
||||
},
|
||||
): TransitionConfig => {
|
||||
const prefersReducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
if (typeof config?.opacity === "undefined" && config) config.opacity = true;
|
||||
const isUsingTranslate = !!config?.x || !!config?.y || !!config?.scale;
|
||||
return {
|
||||
delay: config?.delay || 0,
|
||||
duration: prefersReducedMotion ? 0 : config?.duration || 300,
|
||||
css: (t) => {
|
||||
if (prefersReducedMotion) return "";
|
||||
const translate = isUsingTranslate
|
||||
? `translate(${remap(
|
||||
t,
|
||||
0,
|
||||
1,
|
||||
choose(
|
||||
dir.direction,
|
||||
0,
|
||||
config?.x?.start,
|
||||
config?.x?.end,
|
||||
),
|
||||
choose(
|
||||
dir.direction,
|
||||
0,
|
||||
config?.x?.end,
|
||||
config?.x?.start,
|
||||
),
|
||||
)}px, ${remap(
|
||||
t,
|
||||
0,
|
||||
1,
|
||||
choose(
|
||||
dir.direction,
|
||||
0,
|
||||
config?.y?.start,
|
||||
config?.y?.end,
|
||||
),
|
||||
choose(
|
||||
dir.direction,
|
||||
0,
|
||||
config?.y?.end,
|
||||
config?.y?.start,
|
||||
),
|
||||
)}px) scale(${remap(
|
||||
t,
|
||||
0,
|
||||
1,
|
||||
choose(
|
||||
dir.direction,
|
||||
0.9,
|
||||
config?.scale?.start,
|
||||
config?.scale?.end,
|
||||
),
|
||||
choose(
|
||||
dir.direction,
|
||||
1,
|
||||
config?.scale?.end,
|
||||
config?.scale?.start,
|
||||
),
|
||||
)})`
|
||||
: ``;
|
||||
return `filter: blur(${(1 - t) * (config?.blurMultiplier || 1)}px); opacity: ${config?.opacity ? t : 1}; transform: ${
|
||||
translate
|
||||
}; ${config?.origin ? `transform-origin: ${config.origin};` : ""}`;
|
||||
},
|
||||
easing: config?.easing,
|
||||
};
|
||||
};
|
||||
export function fly(node: HTMLElement, options: FlyParams) {
|
||||
if (!effectsEnabled || isMobileDevice) return {};
|
||||
const animation = svelteFly(node, options);
|
||||
return animation;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
export function is_function(thing: unknown): thing is Function {
|
||||
|
|
@ -165,7 +71,7 @@ export function flip(
|
|||
? duration(Math.sqrt(dx * dx + dy * dy))
|
||||
: duration,
|
||||
easing,
|
||||
css: (t, u) => {
|
||||
css: (_t, u) => {
|
||||
const x = u * dx;
|
||||
const y = u * dy;
|
||||
// const sx = scale ? t + (u * from.width) / to.width : 1;
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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>
|
||||
|
|
@ -1,17 +1,24 @@
|
|||
<script lang="ts">
|
||||
import { blur, duration, flip, transition } from "$lib/animation";
|
||||
import { duration, fade, transition } from "$lib/animation";
|
||||
import { ChevronDown } from "lucide-svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { quintOut } from "svelte/easing";
|
||||
import { fade } from "svelte/transition";
|
||||
|
||||
type Props = {
|
||||
options: string[];
|
||||
selected?: string;
|
||||
onselect?: (option: string) => void;
|
||||
disabled?: boolean;
|
||||
settingsStyle?: boolean;
|
||||
};
|
||||
|
||||
let { options, selected = $bindable(), onselect }: Props = $props();
|
||||
let {
|
||||
options,
|
||||
selected = $bindable(options[0]),
|
||||
onselect,
|
||||
disabled,
|
||||
settingsStyle,
|
||||
}: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let hover = $state(false);
|
||||
|
|
@ -31,10 +38,6 @@
|
|||
toggle();
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
selected = selected || options[0];
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const click = (e: MouseEvent) => {
|
||||
if (dropdown && !dropdown.contains(e.target as Node)) {
|
||||
|
|
@ -47,46 +50,44 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<div class="relative w-full min-w-fit" bind:this={dropdown}>
|
||||
<div
|
||||
class="relative w-full min-w-fit {settingsStyle
|
||||
? 'font-normal'
|
||||
: 'text-xl font-medium'} text-center"
|
||||
bind:this={dropdown}
|
||||
>
|
||||
<button
|
||||
class="font-display w-full min-w-fit justify-between overflow-hidden relative cursor-pointer px-3 border-2 border-solid flex items-center bg-background border-foreground-muted-alt rounded-xl p-2 focus:!outline-none"
|
||||
class="font-display w-full {settingsStyle
|
||||
? 'justify-between'
|
||||
: 'justify-center'} overflow-hidden relative cursor-pointer {settingsStyle
|
||||
? 'px-4'
|
||||
: 'px-3'} py-3.5 bg-button {disabled
|
||||
? 'opacity-50'
|
||||
: ''} flex items-center {settingsStyle
|
||||
? 'rounded-xl'
|
||||
: 'rounded-full'} focus:!outline-none"
|
||||
onclick={toggle}
|
||||
onmouseenter={() => (hover = true)}
|
||||
onmouseleave={() => (hover = false)}
|
||||
{disabled}
|
||||
>
|
||||
<!-- <p>{selected}</p> -->
|
||||
<div
|
||||
class="grid grid-cols-1 grid-rows-1 w-fit text-left flex-grow-0 pr-12"
|
||||
>
|
||||
<div class="grid grid-cols-1 grid-rows-1 w-fit flex-grow-0">
|
||||
{#key selected}
|
||||
<p
|
||||
in:blur={{
|
||||
in:fade={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
blurMultiplier: 6,
|
||||
scale: {
|
||||
start: 0.9,
|
||||
end: 1,
|
||||
},
|
||||
y: {
|
||||
start: isUp ? -50 : 50,
|
||||
end: 0,
|
||||
},
|
||||
}}
|
||||
out:blur={{
|
||||
out:fade={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
blurMultiplier: 6,
|
||||
scale: {
|
||||
start: 1,
|
||||
end: 0.9,
|
||||
},
|
||||
y: {
|
||||
start: 0,
|
||||
end: isUp ? 50 : -50,
|
||||
},
|
||||
}}
|
||||
class="col-start-1 row-start-1 text-left"
|
||||
class="col-start-1 row-start-1 {settingsStyle
|
||||
? 'text-left'
|
||||
: 'text-center'} font-body {settingsStyle
|
||||
? 'font-normal'
|
||||
: 'font-medium'}"
|
||||
>
|
||||
{selected}
|
||||
</p>
|
||||
|
|
@ -108,26 +109,16 @@
|
|||
</button>
|
||||
{#if open}
|
||||
<div
|
||||
style={hover ? "will-change: opacity, blur, transform" : ""}
|
||||
transition:blur={{
|
||||
style={hover ? "will-change: opacity, fade, transform" : ""}
|
||||
transition:fade={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
blurMultiplier: 6,
|
||||
scale: {
|
||||
start: 0.9,
|
||||
end: 1,
|
||||
},
|
||||
y: {
|
||||
start: -10,
|
||||
end: 0,
|
||||
},
|
||||
origin: "top center",
|
||||
}}
|
||||
class="w-full shadow-xl shadow-black/25 absolute overflow-hidden top-full mt-1 left-0 z-50 bg-background border-2 border-solid border-foreground-muted-alt rounded-xl"
|
||||
class="w-full shadow-xl bg-panel-alt shadow-black/25 absolute overflow-hidden top-full mt-1 left-0 z-50 bg-background rounded-xl max-h-[30vh] overflow-y-auto"
|
||||
>
|
||||
{#each options as option}
|
||||
<button
|
||||
class="w-full p-2 px-4 text-left hover:bg-foreground-muted-alt brightness-125"
|
||||
class="w-full p-2 px-4 text-left hover:bg-panel"
|
||||
onclick={() => select(option)}
|
||||
>
|
||||
{option}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { page } from "$app/state";
|
||||
import { duration, fade } from "$lib/animation";
|
||||
import { effects, setTheme } from "$lib/store/index.svelte";
|
||||
import clsx from "clsx";
|
||||
import { MoonIcon, SunIcon } from "lucide-svelte";
|
||||
import { quintOut } from "svelte/easing";
|
||||
import Panel from "../visual/Panel.svelte";
|
||||
import Logo from "../visual/svg/Logo.svelte";
|
||||
|
||||
type Props = {
|
||||
items: {
|
||||
name: string;
|
||||
url: string;
|
||||
activeMatch: (pathname: string) => boolean;
|
||||
icon: any;
|
||||
badge?: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
let { items }: Props = $props();
|
||||
|
||||
let links = $state<HTMLAnchorElement[]>([]);
|
||||
let container = $state<HTMLDivElement>();
|
||||
let containerRect = $derived(container?.getBoundingClientRect());
|
||||
$effect(() => {
|
||||
$inspect(containerRect);
|
||||
});
|
||||
|
||||
const linkRects = $derived(links.map((l) => l.getBoundingClientRect()));
|
||||
|
||||
const selectedIndex = $derived(
|
||||
items.findIndex((i) => i.activeMatch(page.url.pathname)),
|
||||
);
|
||||
</script>
|
||||
|
||||
{#snippet link(item: (typeof items)[0], index: number)}
|
||||
{@const Icon = item.icon}
|
||||
<a
|
||||
bind:this={links[index]}
|
||||
href={item.url}
|
||||
aria-label={item.name}
|
||||
class={clsx(
|
||||
"w-16 md:w-32 h-full relative z-10 rounded-xl flex items-center justify-center gap-3 overflow-hidden",
|
||||
{
|
||||
"bg-panel-highlight":
|
||||
item.activeMatch(page.url.pathname) && !browser,
|
||||
},
|
||||
)}
|
||||
draggable={false}
|
||||
>
|
||||
<div class="grid grid-rows-1 grid-cols-1">
|
||||
{#key item.name}
|
||||
<div
|
||||
class="w-full row-start-1 col-start-1 h-full flex items-center justify-center gap-3"
|
||||
in:fade={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
}}
|
||||
out:fade={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
}}
|
||||
>
|
||||
<div class="relative">
|
||||
<Icon />
|
||||
{#if item.badge}
|
||||
<div
|
||||
class="absolute overflow-hidden grid grid-rows-1 grid-cols-1 -top-1 font-display -right-1 w-fit px-1.5 h-4 rounded-full bg-badge text-on-badge font-medium"
|
||||
style="font-size: 0.7rem;"
|
||||
transition:fade={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
}}
|
||||
>
|
||||
{#key item.badge}
|
||||
<div
|
||||
class="flex items-center justify-center w-full h-full col-start-1 row-start-1"
|
||||
in:fade={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
}}
|
||||
out:fade={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
}}
|
||||
>
|
||||
{item.badge}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="font-medium hidden md:flex">
|
||||
{item.name}
|
||||
</p>
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
<div bind:this={container}>
|
||||
<Panel class="max-w-[778px] w-full h-20 flex items-center gap-3 relative">
|
||||
{#if linkRects[selectedIndex]}
|
||||
<div
|
||||
class="absolute bg-panel-highlight rounded-xl"
|
||||
style="width: {linkRects[selectedIndex]
|
||||
.width}px; height: {linkRects[selectedIndex]
|
||||
.height}px; top: {linkRects[selectedIndex].top -
|
||||
(containerRect?.top || 0)}px; left: {linkRects[
|
||||
selectedIndex
|
||||
].left - (containerRect?.left || 0)}px; {$effects
|
||||
? `transition: left var(--transition) ${duration}ms, top var(--transition) ${duration}ms;`
|
||||
: ''}"
|
||||
></div>
|
||||
{/if}
|
||||
<a
|
||||
class="w-28 h-full bg-accent rounded-xl items-center justify-center hidden md:flex"
|
||||
href="/"
|
||||
>
|
||||
<div class="h-5 w-full">
|
||||
<Logo />
|
||||
</div>
|
||||
</a>
|
||||
{#each items as item, i (item.url)}
|
||||
{@render link(item, i)}
|
||||
{/each}
|
||||
<div class="w-0.5 bg-separator h-full hidden md:flex"></div>
|
||||
<button
|
||||
onclick={() => {
|
||||
const isDark =
|
||||
document.documentElement.classList.contains("dark");
|
||||
setTheme(isDark ? "light" : "dark");
|
||||
}}
|
||||
class="w-14 h-full items-center justify-center hidden md:flex"
|
||||
>
|
||||
<SunIcon class="dynadark:hidden block" />
|
||||
<MoonIcon class="dynadark:block hidden" />
|
||||
</button>
|
||||
</Panel>
|
||||
</div>
|
||||
|
|
@ -1,121 +1,78 @@
|
|||
<script lang="ts">
|
||||
import { Upload } from "lucide-svelte";
|
||||
import { UploadIcon } from "lucide-svelte";
|
||||
import Panel from "../visual/Panel.svelte";
|
||||
import clsx from "clsx";
|
||||
import { onMount } from "svelte";
|
||||
import { files } from "$lib/store/index.svelte";
|
||||
import { converters } from "$lib/converters";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
let fileList = $state<FileList>();
|
||||
let dragBtn = $state<HTMLButtonElement>();
|
||||
type Props = {
|
||||
class?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
files: File[] | undefined;
|
||||
onupload?: () => void;
|
||||
isMobile: boolean;
|
||||
acceptedFormats?: string[];
|
||||
}
|
||||
const { class: classList }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (!fileList) return;
|
||||
files = Array.from(fileList);
|
||||
});
|
||||
let uploaderButton = $state<HTMLButtonElement>();
|
||||
|
||||
let fileInput = $state<HTMLInputElement>();
|
||||
let dragOver = $state(false);
|
||||
|
||||
let { files = $bindable(), onupload, isMobile, acceptedFormats }: Props = $props();
|
||||
|
||||
function upload() {
|
||||
if (!fileInput) return;
|
||||
fileInput.click();
|
||||
}
|
||||
const uploadFiles = async () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
// filter converters to ones where await converter.valid() is true
|
||||
const filteredConverters = (
|
||||
await Promise.all(
|
||||
converters.map(async (c) => {
|
||||
if (await c.valid()) return c;
|
||||
}),
|
||||
)
|
||||
).filter((c) => typeof c !== "undefined");
|
||||
input.accept = filteredConverters
|
||||
.map((c) => c.supportedFormats.join(","))
|
||||
.join(",");
|
||||
input.onchange = (e) => {
|
||||
const oldLength = files.files.length;
|
||||
files.add(input.files);
|
||||
if (oldLength !== files.files.length) goto("/convert");
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const handler = (e: Event) => e.preventDefault();
|
||||
if (!dragBtn) return;
|
||||
dragBtn.addEventListener("dragenter", handler);
|
||||
dragBtn.addEventListener("dragstart", handler);
|
||||
dragBtn.addEventListener("dragend", handler);
|
||||
dragBtn.addEventListener("dragleave", handler);
|
||||
dragBtn.addEventListener("dragover", handler);
|
||||
dragBtn.addEventListener("drag", handler);
|
||||
dragBtn.addEventListener("drop", handler);
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
};
|
||||
|
||||
uploaderButton?.addEventListener("dragover", handler);
|
||||
uploaderButton?.addEventListener("dragenter", handler);
|
||||
uploaderButton?.addEventListener("dragleave", handler);
|
||||
uploaderButton?.addEventListener("drop", handler);
|
||||
|
||||
return () => {
|
||||
if (!dragBtn) return;
|
||||
dragBtn.removeEventListener("dragenter", handler);
|
||||
dragBtn.removeEventListener("dragstart", handler);
|
||||
dragBtn.removeEventListener("dragend", handler);
|
||||
dragBtn.removeEventListener("dragleave", handler);
|
||||
dragBtn.removeEventListener("dragover", handler);
|
||||
dragBtn.removeEventListener("drag", handler);
|
||||
dragBtn.removeEventListener("drop", handler);
|
||||
uploaderButton?.removeEventListener("dragover", handler);
|
||||
uploaderButton?.removeEventListener("dragenter", handler);
|
||||
uploaderButton?.removeEventListener("dragleave", handler);
|
||||
uploaderButton?.removeEventListener("drop", handler);
|
||||
};
|
||||
});
|
||||
|
||||
function drop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
dragOver = false;
|
||||
if (!event.dataTransfer) return;
|
||||
if (!files) files = Array.from(event.dataTransfer.files);
|
||||
else files.push(...Array.from(event.dataTransfer.files));
|
||||
onupload?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
function addFiles() {
|
||||
if (!fileInput) return;
|
||||
if (!fileInput.files) return;
|
||||
if (!files) files = Array.from(fileInput.files);
|
||||
else files.push(...Array.from(fileInput.files));
|
||||
onupload?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
bind:this={dragBtn}
|
||||
onclick={upload}
|
||||
ondragover={() => (dragOver = true)}
|
||||
ondragleave={() => (dragOver = false)}
|
||||
class={clsx(
|
||||
"file-uploader",
|
||||
"w-full h-80 flex items-center justify-center cursor-pointer",
|
||||
"border-2 border-solid border-foreground-muted-alt rounded-2xl",
|
||||
"hover:scale-95 hover:opacity-70 transition-all duration-150 ease-out",
|
||||
{
|
||||
"scale-95 opacity-70 blur-xs": dragOver,
|
||||
},
|
||||
)}
|
||||
class:_drag-over={dragOver}
|
||||
ondrop={drop}
|
||||
onclick={uploadFiles}
|
||||
bind:this={uploaderButton}
|
||||
class={clsx(`hover:scale-105 active:scale-100 duration-200 ${classList}`)}
|
||||
>
|
||||
<div
|
||||
class="file-uploader-center flex items-center justify-center flex-col transition-all duration-150 ease-out px-8"
|
||||
<Panel
|
||||
class="flex justify-center items-center w-full h-full flex-col pointer-events-none"
|
||||
>
|
||||
<div
|
||||
class="size-16 rounded-full text-accent-foreground bg-accent-background flex items-center justify-center"
|
||||
class="w-16 h-16 bg-accent rounded-full flex items-center justify-center p-4"
|
||||
>
|
||||
<Upload class="size-8" />
|
||||
<UploadIcon class="w-full h-full text-on-accent" />
|
||||
</div>
|
||||
<h2 class="font-display text-2xl mt-6">
|
||||
{isMobile ? "Tap" : "Drop or click"} to upload files
|
||||
<h2 class="text-center text-2xl font-semibold mt-4">
|
||||
Drop or click to upload
|
||||
</h2>
|
||||
<p class="text-foreground-muted mt-4">
|
||||
All processing is done on your device. No file or size limit.
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
bind:this={fileInput}
|
||||
onchange={addFiles}
|
||||
accept={acceptedFormats?.join(",") ?? "*"}
|
||||
multiple
|
||||
/>
|
||||
|
||||
<style>
|
||||
.file-uploader:hover .file-uploader-center,
|
||||
.file-uploader._drag-over .file-uploader-center {
|
||||
@apply scale-105;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
<script lang="ts">
|
||||
type Props = {
|
||||
class: string;
|
||||
items: { [name: string]: string };
|
||||
};
|
||||
|
||||
const { class: classList, items }: Props = $props();
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
const links = $derived(Object.entries(items));
|
||||
</script>
|
||||
|
||||
<footer class={classList}>
|
||||
<div
|
||||
class="w-full h-full flex items-center justify-center text-muted gap-3 relative"
|
||||
>
|
||||
<p>© {year} VERT.</p>
|
||||
{#each links as [name, url] (name)}
|
||||
<!-- bullet point -->
|
||||
<p>•</p>
|
||||
<a
|
||||
class="hover:underline font-normal"
|
||||
href={url}
|
||||
target={url.startsWith("http") ? "_blank" : "_self"}
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute bottom-0 left-0 w-full h-24 -z-10"
|
||||
style="background: linear-gradient(to bottom, transparent, var(--bg) 100%)"
|
||||
></div>
|
||||
</footer>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;`
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -0,0 +1,12 @@
|
|||
import { PUB_ENV } from "$env/static/public";
|
||||
|
||||
export const GITHUB_URL_VERT = "https://github.com/VERT-sh/VERT";
|
||||
export const GITHUB_URL_VERTD = "https://github.com/VERT-sh/vertd";
|
||||
export const GITHUB_API_URL = "https://api.github.com/repos/VERT-sh/VERT";
|
||||
export const DISCORD_URL = "https://discord.gg/kqevGxYPak";
|
||||
export const VERT_NAME =
|
||||
PUB_ENV === "development"
|
||||
? "VERT Local"
|
||||
: PUB_ENV === "nightly"
|
||||
? "VERT Nightly"
|
||||
: "VERT.sh";
|
||||
|
|
@ -28,4 +28,8 @@ export class Converter {
|
|||
): Promise<VertFile> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
public async valid(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { VertFile } from "$lib/types";
|
|||
import { Converter } from "./converter.svelte";
|
||||
import { FFmpeg } from "@ffmpeg/ffmpeg";
|
||||
import { browser } from "$app/environment";
|
||||
import { log } from "$lib/logger";
|
||||
import { error, log } from "$lib/logger";
|
||||
import { addToast } from "$lib/store/ToastProvider";
|
||||
|
||||
export class FFmpegConverter extends Converter {
|
||||
private ffmpeg: FFmpeg = null!;
|
||||
|
|
@ -30,17 +31,25 @@ export class FFmpegConverter extends Converter {
|
|||
super();
|
||||
log(["converters", this.name], `created converter`);
|
||||
if (!browser) return;
|
||||
this.ffmpeg = new FFmpeg();
|
||||
(async () => {
|
||||
const baseURL =
|
||||
"https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm";
|
||||
await this.ffmpeg.load({
|
||||
coreURL: `${baseURL}/ffmpeg-core.js`,
|
||||
wasmURL: `${baseURL}/ffmpeg-core.wasm`,
|
||||
});
|
||||
// this is just to cache the wasm and js for when we actually use it. we're not using this ffmpeg instance
|
||||
this.ready = true;
|
||||
})();
|
||||
try {
|
||||
this.ffmpeg = new FFmpeg();
|
||||
(async () => {
|
||||
const baseURL =
|
||||
"https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm";
|
||||
await this.ffmpeg.load({
|
||||
coreURL: `${baseURL}/ffmpeg-core.js`,
|
||||
wasmURL: `${baseURL}/ffmpeg-core.wasm`,
|
||||
});
|
||||
// this is just to cache the wasm and js for when we actually use it. we're not using this ffmpeg instance
|
||||
this.ready = true;
|
||||
})();
|
||||
} catch (err) {
|
||||
error(["converters", this.name], `error loading ffmpeg: ${err}`);
|
||||
addToast(
|
||||
"error",
|
||||
`Error loading ffmpeg, some features may not work.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async convert(input: VertFile, to: string): Promise<VertFile> {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { FFmpegConverter } from "./ffmpeg.svelte";
|
||||
import { VertdConverter } from "./vertd.svelte";
|
||||
import { VipsConverter } from "./vips.svelte";
|
||||
|
||||
export const converters = [new VipsConverter(), new FFmpegConverter()];
|
||||
export const converters = [
|
||||
new VipsConverter(),
|
||||
new FFmpegConverter(),
|
||||
new VertdConverter(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
import { log } from "$lib/logger";
|
||||
import { Settings } from "$lib/sections/settings/index.svelte";
|
||||
import { VertFile } from "$lib/types";
|
||||
import { Converter } from "./converter.svelte";
|
||||
|
||||
interface VertdError {
|
||||
type: "error";
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface VertdSuccess<T> {
|
||||
type: "success";
|
||||
data: T;
|
||||
}
|
||||
|
||||
type VertdResponse<T> = VertdError | VertdSuccess<T>;
|
||||
|
||||
interface UploadResponse {
|
||||
id: string;
|
||||
auth: string;
|
||||
from: string;
|
||||
to: null;
|
||||
completed: false;
|
||||
totalFrames: number;
|
||||
}
|
||||
|
||||
interface RouteMap {
|
||||
"/api/upload": UploadResponse;
|
||||
"/api/version": string;
|
||||
}
|
||||
|
||||
const vertdFetch = async <U extends keyof RouteMap>(
|
||||
url: U,
|
||||
options: RequestInit,
|
||||
): Promise<RouteMap[U]> => {
|
||||
const domain = Settings.instance.settings.vertdURL;
|
||||
const res = await fetch(`${domain}${url}`, options);
|
||||
const text = await res.text();
|
||||
let json: VertdResponse<RouteMap[U]> = null!;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
if (json.type === "error") {
|
||||
throw new Error(json.data);
|
||||
}
|
||||
|
||||
return json.data as RouteMap[U];
|
||||
};
|
||||
|
||||
// ws types
|
||||
|
||||
export type ConversionSpeed =
|
||||
| "verySlow"
|
||||
| "slower"
|
||||
| "slow"
|
||||
| "medium"
|
||||
| "fast"
|
||||
| "ultraFast";
|
||||
|
||||
interface StartJobMessage {
|
||||
type: "startJob";
|
||||
data: {
|
||||
token: string;
|
||||
jobId: string;
|
||||
to: string;
|
||||
speed: ConversionSpeed;
|
||||
};
|
||||
}
|
||||
|
||||
interface ErrorMessage {
|
||||
type: "error";
|
||||
data: {
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProgressMessage {
|
||||
type: "progressUpdate";
|
||||
data: ProgressData;
|
||||
}
|
||||
|
||||
interface CompletedMessage {
|
||||
type: "jobFinished";
|
||||
data: {
|
||||
jobId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface FpsProgress {
|
||||
type: "fps";
|
||||
data: number;
|
||||
}
|
||||
|
||||
interface FrameProgress {
|
||||
type: "frame";
|
||||
data: number;
|
||||
}
|
||||
|
||||
type ProgressData = FpsProgress | FrameProgress;
|
||||
|
||||
type VertdMessage =
|
||||
| StartJobMessage
|
||||
| ErrorMessage
|
||||
| ProgressMessage
|
||||
| CompletedMessage;
|
||||
|
||||
export class VertdConverter extends Converter {
|
||||
public name = "vertd";
|
||||
public ready = $state(false);
|
||||
public reportsProgress = true;
|
||||
public supportedFormats = [".mkv", ".mp4", ".webm", ".avi", ".wmv", ".mov"];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private log: (...msg: any[]) => void = () => {};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.log = (msg) => log(["converters", this.name], msg);
|
||||
this.log("created converter");
|
||||
this.log("not rly sure how to implement this :P");
|
||||
this.ready = true;
|
||||
}
|
||||
|
||||
public async convert(input: VertFile, to: string): Promise<VertFile> {
|
||||
if (to.startsWith(".")) to = to.slice(1);
|
||||
// POST http://localhost:8080/api/upload
|
||||
// multipart body, key is "file", value is the file
|
||||
const formData = new FormData();
|
||||
formData.append("file", input.file, input.name);
|
||||
// const uploadRes = await fetch("http://localhost:8080/api/upload", {
|
||||
// method: "POST",
|
||||
// body: formData,
|
||||
// });
|
||||
|
||||
const uploadRes = await vertdFetch("/api/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const apiUrl = Settings.instance.settings.vertdURL;
|
||||
const ws = new WebSocket(
|
||||
`ws://${apiUrl.replace("http://", "").replace("https://", "")}/api/ws`,
|
||||
);
|
||||
ws.onopen = () => {
|
||||
const speed = Settings.instance.settings.vertdSpeed;
|
||||
this.log("opened ws connection to vertd");
|
||||
const msg: StartJobMessage = {
|
||||
type: "startJob",
|
||||
data: {
|
||||
jobId: uploadRes.id,
|
||||
token: uploadRes.auth,
|
||||
to,
|
||||
speed,
|
||||
},
|
||||
};
|
||||
ws.send(JSON.stringify(msg));
|
||||
this.log("sent startJob message");
|
||||
};
|
||||
|
||||
ws.onmessage = async (e) => {
|
||||
const msg: VertdMessage = JSON.parse(e.data);
|
||||
this.log(`received message ${msg.type}`);
|
||||
switch (msg.type) {
|
||||
case "progressUpdate": {
|
||||
const data = msg.data;
|
||||
if (data.type !== "frame") break;
|
||||
const frame = data.data;
|
||||
input.progress = (frame / uploadRes.totalFrames) * 100;
|
||||
break;
|
||||
}
|
||||
|
||||
case "jobFinished": {
|
||||
this.log("job finished");
|
||||
ws.close();
|
||||
const url = `${apiUrl}/api/download/${msg.data.jobId}/${uploadRes.auth}`;
|
||||
this.log(`downloading from ${url}`);
|
||||
const res = await fetch(url).then((res) => res.blob());
|
||||
resolve(
|
||||
new VertFile(
|
||||
new File([res], input.name),
|
||||
to,
|
||||
this,
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "error": {
|
||||
this.log(`error: ${msg.data.message}`);
|
||||
reject(msg.data.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async valid(): Promise<boolean> {
|
||||
if (!Settings.instance.settings.vertdURL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await vertdFetch("/api/version", {
|
||||
method: "GET",
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.log(e as unknown as string);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import { VertFile } from "$lib/types";
|
||||
import { Converter } from "./converter.svelte";
|
||||
import VipsWorker from "$lib/workers/vips?worker&url";
|
||||
import { browser } from "$app/environment";
|
||||
import type { WorkerMessage, OmitBetterStrict } from "$lib/types";
|
||||
import { log } from "$lib/logger";
|
||||
import { error, log } from "$lib/logger";
|
||||
import { addToast } from "$lib/store/ToastProvider";
|
||||
import type { OmitBetterStrict, WorkerMessage } from "$lib/types";
|
||||
import { VertFile } from "$lib/types";
|
||||
import VipsWorker from "$lib/workers/vips?worker&url";
|
||||
import { Converter } from "./converter.svelte";
|
||||
|
||||
export class VipsConverter extends Converter {
|
||||
private worker: Worker = browser
|
||||
|
|
@ -44,7 +45,15 @@ export class VipsConverter extends Converter {
|
|||
this.worker.onmessage = (e) => {
|
||||
const message: WorkerMessage = e.data;
|
||||
log(["converters", this.name], `received message ${message.type}`);
|
||||
if (message.type === "loaded") this.ready = true;
|
||||
if (message.type === "loaded") {
|
||||
this.ready = true;
|
||||
} else if (message.type === "error") {
|
||||
error(["converters", this.name], `error in worker: ${message.error}`);
|
||||
addToast("error", `Error in VIPS worker, some features may not work.`);
|
||||
throw new Error(message.error);
|
||||
} else {
|
||||
error(["converters", this.name], `unknown message type: ${message.type}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -103,7 +112,7 @@ export class VipsConverter extends Converter {
|
|||
try {
|
||||
this.worker.postMessage(msg);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
error(["converters", this.name], e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
<script lang="ts">
|
||||
import Panel from "$lib/components/visual/Panel.svelte";
|
||||
import { HeartHandshakeIcon } from "lucide-svelte";
|
||||
import { GITHUB_URL_VERT } from "$lib/consts";
|
||||
|
||||
let { mainContribs, ghContribs } = $props();
|
||||
</script>
|
||||
|
||||
{#snippet contributor(
|
||||
name: string,
|
||||
github: string,
|
||||
avatar: string,
|
||||
role?: string,
|
||||
)}
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href={github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex-shrink-0"
|
||||
>
|
||||
<img
|
||||
src={avatar}
|
||||
alt={name}
|
||||
title={name}
|
||||
class="{role
|
||||
? 'w-14 h-14 hoverable-md'
|
||||
: 'w-10 h-10 hoverable-lg'} rounded-full"
|
||||
/>
|
||||
</a>
|
||||
{#if role}
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-xl font-semibold">{name}</p>
|
||||
<p class="text-sm font-normal text-muted">{role}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<Panel class="flex flex-col gap-8 p-6">
|
||||
<h2 class="text-2xl font-bold flex items-center">
|
||||
<div class="rounded-full bg-blue-300 p-2 inline-block mr-3 w-10 h-10">
|
||||
<HeartHandshakeIcon color="black" />
|
||||
</div>
|
||||
Credits
|
||||
</h2>
|
||||
|
||||
<!-- Main contributors -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-row flex-wrap gap-2">
|
||||
{#each mainContribs as contrib}
|
||||
{@const { name, github, avatar, role } = contrib}
|
||||
{@render contributor(name, github, avatar, role)}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GitHub contributors -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-base font-bold">GitHub contributors</h2>
|
||||
{#if ghContribs && ghContribs.length > 0}
|
||||
<p class="text-base text-muted font-normal">
|
||||
Big thanks to all these people for helping out!
|
||||
<a
|
||||
class="text-blue-500 font-normal hover:underline"
|
||||
href={GITHUB_URL_VERT}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Want to help too?
|
||||
</a>
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-base text-muted font-normal italic">
|
||||
Seems like no one has contributed yet...
|
||||
<a
|
||||
class="text-blue-500 font-normal hover:underline"
|
||||
href={GITHUB_URL_VERT}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
be the first to contribute!
|
||||
</a>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if ghContribs && ghContribs.length > 0}
|
||||
<div class="flex flex-row flex-wrap gap-2">
|
||||
{#each ghContribs as contrib}
|
||||
{@const { name, github, avatar } = contrib}
|
||||
{@render contributor(name, github, avatar)}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Panel>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<script lang="ts">
|
||||
import Panel from "$lib/components/visual/Panel.svelte";
|
||||
import { DISCORD_URL, GITHUB_URL_VERT } from "$lib/consts";
|
||||
import { GithubIcon, LinkIcon, MessageCircleMoreIcon } from "lucide-svelte";
|
||||
</script>
|
||||
|
||||
<Panel class="flex flex-col gap-4 p-6">
|
||||
<h2 class="text-2xl font-bold flex items-center">
|
||||
<div
|
||||
class="rounded-full bg-accent-purple p-2 inline-block mr-3 w-10 h-10"
|
||||
>
|
||||
<LinkIcon color="black" />
|
||||
</div>
|
||||
Resources
|
||||
</h2>
|
||||
<div class="flex gap-3">
|
||||
<a
|
||||
href={DISCORD_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn flex-1 gap-2 p-4 rounded-full bg-button text-black dynadark:text-white flex items-center justify-center"
|
||||
>
|
||||
<MessageCircleMoreIcon size="24" class="inline-block mr-2" />
|
||||
Discord
|
||||
</a>
|
||||
<a
|
||||
href={GITHUB_URL_VERT}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn flex-1 gap-2 p-4 rounded-full bg-button text-black dynadark:text-white flex items-center justify-center"
|
||||
>
|
||||
<GithubIcon size="24" class="inline-block mr-2" />
|
||||
Source
|
||||
</a>
|
||||
</div>
|
||||
</Panel>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
<script lang="ts">
|
||||
import Panel from "$lib/components/visual/Panel.svelte";
|
||||
import {
|
||||
theme,
|
||||
effects,
|
||||
setEffects,
|
||||
setTheme,
|
||||
} from "$lib/store/index.svelte";
|
||||
import {
|
||||
MoonIcon,
|
||||
PaletteIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
SunIcon,
|
||||
} from "lucide-svelte";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
let lightElement: HTMLButtonElement;
|
||||
let darkElement: HTMLButtonElement;
|
||||
let enableEffectsElement: HTMLButtonElement;
|
||||
let disableEffectsElement: HTMLButtonElement;
|
||||
|
||||
let effectsUnsubscribe: () => void;
|
||||
let themeUnsubscribe: () => void;
|
||||
|
||||
const updateEffectsClasses = (value: boolean) => {
|
||||
if (value) {
|
||||
enableEffectsElement.classList.add("selected");
|
||||
disableEffectsElement.classList.remove("selected");
|
||||
} else {
|
||||
disableEffectsElement.classList.add("selected");
|
||||
enableEffectsElement.classList.remove("selected");
|
||||
}
|
||||
};
|
||||
|
||||
const updateThemeClasses = (value: string) => {
|
||||
document.documentElement.classList.remove("light", "dark");
|
||||
document.documentElement.classList.add(value);
|
||||
|
||||
if (value === "dark") {
|
||||
darkElement.classList.add("selected");
|
||||
lightElement.classList.remove("selected");
|
||||
} else {
|
||||
lightElement.classList.add("selected");
|
||||
darkElement.classList.remove("selected");
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
effectsUnsubscribe = effects.subscribe(updateEffectsClasses);
|
||||
themeUnsubscribe = theme.subscribe(updateThemeClasses);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (effectsUnsubscribe) effectsUnsubscribe();
|
||||
if (themeUnsubscribe) themeUnsubscribe();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Panel class="flex flex-col gap-8 p-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<h2 class="text-2xl font-bold">
|
||||
<PaletteIcon
|
||||
size="40"
|
||||
class="inline-block -mt-1 mr-2 bg-accent-purple p-2 rounded-full"
|
||||
color="black"
|
||||
/>
|
||||
Appearance
|
||||
</h2>
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-base font-bold">Brightness theme</p>
|
||||
<p class="text-sm text-muted font-normal italic">
|
||||
Want a sunny flash-bang, or a quiet lonely night?
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 w-full">
|
||||
<div class="flex gap-3 w-full">
|
||||
<button
|
||||
bind:this={lightElement}
|
||||
onclick={() => setTheme("light")}
|
||||
class="btn flex-1 p-4 rounded-lg text-black dynadark:text-white flex items-center justify-center"
|
||||
>
|
||||
<SunIcon size="24" class="inline-block mr-2" />
|
||||
Light
|
||||
</button>
|
||||
|
||||
<button
|
||||
bind:this={darkElement}
|
||||
onclick={() => setTheme("dark")}
|
||||
class="btn flex-1 p-4 rounded-lg text-black flex items-center justify-center"
|
||||
>
|
||||
<MoonIcon size="24" class="inline-block mr-2" />
|
||||
Dark
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-base font-bold">Effect settings</p>
|
||||
<p class="text-sm text-muted font-normal italic">
|
||||
Would you like fancy effects, or a more static
|
||||
experience?
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 w-full">
|
||||
<div class="flex gap-3 w-full">
|
||||
<button
|
||||
bind:this={enableEffectsElement}
|
||||
onclick={() => setEffects(true)}
|
||||
class="btn flex-1 p-4 rounded-lg text-black dynadark:text-white flex items-center justify-center"
|
||||
>
|
||||
<PlayIcon size="24" class="inline-block mr-2" />
|
||||
Enable
|
||||
</button>
|
||||
|
||||
<button
|
||||
bind:this={disableEffectsElement}
|
||||
onclick={() => setEffects(false)}
|
||||
class="btn flex-1 p-4 rounded-lg text-black dynadark:text-white flex items-center justify-center"
|
||||
>
|
||||
<PauseIcon size="24" class="inline-block mr-2" />
|
||||
Disable
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<script lang="ts">
|
||||
import FancyTextInput from "$lib/components/functional/FancyInput.svelte";
|
||||
import Panel from "$lib/components/visual/Panel.svelte";
|
||||
import { RefreshCwIcon } from "lucide-svelte";
|
||||
import type { ISettings } from "./index.svelte";
|
||||
|
||||
const { settings }: { settings: ISettings } = $props();
|
||||
</script>
|
||||
|
||||
<Panel class="flex flex-col gap-8 p-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<h2 class="text-2xl font-bold">
|
||||
<RefreshCwIcon
|
||||
size="40"
|
||||
class="inline-block -mt-1 mr-2 bg-accent p-2 rounded-full"
|
||||
color="black"
|
||||
/>
|
||||
Conversion
|
||||
</h2>
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-base font-bold">File name format</p>
|
||||
<p class="text-sm text-muted font-normal">
|
||||
This will determine the name of the file on download, <span
|
||||
class="font-bold italic"
|
||||
>not including the file extension.</span
|
||||
>
|
||||
You can put these following templates in the format, which
|
||||
will be replaced with the relevant information:
|
||||
<span class="font-bold">%name%</span>
|
||||
for the original file name,
|
||||
<span class="font-bold">%extension%</span>
|
||||
for the original file extension, and
|
||||
<span class="font-bold">%date%</span>
|
||||
for a date string of when the file was converted.
|
||||
</p>
|
||||
</div>
|
||||
<FancyTextInput
|
||||
placeholder="VERT_%name%"
|
||||
bind:value={settings.filenameFormat}
|
||||
extension=".ext"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
<script lang="ts">
|
||||
import Panel from "$lib/components/visual/Panel.svelte";
|
||||
import { GITHUB_URL_VERTD } from "$lib/consts";
|
||||
import { ServerIcon } from "lucide-svelte";
|
||||
import type { ISettings } from "./index.svelte";
|
||||
import clsx from "clsx";
|
||||
import Dropdown from "$lib/components/functional/Dropdown.svelte";
|
||||
|
||||
let vertdCommit = $state<string | null>(null);
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
const { settings }: { settings: ISettings } = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (settings.vertdURL) {
|
||||
if (abortController) abortController.abort();
|
||||
abortController = new AbortController();
|
||||
const { signal } = abortController;
|
||||
|
||||
vertdCommit = "loading";
|
||||
fetch(`${settings.vertdURL}/api/version`, { signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("bad response");
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
vertdCommit = data.data;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name !== "AbortError") vertdCommit = null;
|
||||
});
|
||||
} else {
|
||||
if (abortController) abortController.abort();
|
||||
vertdCommit = null;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (abortController) abortController.abort();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<Panel class="flex flex-col gap-8 p-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<h2 class="text-2xl font-bold">
|
||||
<ServerIcon
|
||||
size="40"
|
||||
class="inline-block -mt-1 mr-2 bg-accent-red p-2 rounded-full overflow-visible"
|
||||
color="black"
|
||||
/>
|
||||
Converting Video
|
||||
</h2>
|
||||
<p
|
||||
class={clsx("text-sm font-normal", {
|
||||
"text-failure": vertdCommit === null,
|
||||
"text-green-700 dynadark:text-green-300": vertdCommit !== null,
|
||||
"!text-muted": vertdCommit === "loading",
|
||||
})}
|
||||
>
|
||||
status: {vertdCommit
|
||||
? vertdCommit === "loading"
|
||||
? "loading..."
|
||||
: `available, commit id ${vertdCommit}`
|
||||
: "unavailable (is the url right?)"}
|
||||
</p>
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-muted font-normal">
|
||||
The <code>vertd</code> project is a server wrapper for FFmpeg.
|
||||
This allows you to convert videos through the convenience of
|
||||
VERT's web interface, while still being able to harness the power
|
||||
of your GPU to do it as quickly as possible.
|
||||
</p>
|
||||
<p class="text-sm text-muted font-normal">
|
||||
We currently don't provide a hosted instance due to the
|
||||
upkeep costs. However, it's quite easy to host one on your
|
||||
own PC or server if you know what you're doing. You can
|
||||
download the server binaries <a
|
||||
href={GITHUB_URL_VERTD}
|
||||
target="_blank">here</a
|
||||
>. The process of setting this up will become easier in the
|
||||
future, so stay tuned!
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-base font-bold">Instance URL</p>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Example: http://localhost:24153"
|
||||
bind:value={settings.vertdURL}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-base font-bold">Conversion speed</p>
|
||||
<p class="text-sm text-muted font-normal">
|
||||
This describes the tradeoff between speed and
|
||||
quality. Faster speeds will result in lower quality,
|
||||
but will get the job done quicker.
|
||||
</p>
|
||||
</div>
|
||||
<Dropdown
|
||||
options={[
|
||||
"Very Slow",
|
||||
"Slower",
|
||||
"Slow",
|
||||
"Medium",
|
||||
"Fast",
|
||||
"Ultra Fast",
|
||||
]}
|
||||
settingsStyle
|
||||
selected={(() => {
|
||||
switch (settings.vertdSpeed) {
|
||||
case "verySlow":
|
||||
return "Very Slow";
|
||||
case "slower":
|
||||
return "Slower";
|
||||
case "slow":
|
||||
return "Slow";
|
||||
case "medium":
|
||||
return "Medium";
|
||||
case "fast":
|
||||
return "Fast";
|
||||
case "ultraFast":
|
||||
return "Ultra Fast";
|
||||
}
|
||||
})()}
|
||||
onselect={(selected) => {
|
||||
switch (selected) {
|
||||
case "Very Slow":
|
||||
settings.vertdSpeed = "verySlow";
|
||||
break;
|
||||
case "Slower":
|
||||
settings.vertdSpeed = "slower";
|
||||
break;
|
||||
case "Slow":
|
||||
settings.vertdSpeed = "slow";
|
||||
break;
|
||||
case "Medium":
|
||||
settings.vertdSpeed = "medium";
|
||||
break;
|
||||
case "Fast":
|
||||
settings.vertdSpeed = "fast";
|
||||
break;
|
||||
case "Ultra Fast":
|
||||
settings.vertdSpeed = "ultraFast";
|
||||
break;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import type { ConversionSpeed } from "$lib/converters/vertd.svelte";
|
||||
|
||||
export { default as Appearance } from "./Appearance.svelte";
|
||||
export { default as Conversion } from "./Conversion.svelte";
|
||||
export { default as Vertd } from "./Vertd.svelte";
|
||||
|
||||
export interface ISettings {
|
||||
filenameFormat: string;
|
||||
vertdURL: string;
|
||||
vertdSpeed: ConversionSpeed;
|
||||
}
|
||||
|
||||
export class Settings {
|
||||
public static instance = new Settings();
|
||||
|
||||
public settings: ISettings = $state({
|
||||
filenameFormat: "VERT_%name%",
|
||||
vertdURL: "",
|
||||
vertdSpeed: "slow",
|
||||
});
|
||||
|
||||
public save() {
|
||||
localStorage.setItem("settings", JSON.stringify(this.settings));
|
||||
}
|
||||
|
||||
public load() {
|
||||
const ls = localStorage.getItem("settings");
|
||||
if (!ls) return;
|
||||
const settings: ISettings = JSON.parse(ls);
|
||||
this.settings = {
|
||||
...this.settings,
|
||||
...settings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { writable } from "svelte/store";
|
||||
|
||||
export type ToastType = "success" | "error" | "info" | "warning";
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
disappearing: boolean;
|
||||
durations: {
|
||||
enter: number;
|
||||
stay: number;
|
||||
exit: number;
|
||||
};
|
||||
}
|
||||
|
||||
const toasts = writable<Toast[]>([]);
|
||||
|
||||
let toastId = 0;
|
||||
|
||||
function addToast(
|
||||
type: ToastType,
|
||||
message: string,
|
||||
disappearing?: boolean,
|
||||
durations?: { enter: number; stay: number; exit: number },
|
||||
) {
|
||||
const id = toastId++;
|
||||
|
||||
durations = durations ?? {
|
||||
enter: 300,
|
||||
stay: disappearing || disappearing === undefined ? 5000 : 86400000, // 24h cause why not
|
||||
exit: 500,
|
||||
};
|
||||
|
||||
// if "disappearing" not set, default error/warning to infinite duration
|
||||
if (disappearing === undefined) {
|
||||
switch (type) {
|
||||
case "error":
|
||||
case "warning":
|
||||
durations.stay = 86400000; // 24h cause why not
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const newToast: Toast = {
|
||||
id,
|
||||
type,
|
||||
message,
|
||||
disappearing: disappearing ?? true,
|
||||
durations,
|
||||
};
|
||||
toasts.update((currentToasts) => [...currentToasts, newToast]);
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
removeToast(id);
|
||||
},
|
||||
durations.enter + durations.stay + durations.exit,
|
||||
);
|
||||
}
|
||||
|
||||
function removeToast(id: number) {
|
||||
toasts.update((currentToasts) =>
|
||||
currentToasts.filter((toast) => toast.id !== id),
|
||||
);
|
||||
}
|
||||
|
||||
export { toasts, addToast, removeToast };
|
||||
|
|
@ -1,29 +1,235 @@
|
|||
import { browser } from "$app/environment";
|
||||
import { log } from "$lib/logger";
|
||||
import { converters } from "$lib/converters";
|
||||
import { error, log } from "$lib/logger";
|
||||
import { VertFile } from "$lib/types";
|
||||
import JSCookie from "js-cookie";
|
||||
import jsmediatags from "jsmediatags";
|
||||
import type { TagType } from "jsmediatags/types";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
class Files {
|
||||
public files = $state<VertFile[]>([]);
|
||||
}
|
||||
|
||||
class Theme {
|
||||
public dark = $state(false);
|
||||
public toggle = () => {
|
||||
this.dark = !this.dark;
|
||||
JSCookie.set("theme", this.dark ? "dark" : "light", {
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
expires: 2147483647,
|
||||
});
|
||||
log(["theme"], `set to ${this.dark ? "dark" : "light"}`);
|
||||
if (browser) {
|
||||
window.plausible("Theme set", {
|
||||
props: { theme: theme.dark ? "dark" : "light" },
|
||||
});
|
||||
public requiredConverters = $derived(
|
||||
Array.from(new Set(files.files.map((f) => f.converter))),
|
||||
);
|
||||
|
||||
public ready = $derived(
|
||||
this.files.length === 0
|
||||
? false
|
||||
: this.requiredConverters.every((f) => f?.ready) &&
|
||||
this.files.every((f) => !f.processing),
|
||||
);
|
||||
public results = $derived(
|
||||
this.files.length === 0 ? false : this.files.every((f) => f.result),
|
||||
);
|
||||
|
||||
private _addThumbnail = async (file: VertFile) => {
|
||||
const isAudio = converters
|
||||
.find((c) => c.name === "ffmpeg")
|
||||
?.supportedFormats?.includes(file.from.toLowerCase());
|
||||
const isVideo = converters
|
||||
.find((c) => c.name === "vertd")
|
||||
?.supportedFormats?.includes(file.from.toLowerCase());
|
||||
|
||||
try {
|
||||
if (isAudio) {
|
||||
// try to get the thumbnail from the audio via jsmmediatags
|
||||
const tags = await new Promise<TagType>((resolve, reject) => {
|
||||
jsmediatags.read(file.file, {
|
||||
onSuccess: (tag) => resolve(tag),
|
||||
onError: (error) => reject(error),
|
||||
});
|
||||
});
|
||||
if (tags.tags.picture) {
|
||||
const blob = new Blob(
|
||||
[new Uint8Array(tags.tags.picture.data)],
|
||||
{
|
||||
type: tags.tags.picture.format,
|
||||
},
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
file.blobUrl = url;
|
||||
}
|
||||
} else if (isVideo) {
|
||||
// video
|
||||
file.blobUrl = await this._generateThumbnailFromMedia(
|
||||
file.file,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
// image
|
||||
file.blobUrl = await this._generateThumbnailFromMedia(
|
||||
file.file,
|
||||
false,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
error(["files"], e);
|
||||
}
|
||||
};
|
||||
|
||||
private async _generateThumbnailFromMedia(
|
||||
file: File,
|
||||
isVideo: boolean,
|
||||
): Promise<string | undefined> {
|
||||
const maxSize = 180;
|
||||
const mediaElement = isVideo
|
||||
? document.createElement("video")
|
||||
: new Image();
|
||||
mediaElement.src = URL.createObjectURL(file);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
if (isVideo) {
|
||||
(mediaElement as HTMLVideoElement).onloadeddata = resolve;
|
||||
} else {
|
||||
(mediaElement as HTMLImageElement).onload = resolve;
|
||||
}
|
||||
});
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return undefined;
|
||||
|
||||
const width = isVideo
|
||||
? (mediaElement as HTMLVideoElement).videoWidth
|
||||
: (mediaElement as HTMLImageElement).width;
|
||||
const height = isVideo
|
||||
? (mediaElement as HTMLVideoElement).videoHeight
|
||||
: (mediaElement as HTMLImageElement).height;
|
||||
|
||||
const scale = Math.max(maxSize / width, maxSize / height);
|
||||
canvas.width = width * scale;
|
||||
canvas.height = height * scale;
|
||||
ctx.drawImage(mediaElement, 0, 0, canvas.width, canvas.height);
|
||||
const url = canvas.toDataURL();
|
||||
canvas.remove();
|
||||
return url;
|
||||
}
|
||||
|
||||
private _add(file: VertFile | File) {
|
||||
if (file instanceof VertFile) {
|
||||
this.files.push(file);
|
||||
this._addThumbnail(file);
|
||||
} else {
|
||||
const format = "." + file.name.split(".").pop()?.toLowerCase();
|
||||
if (!format) {
|
||||
log(["files"], `no extension found for ${file.name}`);
|
||||
return;
|
||||
}
|
||||
const converter = converters.find((c) =>
|
||||
c.supportedFormats.includes(
|
||||
format || ".somenonexistentextension",
|
||||
),
|
||||
);
|
||||
if (!converter) {
|
||||
log(["files"], `no converter found for ${file.name}`);
|
||||
this.files.push(new VertFile(file, format, null));
|
||||
return;
|
||||
}
|
||||
const to = converter.supportedFormats.find((f) => f !== format);
|
||||
if (!to) {
|
||||
log(["files"], `no output format found for ${file.name}`);
|
||||
return;
|
||||
}
|
||||
const vf = new VertFile(file, to, converter);
|
||||
this.files.push(vf);
|
||||
this._addThumbnail(vf);
|
||||
}
|
||||
}
|
||||
|
||||
public add(file: VertFile | null | undefined): void;
|
||||
public add(file: File | null | undefined): void;
|
||||
public add(file: File[] | null | undefined): void;
|
||||
public add(file: VertFile[] | null | undefined): void;
|
||||
public add(file: FileList | null | undefined): void;
|
||||
public add(
|
||||
file:
|
||||
| VertFile
|
||||
| File
|
||||
| VertFile[]
|
||||
| File[]
|
||||
| FileList
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
if (!file) return;
|
||||
if (Array.isArray(file) || file instanceof FileList) {
|
||||
for (const f of file) {
|
||||
this._add(f);
|
||||
}
|
||||
} else {
|
||||
this._add(file);
|
||||
}
|
||||
}
|
||||
|
||||
public async convertAll() {
|
||||
await Promise.all(this.files.map((f) => f.convert()));
|
||||
}
|
||||
|
||||
public async downloadAll() {
|
||||
if (files.files.length === 0) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const dlFiles: any[] = [];
|
||||
for (let i = 0; i < files.files.length; i++) {
|
||||
const file = files.files[i];
|
||||
const result = file.result;
|
||||
if (!result) {
|
||||
console.error("No result found");
|
||||
continue;
|
||||
}
|
||||
dlFiles.push({
|
||||
name: file.file.name.replace(/\.[^/.]+$/, "") + file.to,
|
||||
lastModified: Date.now(),
|
||||
input: await result.file.arrayBuffer(),
|
||||
});
|
||||
}
|
||||
const { downloadZip } = await import("client-zip");
|
||||
const blob = await downloadZip(dlFiles, "converted.zip").blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const settings = JSON.parse(localStorage.getItem("settings") ?? "{}");
|
||||
const filenameFormat = settings.filenameFormat ?? "VERT_%name%";
|
||||
|
||||
const format = (name: string) => {
|
||||
const date = new Date().toISOString();
|
||||
return name
|
||||
.replace(/%date%/g, date)
|
||||
.replace(/%name%/g, "Multi")
|
||||
.replace(/%extension%/g, "");
|
||||
};
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${format(filenameFormat)}.zip`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function setTheme(themeTo: "light" | "dark") {
|
||||
document.documentElement.classList.remove("light", "dark");
|
||||
document.documentElement.classList.add(themeTo);
|
||||
localStorage.setItem("theme", themeTo);
|
||||
window.plausible("Theme set", {
|
||||
props: { theme: themeTo },
|
||||
});
|
||||
log(["theme"], `set to ${themeTo}`);
|
||||
theme.set(themeTo);
|
||||
}
|
||||
|
||||
export function setEffects(effectsEnabled: boolean) {
|
||||
localStorage.setItem("effects", effectsEnabled.toString());
|
||||
window.plausible("Effects set", {
|
||||
props: { effects: effectsEnabled },
|
||||
});
|
||||
log(["effects"], `set to ${effectsEnabled}`);
|
||||
effects.set(effectsEnabled);
|
||||
}
|
||||
|
||||
export const files = new Files();
|
||||
export const theme = new Theme();
|
||||
export const showGradient = writable(true);
|
||||
export const gradientColor = writable("");
|
||||
|
||||
export const isMobile = writable(false);
|
||||
export const effects = writable(true);
|
||||
export const theme = writable<"light" | "dark">("light");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Converter } from "$lib/converters/converter.svelte";
|
||||
import { addToast } from "$lib/store/ToastProvider";
|
||||
|
||||
export class VertFile {
|
||||
public id: string = Math.random().toString(36).slice(2, 8);
|
||||
|
|
@ -18,12 +19,14 @@ export class VertFile {
|
|||
|
||||
public blobUrl = $state<string>();
|
||||
|
||||
public processing = $state(false);
|
||||
|
||||
public converter: Converter | null = null;
|
||||
|
||||
constructor(
|
||||
public readonly file: File,
|
||||
to: string,
|
||||
converter?: Converter,
|
||||
converter?: Converter | null,
|
||||
blobUrl?: string,
|
||||
) {
|
||||
this.to = to;
|
||||
|
|
@ -37,13 +40,36 @@ export class VertFile {
|
|||
if (!this.converter) throw new Error("No converter found");
|
||||
this.result = null;
|
||||
this.progress = 0;
|
||||
const res = await this.converter.convert(this, this.to);
|
||||
this.result = res;
|
||||
this.processing = true;
|
||||
let res;
|
||||
try {
|
||||
res = await this.converter.convert(this, this.to);
|
||||
this.result = res;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
addToast("error", `Error converting file: ${this.file.name}`);
|
||||
this.result = null;
|
||||
}
|
||||
this.processing = false;
|
||||
return res;
|
||||
}
|
||||
|
||||
public async download() {
|
||||
if (!this.result) throw new Error("No result found");
|
||||
|
||||
const settings = JSON.parse(localStorage.getItem("settings") ?? "{}");
|
||||
const filenameFormat = settings.filenameFormat ?? "VERT_%name%";
|
||||
|
||||
const format = (name: string) => {
|
||||
const date = new Date().toISOString();
|
||||
const baseName = this.file.name.replace(/\.[^/.]+$/, "");
|
||||
const originalExtension = this.file.name.split(".").pop()!;
|
||||
return name
|
||||
.replace(/%date%/g, date)
|
||||
.replace(/%name%/g, baseName)
|
||||
.replace(/%extension%/g, originalExtension);
|
||||
};
|
||||
|
||||
const blob = URL.createObjectURL(
|
||||
new Blob([await this.result.file.arrayBuffer()], {
|
||||
type: this.to.slice(1),
|
||||
|
|
@ -51,7 +77,7 @@ export class VertFile {
|
|||
);
|
||||
const a = document.createElement("a");
|
||||
a.href = blob;
|
||||
a.download = `VERT-Converted_${new Date().toISOString()}${this.to}`;
|
||||
a.download = `${format(filenameFormat)}${this.to}`;
|
||||
// force it to not open in a new tab
|
||||
a.target = "_blank";
|
||||
a.style.display = "none";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,285 +1,362 @@
|
|||
<script lang="ts">
|
||||
import "../app.scss";
|
||||
import { goto } from "$app/navigation";
|
||||
import { blur, duration } from "$lib/animation";
|
||||
import { quintOut } from "svelte/easing";
|
||||
import { files, theme } from "$lib/store/index.svelte";
|
||||
import Logo from "$lib/components/visual/svg/Logo.svelte";
|
||||
import { page } from "$app/state";
|
||||
import { beforeNavigate, goto } from "$app/navigation";
|
||||
import { PUB_HOSTNAME, PUB_PLAUSIBLE_URL } from "$env/static/public";
|
||||
import { duration, fly } from "$lib/animation";
|
||||
import VertVBig from "$lib/assets/vert-bg.svg?component";
|
||||
import featuredImage from "$lib/assets/VERT_Feature.webp";
|
||||
import Navbar from "$lib/components/functional/Navbar.svelte";
|
||||
import Footer from "$lib/components/visual/Footer.svelte";
|
||||
import Logo from "$lib/components/visual/svg/Logo.svelte";
|
||||
|
||||
import { fade } from "$lib/animation";
|
||||
import {
|
||||
PUB_ENV,
|
||||
PUB_HOSTNAME,
|
||||
PUB_PLAUSIBLE_URL,
|
||||
} from "$env/static/public";
|
||||
import FancyMenu from "$lib/components/functional/FancyMenu.svelte";
|
||||
import { writable } from "svelte/store";
|
||||
import { MoonIcon, SunIcon } from "lucide-svelte";
|
||||
import { browser } from "$app/environment";
|
||||
import JSCookie from "js-cookie";
|
||||
files,
|
||||
gradientColor,
|
||||
isMobile,
|
||||
effects,
|
||||
showGradient,
|
||||
theme,
|
||||
} from "$lib/store/index.svelte";
|
||||
import {
|
||||
InfoIcon,
|
||||
RefreshCw,
|
||||
SettingsIcon,
|
||||
UploadIcon,
|
||||
} from "lucide-svelte";
|
||||
import { onMount } from "svelte";
|
||||
let { children, data } = $props();
|
||||
import { quintOut } from "svelte/easing";
|
||||
import "../app.scss";
|
||||
import { DISCORD_URL, GITHUB_URL_VERT, VERT_NAME } from "$lib/consts";
|
||||
import { type Toast as ToastType, toasts } from "$lib/store/ToastProvider";
|
||||
import Toast from "$lib/components/visual/Toast.svelte";
|
||||
import { Settings } from "$lib/sections/settings/index.svelte";
|
||||
let { children } = $props();
|
||||
|
||||
let shouldGoBack = writable(false);
|
||||
let navbar = $state<HTMLDivElement>();
|
||||
let hover = $state(false);
|
||||
let dropping = $state(false);
|
||||
let goingLeft = $state(false);
|
||||
let toastList = $state<ToastType[]>([]);
|
||||
|
||||
const links = $derived<
|
||||
toasts.subscribe((value) => {
|
||||
toastList = value as ToastType[];
|
||||
});
|
||||
|
||||
const items = $derived<
|
||||
{
|
||||
name: string;
|
||||
url: string;
|
||||
activeMatch: (pathname: string) => boolean;
|
||||
icon: any;
|
||||
badge?: number;
|
||||
}[]
|
||||
>([
|
||||
{
|
||||
name: "Upload",
|
||||
url: "/",
|
||||
activeMatch: (pathname) => pathname === "/",
|
||||
icon: UploadIcon,
|
||||
},
|
||||
{
|
||||
name:
|
||||
files.files.length > 0
|
||||
? `Convert (${files.files.length})`
|
||||
: `Convert`,
|
||||
name: "Convert",
|
||||
url: "/convert",
|
||||
activeMatch: (pathname) => pathname === "/convert",
|
||||
icon: RefreshCw,
|
||||
badge: files.files.length,
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
url: "/settings",
|
||||
activeMatch: (pathname) => pathname.startsWith("/settings"),
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
{
|
||||
name: "About",
|
||||
url: "/about",
|
||||
activeMatch: (pathname) => pathname.startsWith("/about"),
|
||||
icon: InfoIcon,
|
||||
},
|
||||
]);
|
||||
|
||||
const maybeNavToHome = (e: DragEvent) => {
|
||||
if (e.dataTransfer?.types.includes("Files")) {
|
||||
e.preventDefault();
|
||||
goto("/");
|
||||
}
|
||||
const dropFiles = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
dropping = false;
|
||||
const oldLength = files.files.length;
|
||||
files.add(e.dataTransfer?.files);
|
||||
if (oldLength !== files.files.length) goto("/convert");
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
if (theme.dark) {
|
||||
document.documentElement.classList.add("dark");
|
||||
document.documentElement.classList.remove("light");
|
||||
JSCookie.set("theme", "dark", {
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
expires: 2147483647,
|
||||
});
|
||||
} else {
|
||||
document.documentElement.classList.add("light");
|
||||
document.documentElement.classList.remove("dark");
|
||||
JSCookie.set("theme", "light", {
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
expires: 2147483647,
|
||||
});
|
||||
}
|
||||
});
|
||||
const handleDrag = (e: DragEvent, drag: boolean) => {
|
||||
e.preventDefault();
|
||||
dropping = drag;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const mouseEnter = () => {
|
||||
hover = true;
|
||||
};
|
||||
isMobile.set(window.innerWidth <= 768);
|
||||
window.addEventListener("resize", () => {
|
||||
isMobile.set(window.innerWidth <= 768);
|
||||
});
|
||||
|
||||
const mouseLeave = () => {
|
||||
hover = false;
|
||||
};
|
||||
effects.set(localStorage.getItem("effects") !== "false"); // defaults to true if not set
|
||||
theme.set(
|
||||
(localStorage.getItem("theme") as "light" | "dark") || "light",
|
||||
);
|
||||
|
||||
navbar?.addEventListener("mouseenter", mouseEnter);
|
||||
navbar?.addEventListener("mouseleave", mouseLeave);
|
||||
Settings.instance.load();
|
||||
});
|
||||
|
||||
beforeNavigate((e) => {
|
||||
const oldIndex = items.findIndex((i) =>
|
||||
i.activeMatch(e.from?.url.pathname || ""),
|
||||
);
|
||||
const newIndex = items.findIndex((i) =>
|
||||
i.activeMatch(e.to?.url.pathname || ""),
|
||||
);
|
||||
if (newIndex < oldIndex) {
|
||||
goingLeft = true;
|
||||
} else {
|
||||
goingLeft = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>VERT.sh</title>
|
||||
<title>{VERT_NAME}</title>
|
||||
<meta name="theme-color" content="#F2ABEE" />
|
||||
<meta
|
||||
name="title"
|
||||
content="{VERT_NAME} — Free, fast, and awesome file convert"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
content="With VERT you can convert image and audio files to and from PNG, JPG, WEBP, MP3, WAV, FLAC, and more. No ads, no tracking, open source, and all processing is done on your device."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta
|
||||
property="og:title"
|
||||
content="{VERT_NAME} — Free, fast, and awesome file convert"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="With VERT you can convert image and audio files to and from PNG, JPG, WEBP, MP3, WAV, FLAC, and more. No ads, no tracking, open source, and all processing is done on your device."
|
||||
/>
|
||||
<meta property="og:image" content={featuredImage} />
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta
|
||||
property="twitter:title"
|
||||
content="{VERT_NAME} — Free, fast, and awesome file convert"
|
||||
/>
|
||||
<meta
|
||||
property="twitter:description"
|
||||
content="With VERT you can convert image and audio files to and from PNG, JPG, WEBP, MP3, WAV, FLAC, and more. No ads, no tracking, open source, and all processing is done on your device."
|
||||
/>
|
||||
<meta property="twitter:image" content={featuredImage} />
|
||||
{#if PUB_PLAUSIBLE_URL}<script
|
||||
defer
|
||||
data-domain={PUB_HOSTNAME || "vert.sh"}
|
||||
src="{PUB_PLAUSIBLE_URL}/js/script.pageview-props.tagged-events.js"
|
||||
></script>{/if}
|
||||
<script src="/coi-serviceworker.min.js"></script>
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
role="main"
|
||||
class="w-full h-full max-w-screen-lg mx-auto p-4"
|
||||
ondragenter={maybeNavToHome}
|
||||
class="flex flex-col min-h-screen h-full"
|
||||
ondrop={dropFiles}
|
||||
ondragenter={(e) => handleDrag(e, true)}
|
||||
ondragover={(e) => handleDrag(e, true)}
|
||||
ondragleave={(e) => handleDrag(e, false)}
|
||||
role="region"
|
||||
>
|
||||
<div class="flex justify-center mb-5 lg:hidden">
|
||||
<a
|
||||
href="/"
|
||||
class="px-4 relative h-14 mr-3 justify-center items-center bg-accent-background fill-accent-foreground rounded-xl md:hidden flex"
|
||||
>
|
||||
<div class="h-6 relative w-24 items-center flex justify-center">
|
||||
<Logo />
|
||||
{#if PUB_ENV === "nightly"}
|
||||
<div
|
||||
class="absolute -top-6 -left-10 px-2 py-1 w-fit bg-foreground-highlight text-accent-background rotate-[-10deg] rounded-xl"
|
||||
style="font-family: Comic Sans MS, sans-serif;"
|
||||
>
|
||||
NIGHTLY
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{#if dropping}
|
||||
<div
|
||||
class="fixed w-screen h-screen opacity-40 dynadark:opacity-20 z-[100] pointer-events-none blur-2xl {$effects
|
||||
? 'dragoverlay'
|
||||
: 'bg-accent-blue'}"
|
||||
class:_dragover={dropping && $effects}
|
||||
transition:fade={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
}}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="w-full max-w-screen-md p-1 border-solid border-2 rounded-2xl border-foreground-muted-alt flex mb-10 mx-auto lg:mt-5"
|
||||
bind:this={navbar}
|
||||
>
|
||||
<div class="md:p-1">
|
||||
<!-- FIXME: if user resizes between desktop/mobile, highlight of page disappears (only shows on original size) -->
|
||||
<div>
|
||||
<!-- Mobile logo -->
|
||||
<div class="flex md:hidden justify-center items-center pb-8 pt-4">
|
||||
<a
|
||||
class="flex items-center justify-center bg-panel p-2 rounded-[20px] shadow-panel"
|
||||
href="/"
|
||||
class="px-3 relative w-full h-full mr-3 justify-center items-center bg-accent-background fill-accent-foreground rounded-xl md:flex hidden"
|
||||
>
|
||||
<div class="h-6 w-24 items-center flex justify-center relative">
|
||||
<Logo />
|
||||
{#if PUB_ENV === "nightly"}
|
||||
<div
|
||||
class="absolute -top-6 -left-10 px-2 py-1 w-fit bg-foreground-highlight text-accent-background rotate-[-10deg] rounded-xl"
|
||||
style="font-family: Comic Sans MS, sans-serif;"
|
||||
>
|
||||
NIGHTLY
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="h-14 bg-accent rounded-[14px] flex items-center justify-center"
|
||||
>
|
||||
<div class="w-28 h-5">
|
||||
<Logo />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<FancyMenu {links} {shouldGoBack} />
|
||||
<div class="h-16 px-4 flex items-center">
|
||||
<button onclick={theme.toggle} class="grid-cols-1 grid-rows-1 grid">
|
||||
<!-- {#if theme.dark}
|
||||
<div
|
||||
class="w-full h-full flex items-center justify-center row-start-1 col-start-1"
|
||||
>
|
||||
<MoonIcon />
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="w-full h-full flex items-center justify-center row-start-1 col-start-1"
|
||||
>
|
||||
<SunIcon />
|
||||
</div>
|
||||
{/if} -->
|
||||
{#if browser}
|
||||
{#if theme.dark}
|
||||
<div
|
||||
in:blur={{
|
||||
blurMultiplier: 1,
|
||||
duration,
|
||||
easing: quintOut,
|
||||
scale: {
|
||||
start: 0.5,
|
||||
end: 1,
|
||||
},
|
||||
}}
|
||||
out:blur={{
|
||||
blurMultiplier: 1,
|
||||
duration,
|
||||
easing: quintOut,
|
||||
scale: {
|
||||
start: 1,
|
||||
end: 1.5,
|
||||
},
|
||||
}}
|
||||
class="w-full h-full flex items-center justify-center row-start-1 col-start-1"
|
||||
>
|
||||
<MoonIcon class="w-8" />
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
in:blur={{
|
||||
blurMultiplier: 1,
|
||||
duration,
|
||||
easing: quintOut,
|
||||
scale: {
|
||||
start: 0.5,
|
||||
end: 1,
|
||||
},
|
||||
}}
|
||||
out:blur={{
|
||||
blurMultiplier: 1,
|
||||
duration,
|
||||
easing: quintOut,
|
||||
scale: {
|
||||
start: 1,
|
||||
end: 1.5,
|
||||
},
|
||||
}}
|
||||
class="w-full h-full flex items-center justify-center row-start-1 col-start-1"
|
||||
>
|
||||
<SunIcon class="w-8" />
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div
|
||||
class="w-full h-full flex items-center justify-center row-start-1 col-start-1 dynadark:hidden"
|
||||
>
|
||||
<SunIcon class="w-8" />
|
||||
</div>
|
||||
<div
|
||||
class="w-full h-full hidden items-center justify-center row-start-1 col-start-1 dynadark:flex"
|
||||
>
|
||||
<MoonIcon class="w-8" />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Desktop navbar -->
|
||||
<div class="hidden md:flex p-8 w-screen justify-center">
|
||||
<Navbar {items} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full max-w-screen-lg grid grid-cols-1 grid-rows-1 relative">
|
||||
{#key data.pathname}
|
||||
<div class="w-full">
|
||||
|
||||
<div class="grid grid-rows-1 grid-cols-1 h-full flex-grow">
|
||||
{#key page.url.pathname}
|
||||
<div
|
||||
class="row-start-1 col-start-1"
|
||||
in:fly={{
|
||||
x: goingLeft ? -window.innerWidth : window.innerWidth,
|
||||
duration,
|
||||
easing: quintOut,
|
||||
delay: 25,
|
||||
}}
|
||||
out:fly={{
|
||||
x: goingLeft ? window.innerWidth : -window.innerWidth,
|
||||
duration,
|
||||
easing: quintOut,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full"
|
||||
style={hover ? "will-change: opacity, blur, transform" : ""}
|
||||
in:blur={{
|
||||
class="flex flex-col h-full pb-32"
|
||||
in:fade={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
blurMultiplier: 12,
|
||||
x: {
|
||||
start: !$shouldGoBack ? 250 : -250,
|
||||
end: 0,
|
||||
},
|
||||
y: {
|
||||
start: 100,
|
||||
end: 0,
|
||||
},
|
||||
scale: {
|
||||
start: 0.75,
|
||||
end: 1,
|
||||
},
|
||||
origin: "top center",
|
||||
delay: $isMobile ? 0 : 100,
|
||||
}}
|
||||
out:blur={{
|
||||
out:fade={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
blurMultiplier: 12,
|
||||
x: {
|
||||
start: 0,
|
||||
end: !$shouldGoBack ? -250 : 250,
|
||||
},
|
||||
y: {
|
||||
start: 0,
|
||||
end: 100,
|
||||
},
|
||||
scale: {
|
||||
start: 1,
|
||||
end: 0.75,
|
||||
},
|
||||
origin: "top center",
|
||||
delay: $isMobile ? 0 : 200,
|
||||
}}
|
||||
>
|
||||
<div class="pb-20">
|
||||
{@render children()}
|
||||
</div>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<div class="fixed bottom-28 md:bottom-0 right-0 p-4 space-y-4 z-50">
|
||||
{#each toastList as { id, type, message, durations }}
|
||||
<Toast {id} {type} {message} {durations} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div
|
||||
class="hidden md:block w-full h-14 border-t border-separator fixed bottom-0 mt-12"
|
||||
>
|
||||
<Footer
|
||||
class="w-full h-full"
|
||||
items={{
|
||||
//"Privacy policy": "#",
|
||||
"Source code": GITHUB_URL_VERT,
|
||||
"Discord server": DISCORD_URL,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mobile navbar -->
|
||||
<div
|
||||
class="fixed md:hidden bottom-0 left-0 w-screen p-8 justify-center z-50"
|
||||
>
|
||||
<div class="flex flex-col justify-center items-center">
|
||||
<Navbar {items} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gradients placed here to prevent it overlapping in transitions -->
|
||||
{#if page.url.pathname === "/"}
|
||||
<div
|
||||
class="fixed -z-30 top-0 left-0 w-screen h-screen flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
<VertVBig
|
||||
class="fill-[--fg] opacity-10 dynadark:opacity-5 scale-[200%] md:scale-[80%]"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="gradient-bg"
|
||||
class="fixed top-0 left-0 w-screen h-screen -z-40 pointer-events-none"
|
||||
style="background: var(--bg-gradient);"
|
||||
></div>
|
||||
{:else if page.url.pathname === "/convert" && $showGradient}
|
||||
<div
|
||||
id="gradient-bg"
|
||||
class="fixed top-0 left-0 w-screen h-screen -z-40 pointer-events-none"
|
||||
style="background: var(--bg-gradient-{$gradientColor || 'pink'});"
|
||||
></div>
|
||||
{:else if page.url.pathname === "/convert" && files.files.length === 1 && files.files[0].blobUrl}
|
||||
<div
|
||||
class="fixed w-screen h-screen opacity-75 overflow-hidden top-0 left-0 -z-50 pointer-events-none grid grid-cols-1 grid-rows-1 scale-105"
|
||||
>
|
||||
<div
|
||||
class="w-full relative"
|
||||
transition:fade={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
class="object-cover w-full h-full blur-md"
|
||||
src={files.files[0].blobUrl}
|
||||
alt={files.files[0].name}
|
||||
/>
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
style="background: var(--bg-gradient-image);"
|
||||
></div>
|
||||
<!-- <div class="absolute bottom-0 left-0 w-full h-full">
|
||||
<ProgressiveBlur
|
||||
direction="bottom"
|
||||
endIntensity={256}
|
||||
iterations={8}
|
||||
fadeTo="var(--bg)"
|
||||
/>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
{:else if page.url.pathname === "/settings"}
|
||||
<div
|
||||
id="gradient-bg"
|
||||
class="fixed top-0 left-0 w-screen h-screen -z-40 pointer-events-none"
|
||||
style="background: var(--bg-gradient-blue);"
|
||||
></div>
|
||||
{:else if page.url.pathname === "/about"}
|
||||
<div
|
||||
id="gradient-bg"
|
||||
class="fixed top-0 left-0 w-screen h-screen -z-40 pointer-events-none"
|
||||
style="background: var(--bg-gradient-pink);"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.dragoverlay {
|
||||
animation: dragoverlay-animation 3s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes dragoverlay-animation {
|
||||
0% {
|
||||
@apply bg-accent-pink;
|
||||
}
|
||||
|
||||
25% {
|
||||
@apply bg-accent-blue;
|
||||
}
|
||||
|
||||
50% {
|
||||
@apply bg-accent-purple;
|
||||
}
|
||||
|
||||
75% {
|
||||
@apply bg-accent-red;
|
||||
}
|
||||
|
||||
100% {
|
||||
@apply bg-accent-pink;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,251 +1,87 @@
|
|||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
// this comment was written on 15/11/2024 at 16:01 GMT.
|
||||
// i bet to myself that i could complete this whole redesign implementation
|
||||
// by the time realmy got started on it. i guess we'll see how that goes
|
||||
//
|
||||
// ship fast n break things !!
|
||||
// -- nullptr
|
||||
|
||||
// that definitely happened
|
||||
// -- JovannMC
|
||||
|
||||
import Uploader from "$lib/components/functional/Uploader.svelte";
|
||||
import { converters } from "$lib/converters";
|
||||
import { log } from "$lib/logger";
|
||||
import { files } from "$lib/store/index.svelte";
|
||||
import { VertFile } from "$lib/types/file.svelte";
|
||||
import { Check } from "lucide-svelte";
|
||||
import jsmediatags from "jsmediatags";
|
||||
import type { TagType } from "jsmediatags/types/index.js";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
let ourFiles = $state<File[]>();
|
||||
|
||||
const runUpload = async () => {
|
||||
const newFilePromises = (ourFiles || []).map(async (f) => {
|
||||
return new Promise<(typeof files.files)[0] | void>(
|
||||
(resolve, reject) => {
|
||||
const from =
|
||||
"." + f.name.toLowerCase().split(".").slice(-1);
|
||||
const converter = converters.find((c) =>
|
||||
c.supportedFormats.includes(from.toLowerCase()),
|
||||
);
|
||||
if (!converter) resolve();
|
||||
const to =
|
||||
converter?.supportedFormats.find((f) => f !== from) ||
|
||||
converters[0].supportedFormats[0];
|
||||
log(
|
||||
["uploader", "converter"],
|
||||
`converting ${from} to ${to} using ${converter?.name || "... no converter??"}`,
|
||||
);
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const img = new Image();
|
||||
img.src = URL.createObjectURL(f);
|
||||
const maxSize = 512;
|
||||
img.onload = () => {
|
||||
const scale = Math.max(
|
||||
maxSize / img.width,
|
||||
maxSize / img.height,
|
||||
);
|
||||
canvas.width = img.width * scale;
|
||||
canvas.height = img.height * scale;
|
||||
ctx?.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
// get the blob
|
||||
canvas.toBlob(
|
||||
async (blob) => {
|
||||
resolve(
|
||||
new VertFile(
|
||||
f,
|
||||
to,
|
||||
converter,
|
||||
URL.createObjectURL(blob!),
|
||||
),
|
||||
);
|
||||
},
|
||||
"image/jpeg",
|
||||
0.75,
|
||||
);
|
||||
};
|
||||
|
||||
img.onerror = async () => {
|
||||
// resolve(new VertFile(f, to, converter));
|
||||
const reader = new FileReader();
|
||||
const file = new VertFile(f, to, converter);
|
||||
resolve(file);
|
||||
reader.onload = async (e) => {
|
||||
const tags = await new Promise<TagType>(
|
||||
(resolve, reject) => {
|
||||
jsmediatags.read(
|
||||
new Blob([
|
||||
new Uint8Array(
|
||||
e.target?.result as ArrayBuffer,
|
||||
),
|
||||
]),
|
||||
{
|
||||
onSuccess: (tag) => resolve(tag),
|
||||
onError: (error) => reject(error),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
const picture = tags.tags.picture;
|
||||
if (!picture) return;
|
||||
|
||||
const blob = new Blob(
|
||||
[new Uint8Array(picture.data)],
|
||||
{
|
||||
type: picture.format,
|
||||
},
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
file.blobUrl = url;
|
||||
};
|
||||
reader.readAsArrayBuffer(f);
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
let oldLen = files.files.length;
|
||||
files.files = [
|
||||
...files.files,
|
||||
...(await Promise.all(newFilePromises)).filter(
|
||||
(f) => typeof f !== "undefined",
|
||||
),
|
||||
];
|
||||
let newLen = files.files.length;
|
||||
log(["uploader"], `handled ${newLen - oldLen} files`);
|
||||
ourFiles = [];
|
||||
|
||||
if (files.files.length > 0) goto("/convert");
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>VERT.sh — Free, fast, and awesome file convert</title>
|
||||
<meta
|
||||
name="title"
|
||||
content="VERT.sh — Free, fast, and awesome file convert"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
content="With VERT you can convert images to PNG, JPG, WEBP, GIF, AVIF, and more. No ads, no tracking, open source, and all processing is done on your device."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta
|
||||
property="og:title"
|
||||
content="VERT.sh — Free, fast, and awesome file convert"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="With VERT you can convert images to PNG, JPG, WEBP, GIF, AVIF, and more. No ads, no tracking, open source, and all processing is done on your device."
|
||||
/>
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta
|
||||
property="twitter:title"
|
||||
content="VERT.sh — Free, fast, and awesome file convert"
|
||||
/>
|
||||
<meta
|
||||
property="twitter:description"
|
||||
content="With VERT you can convert images to PNG, JPG, WEBP, GIF, AVIF, and more. No ads, no tracking, open source, and all processing is done on your device."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
{#snippet sellingPoint(text: string)}
|
||||
<li
|
||||
class="grid items-center gap-4"
|
||||
style="grid-template-columns: 2rem auto"
|
||||
>
|
||||
<div
|
||||
class="h-8 w-8 bg-accent-background text-accent-foreground rounded-full flex items-center justify-center"
|
||||
>
|
||||
<Check />
|
||||
</div>
|
||||
<span class="text-lg">{text}</span>
|
||||
</li>
|
||||
{/snippet}
|
||||
|
||||
<div class="[@media(max-height:768px)]:block mt-10 picker-fly">
|
||||
<Uploader
|
||||
isMobile={data.isMobile || false}
|
||||
bind:files={ourFiles}
|
||||
onupload={runUpload}
|
||||
acceptedFormats={[
|
||||
...new Set(converters.flatMap((c) => c.supportedFormats)),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-20">
|
||||
<h1 class="text-3xl text-center font-display header-fly-in">
|
||||
Free, fast, and awesome file converting <span
|
||||
class="px-2 py-1 text-xl bg-accent-background text-accent-foreground rounded-lg"
|
||||
>BETA</span
|
||||
>
|
||||
</h1>
|
||||
<div class="flex justify-center mt-10">
|
||||
<div class="grid gap-4">
|
||||
<!-- {@render sellingPoint("Very fast, all processing done on device")}
|
||||
{@render sellingPoint("No ads, and open source")}
|
||||
{@render sellingPoint("Beautiful and straightforward UI")} -->
|
||||
{#each ["Very fast, all processing done on device", "No file or size limit", "No ads, and open source", "Beautiful and straightforward UI"] as text, i}
|
||||
<div class="fly-in" style="--delay: {i * 50}ms;">
|
||||
{@render sellingPoint(text)}
|
||||
<div
|
||||
class="w-screen px-2 md:px-8 h-full flex items-start justify-center overflow-hidden max-h-screen"
|
||||
>
|
||||
<div class="content w-screen flex items-center justify-center flex-grow">
|
||||
<div class="max-w-5xl w-full">
|
||||
<div
|
||||
class="flex items-center h-auto gap-12 md:gap-24 md:flex-row flex-col"
|
||||
>
|
||||
<div class="flex-grow w-full text-center md:text-left">
|
||||
<h1
|
||||
class="text-4xl px-12 md:p-0 md:text-6xl flex-wrap tracking-tight leading-tight md:leading-[72px] mb-4 md:mb-6"
|
||||
>
|
||||
The file converter you'll love.
|
||||
</h1>
|
||||
<p
|
||||
class="font-normal px-5 md:p-0 text-lg md:text-xl text-black text-muted dynadark:text-muted"
|
||||
>
|
||||
All processing is done on your device. No file size
|
||||
limit, no ads, and completely open source.
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="flex-grow w-11/12 md:w-full h-72">
|
||||
<Uploader class="w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* for this page specifically */
|
||||
:global(html, body) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@keyframes fly-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(50px);
|
||||
filter: blur(18px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
/* Centers content on most screen sizes, excluding mobile */
|
||||
@media screen and (min-width: 768px) {
|
||||
.content {
|
||||
padding-top: 10vh;
|
||||
padding-bottom: 10vh;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes picker-fly {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(48px);
|
||||
filter: blur(18px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
@media screen and (min-width: 768px) and (min-height: 576px) {
|
||||
.content {
|
||||
padding-top: 15vh;
|
||||
padding-bottom: 15vh;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes header-fly-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.9);
|
||||
filter: blur(18px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0);
|
||||
@media screen and (min-width: 768px) and (min-height: 720px) {
|
||||
.content {
|
||||
padding-top: 20vh;
|
||||
padding-bottom: 20vh;
|
||||
}
|
||||
}
|
||||
|
||||
.header-fly-in {
|
||||
animation: header-fly-in var(--transition) 750ms forwards;
|
||||
opacity: 0;
|
||||
@media screen and (min-width: 768px) and (min-height: 1080px) {
|
||||
.content {
|
||||
padding-top: 25vh;
|
||||
padding-bottom: 25vh;
|
||||
}
|
||||
}
|
||||
|
||||
.fly-in {
|
||||
animation: fly-in var(--transition) 750ms var(--delay) forwards;
|
||||
opacity: 0;
|
||||
@media screen and (min-width: 768px) and (min-height: 1440px) {
|
||||
.content {
|
||||
padding-top: 30vh;
|
||||
padding-bottom: 30vh;
|
||||
}
|
||||
}
|
||||
|
||||
.picker-fly {
|
||||
animation: picker-fly var(--transition) 750ms forwards;
|
||||
opacity: 0;
|
||||
@media screen and (min-width: 768px) and (min-height: 2160px) {
|
||||
.content {
|
||||
padding-top: 35vh;
|
||||
padding-bottom: 35vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,159 +1,132 @@
|
|||
<script lang="ts">
|
||||
import { error, log } from "$lib/logger";
|
||||
import * as About from "$lib/sections/about";
|
||||
import { InfoIcon } from "lucide-svelte";
|
||||
import { onMount } from "svelte";
|
||||
import avatarNullptr from "$lib/assets/avatars/nullptr.jpg";
|
||||
import avatarRealmy from "$lib/assets/avatars/realmy.jpg";
|
||||
import avatarJovannMC from "$lib/assets/avatars/jovannmc.jpg";
|
||||
import { GITHUB_API_URL } from "$lib/consts";
|
||||
import { addToast } from "$lib/store/ToastProvider";
|
||||
|
||||
const multiplier = 50;
|
||||
/* interface Donator {
|
||||
name: string;
|
||||
amount?: string | number;
|
||||
avatar: string;
|
||||
} */
|
||||
|
||||
const credits = [
|
||||
interface Contributor {
|
||||
name: string;
|
||||
github: string;
|
||||
avatar: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
// const donors: Donator[] = [];
|
||||
|
||||
const mainContribs: Contributor[] = [
|
||||
{
|
||||
name: "nullptr",
|
||||
github: "https://github.com/not-nullptr",
|
||||
role: "Lead developer; conversion backend, UI implementation",
|
||||
avatar: avatarNullptr,
|
||||
url: "https://nullp.tr",
|
||||
description: "conversion backend, UI, animations, promotion",
|
||||
},
|
||||
{
|
||||
name: "Realmy",
|
||||
github: "https://github.com/RealmyTheMan",
|
||||
role: "Lead designer; logo and branding, user interface design",
|
||||
avatar: avatarRealmy,
|
||||
url: "https://realmy.net",
|
||||
description: "idea, UI, branding, operational costs",
|
||||
},
|
||||
{
|
||||
name: "JovannMC",
|
||||
github: "https://github.com/JovannMC",
|
||||
role: "Developer; UI implementation",
|
||||
avatar: avatarJovannMC,
|
||||
},
|
||||
];
|
||||
|
||||
let ghContribs: Contributor[] = [];
|
||||
|
||||
onMount(async () => {
|
||||
// Check if the data is already in sessionStorage
|
||||
const cachedContribs = sessionStorage.getItem("ghContribs");
|
||||
if (cachedContribs) {
|
||||
ghContribs = JSON.parse(cachedContribs);
|
||||
log(["about"], "loaded GitHub contributors from cache");
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch GitHub contributors
|
||||
try {
|
||||
const response = await fetch(`${GITHUB_API_URL}/contributors`);
|
||||
if (!response.ok) {
|
||||
addToast("error", "Error fetching GitHub contributors");
|
||||
throw new Error(`HTTP error, status: ${response.status}`);
|
||||
}
|
||||
const allContribs = await response.json();
|
||||
|
||||
// Filter out main contributors
|
||||
const mainContribNames = mainContribs.map((contrib) =>
|
||||
contrib.github.split("/").pop(),
|
||||
);
|
||||
const filteredContribs = allContribs.filter(
|
||||
(contrib: { login: string }) =>
|
||||
!mainContribNames.includes(contrib.login),
|
||||
);
|
||||
|
||||
// Fetch and cache avatar images as Base64
|
||||
const fetchAvatar = async (url: string) => {
|
||||
const res = await fetch(url);
|
||||
const blob = await res.blob();
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
};
|
||||
|
||||
ghContribs = await Promise.all(
|
||||
filteredContribs.map(
|
||||
async (contrib: {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
html_url: string;
|
||||
}) => ({
|
||||
name: contrib.login,
|
||||
avatar: await fetchAvatar(contrib.avatar_url),
|
||||
github: contrib.html_url,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Cache the data in sessionStorage
|
||||
sessionStorage.setItem("ghContribs", JSON.stringify(ghContribs));
|
||||
} catch (e) {
|
||||
error(["general"], `Error fetching GitHub contributors: ${e}`);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>About VERT</title>
|
||||
<meta name="title" content="About VERT — VERT.sh" />
|
||||
<meta property="og:title" content="About VERT — VERT.sh" />
|
||||
<meta property="twitter:title" content="About VERT — VERT.sh" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="text-lg mx-auto max-w-screen-md">
|
||||
<h1
|
||||
class="font-display text-3xl text-transition"
|
||||
style="--delay: {0 * multiplier}ms"
|
||||
>
|
||||
⁉️ about VERT
|
||||
<div class="flex flex-col h-full items-center">
|
||||
<h1 class="hidden md:block text-[40px] tracking-tight leading-[72px] mb-6">
|
||||
<InfoIcon size="40" class="inline-block -mt-2 mr-2" />
|
||||
About
|
||||
</h1>
|
||||
<p class="mt-6 text-transition" style="--delay: {1 * multiplier}ms">
|
||||
You know what sucks? File converters! They're usually riddled with ads,
|
||||
and take an ungodly amount of time to complete. <b
|
||||
>So we made a better one!</b
|
||||
>
|
||||
</p>
|
||||
<p class="mt-4 text-transition" style="--delay: {2 * multiplier}ms">
|
||||
VERT is a file converter that's open source, completely ad free, and
|
||||
much much faster than you're used to. All the converting is done on your
|
||||
device, which makes it both private and very speedy. And it of course
|
||||
has a beautiful UI! ✨
|
||||
</p>
|
||||
|
||||
<h2
|
||||
class="font-display text-3xl mt-12 text-transition"
|
||||
style="--delay: {3 * multiplier}ms"
|
||||
<div
|
||||
class="w-full max-w-[1280px] flex flex-col md:flex-row gap-4 p-4 md:px-4 md:py-0"
|
||||
>
|
||||
🖼️ supported formats
|
||||
</h2>
|
||||
<p class="mt-6 text-transition" style="--delay: {4 * multiplier}ms">
|
||||
As of right now, VERT supports image and audio conversion of most
|
||||
popular formats. We'll add support for more formats in the future!
|
||||
</p>
|
||||
<!-- Why VERT? & Credits -->
|
||||
<div class="flex flex-col gap-4 flex-1">
|
||||
<About.Why />
|
||||
</div>
|
||||
|
||||
<h2
|
||||
class="font-display text-3xl mt-12 text-transition"
|
||||
style="--delay: {5 * multiplier}ms"
|
||||
>
|
||||
🔗 resources
|
||||
</h2>
|
||||
<ul class="list-disc list-inside mt-6">
|
||||
<li class="text-transition" style="--delay: {6 * multiplier}ms">
|
||||
<a
|
||||
href="https://github.com/not-nullptr/VERT"
|
||||
class="text-foreground-highlight hover:underline">Source code</a
|
||||
> (hosted on GitHub, licensed under AGPL-3.0)
|
||||
</li>
|
||||
|
||||
<li class="text-transition" style="--delay: {7 * multiplier}ms">
|
||||
<a
|
||||
href="https://discord.gg/8XXZ7TFFrK"
|
||||
class="text-foreground-highlight hover:underline"
|
||||
>Discord server</a
|
||||
> (for chit-chat, suggestions, and support)
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2
|
||||
class="font-display text-3xl mt-12 text-transition"
|
||||
style="--delay: {8 * multiplier}ms"
|
||||
>
|
||||
🎨 credits
|
||||
</h2>
|
||||
<div class="flex gap-4 mt-8">
|
||||
{#each credits as credit, i}
|
||||
<div class="hover:scale-105 w-56 transition-transform">
|
||||
<div
|
||||
class="border-2 credit-transition border-solid border-foreground-muted-alt rounded-2xl overflow-hidden"
|
||||
style="--delay: {i * 50 + multiplier * 9}ms;"
|
||||
>
|
||||
<a class="w-48" href={credit.url} target="_blank">
|
||||
<img src={credit.avatar} alt="{credit.name}'s avatar" />
|
||||
<div class="text-center py-4 px-2">
|
||||
<p class="font-display text-xl">{credit.name}</p>
|
||||
<p class="text-sm text-foreground-muted mt-2">
|
||||
{credit.description}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Resources & Donate to VERT -->
|
||||
<div class="flex flex-col gap-4 flex-1">
|
||||
<About.Resources />
|
||||
<About.Credits {mainContribs} {ghContribs} />
|
||||
<!-- <About.Donate /> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-foreground-muted text-base mt-10 text-transition"
|
||||
style="--delay: {10 * multiplier}ms"
|
||||
>
|
||||
(obviously inspired by <a
|
||||
href="https://cobalt.tools"
|
||||
class="hover:underline">cobalt.tools</a
|
||||
>)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes credit-transition {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(60px);
|
||||
filter: blur(18px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes text-transition {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(60px);
|
||||
filter: blur(18px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
.credit-transition {
|
||||
animation: credit-transition 750ms var(--transition) var(--delay)
|
||||
forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.text-transition {
|
||||
animation: text-transition 750ms var(--transition) var(--delay) forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,568 +1,199 @@
|
|||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { blur, duration, flip } from "$lib/animation";
|
||||
import ConversionPanel from "$lib/components/functional/ConversionPanel.svelte";
|
||||
import Dropdown from "$lib/components/functional/Dropdown.svelte";
|
||||
import ProgressiveBlur from "$lib/components/visual/effects/ProgressiveBlur.svelte";
|
||||
import Uploader from "$lib/components/functional/Uploader.svelte";
|
||||
import Panel from "$lib/components/visual/Panel.svelte";
|
||||
import ProgressBar from "$lib/components/visual/ProgressBar.svelte";
|
||||
import { converters } from "$lib/converters";
|
||||
import type { Converter } from "$lib/converters/converter.svelte";
|
||||
import { log } from "$lib/logger";
|
||||
import { files } from "$lib/store/index.svelte";
|
||||
import type { VertFile } from "$lib/types";
|
||||
import clsx from "clsx";
|
||||
import { ArrowRight, Disc2Icon, FileAudioIcon, XIcon } from "lucide-svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { quintOut } from "svelte/easing";
|
||||
import {
|
||||
fade,
|
||||
type EasingFunction,
|
||||
type TransitionConfig,
|
||||
} from "svelte/transition";
|
||||
files,
|
||||
gradientColor,
|
||||
showGradient,
|
||||
} from "$lib/store/index.svelte";
|
||||
import { VertFile } from "$lib/types";
|
||||
import {
|
||||
AudioLines,
|
||||
DownloadIcon,
|
||||
FileMusicIcon,
|
||||
FileQuestionIcon,
|
||||
FileVideo2,
|
||||
FilmIcon,
|
||||
ImageIcon,
|
||||
ImageOffIcon,
|
||||
RotateCwIcon,
|
||||
XIcon,
|
||||
} from "lucide-svelte";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
const reversedFiles = $derived(files.files.slice().reverse());
|
||||
|
||||
let finisheds = $state(
|
||||
Array.from({ length: files.files.length }, () => false),
|
||||
);
|
||||
|
||||
let processings = $state<boolean[]>([]);
|
||||
|
||||
const convertersRequired = $derived.by(() => {
|
||||
const required: Converter[] = [];
|
||||
for (let i = 0; i < files.files.length; i++) {
|
||||
const file = files.files[i];
|
||||
const converter = converters.find(
|
||||
(c) =>
|
||||
c.supportedFormats.includes(file.from.toLowerCase()) &&
|
||||
c.supportedFormats.includes(file.to.toLowerCase()),
|
||||
);
|
||||
if (!converter) throw new Error("No converter found");
|
||||
required.push(converter);
|
||||
$effect(() => {
|
||||
if (files.files.length === 1 && files.files[0].blobUrl) {
|
||||
showGradient.set(false);
|
||||
} else {
|
||||
showGradient.set(true);
|
||||
}
|
||||
|
||||
// Set gradient color depending on the file types
|
||||
// TODO: if more file types added, add a "fileType" property to the file object
|
||||
const allAudio = files.files.every(
|
||||
(file) => file.converter?.name === "ffmpeg",
|
||||
);
|
||||
const allImages = files.files.every(
|
||||
(file) =>
|
||||
file.converter?.name !== "ffmpeg" &&
|
||||
file.converter?.name !== "vertd",
|
||||
);
|
||||
const allVideos = files.files.every(
|
||||
(file) => file.converter?.name === "vertd",
|
||||
);
|
||||
|
||||
if (
|
||||
files.files.length === 0 ||
|
||||
(!allAudio && !allImages && !allVideos)
|
||||
) {
|
||||
gradientColor.set("");
|
||||
} else {
|
||||
gradientColor.set(allAudio ? "purple" : allVideos ? "red" : "blue");
|
||||
}
|
||||
return Array.from(new Set(required));
|
||||
});
|
||||
|
||||
const multipleConverters = $derived(convertersRequired.length > 1);
|
||||
|
||||
const noMultConverter = $derived(
|
||||
multipleConverters ? null : convertersRequired[0],
|
||||
);
|
||||
|
||||
const allConvertersReady = $derived(
|
||||
convertersRequired.every((c) => c.ready),
|
||||
);
|
||||
|
||||
let disabled = $derived(files.files.some((f) => !f.result));
|
||||
|
||||
onMount(() => {
|
||||
finisheds.forEach((_, i) => {
|
||||
const duration = 575 + i * 50 - 32;
|
||||
setTimeout(() => {
|
||||
finisheds[i] = true;
|
||||
}, duration);
|
||||
});
|
||||
});
|
||||
|
||||
const convertAll = async () => {
|
||||
const perf = performance.now();
|
||||
files.files.forEach((f) => (f.result = null));
|
||||
const promises: Promise<void>[] = [];
|
||||
for (let i = 0; i < files.files.length; i++) {
|
||||
promises.push(
|
||||
(async (i) => {
|
||||
window.plausible("Convert", {
|
||||
props: {
|
||||
"Convert from": files.files[i].from.toLowerCase(),
|
||||
"Convert to": files.files[i].to.toLowerCase(),
|
||||
Conversion: `${files.files[i].from.toLowerCase()} to ${files.files[i].to.toLowerCase()}`,
|
||||
},
|
||||
});
|
||||
await convert(files.files[i], i);
|
||||
})(i),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
const ms = performance.now() - perf;
|
||||
const seconds = (ms / 1000).toFixed(2);
|
||||
log(["converter"], `converted all files in ${seconds}s`);
|
||||
};
|
||||
|
||||
const convert = async (file: VertFile, index: number) => {
|
||||
file.progress = 0;
|
||||
processings[index] = true;
|
||||
await file.convert();
|
||||
processings[index] = false;
|
||||
};
|
||||
|
||||
const downloadAll = async () => {
|
||||
const dlFiles: any[] = [];
|
||||
for (let i = 0; i < files.files.length; i++) {
|
||||
const file = files.files[i];
|
||||
const result = file.result;
|
||||
if (!result) {
|
||||
console.error("No result found");
|
||||
continue;
|
||||
}
|
||||
dlFiles.push({
|
||||
name: file.file.name.replace(/\.[^/.]+$/, "") + file.to,
|
||||
lastModified: Date.now(),
|
||||
input: await result.file.arrayBuffer(),
|
||||
});
|
||||
}
|
||||
if (files.files.length === 0) return;
|
||||
if (files.files.length === 1) {
|
||||
// download the image only
|
||||
const blob = URL.createObjectURL(
|
||||
new Blob([dlFiles[0].input], {
|
||||
type: files.files[0].to.slice(1),
|
||||
}),
|
||||
);
|
||||
const a = document.createElement("a");
|
||||
a.href = blob;
|
||||
a.download = `VERT-Converted_${new Date().toISOString()}${
|
||||
files.files[0].to
|
||||
}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(blob);
|
||||
a.remove();
|
||||
return;
|
||||
}
|
||||
const { downloadZip } = await import("client-zip");
|
||||
const blob = await downloadZip(dlFiles, "converted.zip").blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `VERT-Converted_${new Date().toISOString()}.zip`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
};
|
||||
|
||||
const deleteAll = () => {
|
||||
files.files = [];
|
||||
goto("/");
|
||||
};
|
||||
|
||||
export const progBlur = (
|
||||
_: HTMLElement,
|
||||
config:
|
||||
| Partial<{
|
||||
duration: number;
|
||||
easing: EasingFunction;
|
||||
}>
|
||||
| undefined,
|
||||
dir: {
|
||||
direction: "in" | "out" | "both";
|
||||
},
|
||||
): TransitionConfig => {
|
||||
const prefersReducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
if (!config) config = {};
|
||||
if (!config.duration) config.duration = 300;
|
||||
if (!config.easing) config.easing = quintOut;
|
||||
return {
|
||||
duration: prefersReducedMotion ? 0 : config?.duration || 300,
|
||||
css: (t) => {
|
||||
return "--blur-amount: " + (dir.direction !== "in" ? t : 1 - t);
|
||||
},
|
||||
easing: config?.easing,
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Your Conversions</title>
|
||||
<meta name="title" content="Your Conversions — VERT.sh" />
|
||||
<meta property="og:title" content="Your Conversions — VERT.sh" />
|
||||
<meta property="twitter:title" content="Your Conversions — VERT.sh" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="grid grid-cols-1 grid-rows-1 w-full">
|
||||
{#if files.files.length === 0}
|
||||
<p class="text-foreground-muted col-start-1 row-start-1 text-center">
|
||||
No files uploaded. Head to the Upload tab to begin!
|
||||
</p>
|
||||
{:else}
|
||||
<div
|
||||
class="flex flex-col gap-4 w-full col-start-1 row-start-1"
|
||||
out:blur={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
blurMultiplier: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="w-full p-4 max-w-screen-lg border-solid flex-col border-2 rounded-2xl border-foreground-muted-alt flex flex-shrink-0"
|
||||
{#snippet fileItem(file: VertFile, index: number)}
|
||||
{@const isAudio = file.converter?.name === "ffmpeg"}
|
||||
{@const isVideo = file.converter?.name === "vertd"}
|
||||
<Panel class="p-5 flex flex-col min-w-0 gap-4 relative">
|
||||
<div class="flex-shrink-0 h-8 w-full flex items-center gap-2">
|
||||
{#if !file.converter}
|
||||
<FileQuestionIcon size="24" class="flex-shrink-0" />
|
||||
{:else if isAudio}
|
||||
<AudioLines size="24" class="flex-shrink-0" />
|
||||
{:else if isVideo}
|
||||
<FilmIcon size="24" class="flex-shrink-0" />
|
||||
{:else}
|
||||
<ImageIcon size="24" class="flex-shrink-0" />
|
||||
{/if}
|
||||
<div class="flex-grow overflow-hidden">
|
||||
{#if file.processing}
|
||||
<ProgressBar
|
||||
min={0}
|
||||
max={100}
|
||||
progress={file.converter?.reportsProgress
|
||||
? file.progress
|
||||
: null}
|
||||
/>
|
||||
{:else}
|
||||
<h2
|
||||
class="text-xl font-body overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{file.name}
|
||||
</h2>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="flex-shrink-0 w-8 rounded-full hover:bg-panel-alt h-full flex items-center justify-center"
|
||||
onclick={() =>
|
||||
(files.files = files.files.filter((_, i) => i !== index))}
|
||||
>
|
||||
<h2 class="font-bold text-xl mb-1">Options</h2>
|
||||
<div class="flex flex-col w-full gap-4 mt-2">
|
||||
<div class="flex flex-col gap-3 w-fit">
|
||||
<h3>Set all target formats</h3>
|
||||
<div class="grid grid-rows-1 grid-cols-1">
|
||||
{#if !multipleConverters && noMultConverter}
|
||||
<div
|
||||
transition:blur={{
|
||||
blurMultiplier: 8,
|
||||
duration,
|
||||
easing: quintOut,
|
||||
}}
|
||||
class="row-start-1 col-start-1 w-fit"
|
||||
>
|
||||
<Dropdown
|
||||
options={noMultConverter.supportedFormats}
|
||||
onselect={(o) => {
|
||||
// files.conversionTypes = Array.from(
|
||||
// { length: files.files.length },
|
||||
// () => o,
|
||||
// );
|
||||
|
||||
files.files.forEach((file) => {
|
||||
file.result = null;
|
||||
file.to = o;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="italic w-fit text-foreground-muted-alt flex items-center row-start-1 col-start-1"
|
||||
transition:blur={{
|
||||
blurMultiplier: 8,
|
||||
duration,
|
||||
easing: quintOut,
|
||||
}}
|
||||
>
|
||||
The listed files require different
|
||||
converters, so you can't set them in bulk.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-3 mt-4">
|
||||
<button
|
||||
onclick={convertAll}
|
||||
class={clsx("btn flex-grow", {
|
||||
"btn-highlight":
|
||||
disabled && !processings.some((p) => p),
|
||||
})}
|
||||
disabled={!allConvertersReady ||
|
||||
processings.some((p) => p)}
|
||||
>
|
||||
{#if allConvertersReady}
|
||||
Convert {files.files.length > 1 ? "All" : ""}
|
||||
{:else}
|
||||
Loading...
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={downloadAll}
|
||||
class={clsx("btn flex-grow", {
|
||||
"btn-highlight": !disabled,
|
||||
})}
|
||||
{disabled}
|
||||
>Download {files.files.length > 1 ? "All" : ""}</button
|
||||
>
|
||||
<button
|
||||
onclick={deleteAll}
|
||||
disabled={processings.some((p) => p)}
|
||||
class="btn flex-grow"
|
||||
>
|
||||
Delete All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full gap-4 grid md:grid-cols-2">
|
||||
{#each reversedFiles as file, i (file.id)}
|
||||
{@const converter = (() => {
|
||||
return converters.find((c) =>
|
||||
c.supportedFormats.includes(
|
||||
file.from.toLowerCase(),
|
||||
),
|
||||
);
|
||||
})()}
|
||||
<div
|
||||
class="relative"
|
||||
animate:flip={{ duration, easing: quintOut }}
|
||||
out:blur={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
blurMultiplier: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class={clsx(
|
||||
"flex relative flex-shrink-0 items-center w-full rounded-xl h-72",
|
||||
{
|
||||
"initial-fade": !finisheds[i],
|
||||
},
|
||||
)}
|
||||
style="--delay: {i * 50}ms; z-index: {files.files
|
||||
.length - i}; border: solid 2px {file.result
|
||||
? 'var(--accent-bg)'
|
||||
: 'var(--fg-muted-alt)'}; transition: border 1000ms ease; transition: filter {duration}ms var(--transition), transform {duration}ms var(--transition);"
|
||||
>
|
||||
<div
|
||||
class="flex h-full flex-col items-center w-full z-50 relative"
|
||||
>
|
||||
<div class="w-full flex-shrink-0">
|
||||
<div
|
||||
class={clsx(
|
||||
"py-3 dynadark:[--transparency:50%] [--transparency:25%] px-4 w-full flex transition-colors duration-300 flex-shrink text-left border-b-2 border-solid border-foreground-muted-alt rounded-tl-[9.5px] rounded-tr-[10px] overflow-hidden",
|
||||
{
|
||||
"text-accent-foreground":
|
||||
file.result,
|
||||
"text-foreground": !file.result,
|
||||
},
|
||||
)}
|
||||
style="background-color: color-mix(in srgb, var(--{file.result
|
||||
? 'accent-bg'
|
||||
: 'bg'}), transparent var(--transparency)); backdrop-filter: blur({data.isFirefox
|
||||
? 0
|
||||
: 18}px);"
|
||||
>
|
||||
<div
|
||||
class="w-full grid grid-cols-1 grid-rows-1"
|
||||
>
|
||||
{#if processings[files.files.length - i - 1]}
|
||||
<div
|
||||
class="w-full row-start-1 col-start-1 h-full flex items-center pr-4"
|
||||
transition:blur={{
|
||||
blurMultiplier: 6,
|
||||
duration,
|
||||
easing: quintOut,
|
||||
scale: {
|
||||
start: 0.9,
|
||||
end: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ProgressBar
|
||||
min={0}
|
||||
max={100}
|
||||
progress={file.converter
|
||||
?.reportsProgress
|
||||
? file.result
|
||||
? 100
|
||||
: file.progress
|
||||
: null}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<h3
|
||||
class="row-start-1 col-start-1 whitespace-nowrap overflow-hidden text-ellipsis font-medium"
|
||||
transition:blur={{
|
||||
blurMultiplier: 6,
|
||||
duration,
|
||||
easing: quintOut,
|
||||
scale: {
|
||||
start: 0.9,
|
||||
end: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{file.file.name}
|
||||
</h3>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={() => {
|
||||
// delete the file from the list
|
||||
files.files =
|
||||
files.files.filter(
|
||||
(f) => f !== file,
|
||||
);
|
||||
if (files.files.length === 0)
|
||||
goto("/");
|
||||
}}
|
||||
class="ml-2 mr-1 flex-shrink-0"
|
||||
>
|
||||
<XIcon size="24" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex gap-3 justify-normal flex-grow w-full h-full"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-end gap-3 w-full"
|
||||
>
|
||||
<div
|
||||
class="flex items-end gap-3 w-full h-full px-5"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center gap-3 w-full pb-4"
|
||||
>
|
||||
{#if converter && converter.supportedFormats.includes(file.from.toLowerCase())}
|
||||
<span>from</span>
|
||||
<span
|
||||
class="py-2 px-3 font-display bg-foreground text-background rounded-xl"
|
||||
>{file.from}</span
|
||||
>
|
||||
<span>to</span>
|
||||
<div class="inline-flex">
|
||||
<Dropdown
|
||||
options={converter.supportedFormats}
|
||||
bind:selected={files
|
||||
.files[
|
||||
files.files
|
||||
.length -
|
||||
i -
|
||||
1
|
||||
].to}
|
||||
onselect={() => {
|
||||
file.result =
|
||||
null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<span
|
||||
class="py-2 px-3 font-display bg-foreground-failure text-white rounded-xl"
|
||||
>{file.from}</span
|
||||
>
|
||||
|
||||
<span
|
||||
class="text-foreground-failure"
|
||||
>
|
||||
is not supported!
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div
|
||||
class="hidden lg:flex gap-4 w-full"
|
||||
>
|
||||
<button
|
||||
class="btn flex-grow flex-shrink-0"
|
||||
onclick={() => convert(file)}
|
||||
>
|
||||
Convert
|
||||
</button>
|
||||
<button
|
||||
class="btn flex-grow flex-shrink-0"
|
||||
disabled={!file.result}
|
||||
onclick={file.download}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if converter && converter.supportedFormats.includes(file.from.toLowerCase())}
|
||||
<!-- god knows why, but setting opacity > 0.98 causes a z-ordering issue in firefox ??? -->
|
||||
<div
|
||||
class="absolute top-[0px] -z-50 left-0 w-full h-full opacity-[0.98] rounded-xl overflow-hidden"
|
||||
>
|
||||
{#if file.blobUrl}
|
||||
<div
|
||||
class="bg-cover bg-center w-full h-full"
|
||||
style="background-image: url({file.blobUrl})"
|
||||
in:blur={{
|
||||
blurMultiplier: 24,
|
||||
scale: {
|
||||
start: 1.1,
|
||||
end: 1,
|
||||
},
|
||||
duration,
|
||||
easing: quintOut,
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
class="absolute left-0 top-0 pt-[50px] h-full w-full"
|
||||
transition:progBlur={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
}}
|
||||
>
|
||||
<ProgressiveBlur
|
||||
direction="bottom"
|
||||
endIntensity={64}
|
||||
iterations={8}
|
||||
fadeTo="var(--bg-transparent)"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="w-full h-full flex items-center justify-center"
|
||||
>
|
||||
<FileAudioIcon
|
||||
size="96"
|
||||
strokeWidth="1.5"
|
||||
color="var(--fg)"
|
||||
opacity="0.9"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<XIcon size="24" class="text-muted" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !file.converter}
|
||||
<div
|
||||
class="h-full flex flex-col text-center justify-center text-failure"
|
||||
>
|
||||
<p class="font-body font-bold">We can't convert this file.</p>
|
||||
<p class="font-normal">
|
||||
Only image, video, and audio files are supported
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-row justify-between">
|
||||
<div
|
||||
class="flex gap-4 w-full h-[152px] overflow-hidden relative"
|
||||
>
|
||||
<div class="w-1/2 h-full overflow-hidden rounded-xl">
|
||||
{#if file.blobUrl}
|
||||
<img
|
||||
class="object-cover w-full h-full"
|
||||
src={file.blobUrl}
|
||||
alt={file.name}
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="w-full h-full flex items-center justify-center text-black"
|
||||
style="background: var({isAudio
|
||||
? '--bg-gradient-purple-alt'
|
||||
: isVideo
|
||||
? '--bg-gradient-red-alt'
|
||||
: '--bg-gradient-blue-alt'})"
|
||||
>
|
||||
{#if isAudio}
|
||||
<FileMusicIcon size="56" />
|
||||
{:else if isVideo}
|
||||
<FileVideo2 size="56" />
|
||||
{:else}
|
||||
<ImageOffIcon size="56" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-16 right-0 mr-4 pl-2 h-[calc(100%-83px)] w-[calc(50%-38px)] pr-4 pb-1 flex items-center justify-center aspect-square"
|
||||
>
|
||||
<div
|
||||
class="w-[122px] h-fit flex flex-col gap-2 items-center justify-center"
|
||||
>
|
||||
<Dropdown
|
||||
options={file.converter?.supportedFormats || []}
|
||||
bind:selected={file.to}
|
||||
onselect={() => file.result && (file.result = null)}
|
||||
/>
|
||||
<div class="w-full flex items-center justify-between">
|
||||
<button
|
||||
class="btn p-0 w-14 h-14 text-black {isAudio
|
||||
? 'bg-accent-purple'
|
||||
: isVideo
|
||||
? 'bg-accent-red'
|
||||
: 'bg-accent-blue'}"
|
||||
disabled={!files.ready}
|
||||
onclick={file.convert}
|
||||
>
|
||||
<RotateCwIcon size="24" />
|
||||
</button>
|
||||
<button
|
||||
class="btn p-0 w-14 h-14"
|
||||
onclick={file.download}
|
||||
disabled={!file.result}
|
||||
>
|
||||
<DownloadIcon size="24" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Panel>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-col justify-center items-center gap-8 -mt-4 px-4 md:p-0">
|
||||
<div class="max-w-[778px] w-full">
|
||||
<ConversionPanel />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-full max-w-[778px] grid grid-cols-1 md:grid-cols-2 auto-rows-[240px] gap-4 md:p-0"
|
||||
>
|
||||
{#each files.files as file, i (file.id)}
|
||||
{#if files.files.length >= 2 && i === 1}
|
||||
<Uploader
|
||||
class="w-full h-full col-start-1 row-start-1 md:col-start-2"
|
||||
/>
|
||||
{/if}
|
||||
{@render fileItem(file, i)}
|
||||
{#if files.files.length < 2}
|
||||
<Uploader class="w-full h-full" />
|
||||
{/if}
|
||||
{/each}
|
||||
{#if files.files.length === 0}
|
||||
<Uploader class="w-full h-full col-span-2" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes initial-transition {
|
||||
0% {
|
||||
transform: translateY(50px);
|
||||
filter: blur(16px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.initial-fade {
|
||||
animation: initial-transition 600ms var(--delay) var(--transition);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.initial-fade.finished {
|
||||
animation: none;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
@keyframes processing {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
filter: blur(0px);
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
filter: blur(4px);
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
filter: blur(0px);
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.processing {
|
||||
animation: processing 2000ms infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
transition:
|
||||
filter 500ms var(--transition),
|
||||
transform 500ms var(--transition);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { log } from "$lib/logger";
|
||||
import * as Settings from "$lib/sections/settings/index.svelte";
|
||||
import { addToast } from "$lib/store/ToastProvider";
|
||||
import { SettingsIcon } from "lucide-svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let settings = $state(Settings.Settings.instance.settings);
|
||||
|
||||
let isInitial = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
if (isInitial) {
|
||||
isInitial = false;
|
||||
return;
|
||||
}
|
||||
settings;
|
||||
const savedSettings = localStorage.getItem("settings");
|
||||
if (savedSettings) {
|
||||
const parsedSettings = JSON.parse(savedSettings);
|
||||
if (parsedSettings === settings) return;
|
||||
}
|
||||
|
||||
log(["settings"], "saving settings");
|
||||
try {
|
||||
Settings.Settings.instance.settings = settings;
|
||||
Settings.Settings.instance.save();
|
||||
} catch (error) {
|
||||
log(["settings", "error"], `failed to save settings: ${error}`);
|
||||
addToast("error", "Failed to save settings!");
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const savedSettings = localStorage.getItem("settings");
|
||||
if (savedSettings) {
|
||||
const parsedSettings = JSON.parse(savedSettings);
|
||||
Settings.Settings.instance.settings = {
|
||||
...Settings.Settings.instance.settings,
|
||||
...parsedSettings,
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full items-center">
|
||||
<h1 class="hidden md:block text-[40px] tracking-tight leading-[72px] mb-6">
|
||||
<SettingsIcon size="40" class="inline-block -mt-2 mr-2" />
|
||||
Settings
|
||||
</h1>
|
||||
|
||||
<div
|
||||
class="w-full max-w-[1280px] flex flex-col md:flex-row gap-4 p-4 md:px-4 md:py-0"
|
||||
>
|
||||
<div class="flex flex-col gap-4 flex-1">
|
||||
<Settings.Conversion {settings} />
|
||||
<Settings.Vertd {settings} />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 flex-1">
|
||||
<Settings.Appearance />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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."))})();
|
||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
|
@ -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} */
|
||||
|
|
|
|||
|
|
@ -5,15 +5,40 @@ export default {
|
|||
content: ["./src/**/*.{html,js,svelte,ts}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "var(--bg)",
|
||||
backgroundColor: {
|
||||
panel: "var(--bg-panel)",
|
||||
"panel-highlight": "var(--bg-panel-highlight)",
|
||||
separator: "var(--bg-separator)",
|
||||
button: "var(--bg-button)",
|
||||
"panel-alt": "var(--bg-button)",
|
||||
badge: "var(--bg-badge)",
|
||||
},
|
||||
borderColor: {
|
||||
separator: "var(--bg-separator)",
|
||||
button: "var(--bg-button)",
|
||||
},
|
||||
textColor: {
|
||||
foreground: "var(--fg)",
|
||||
"foreground-muted": "var(--fg-muted)",
|
||||
"foreground-muted-alt": "var(--fg-muted-alt)",
|
||||
"foreground-failure": "var(--fg-failure)",
|
||||
"foreground-highlight": "var(--fg-highlight)",
|
||||
"accent-background": "var(--accent-bg)",
|
||||
"accent-foreground": "var(--accent-fg)",
|
||||
muted: "var(--fg-muted)",
|
||||
accent: "var(--fg-accent)",
|
||||
failure: "var(--fg-failure)",
|
||||
"on-accent": "var(--fg-on-accent)",
|
||||
"on-badge": "var(--fg-on-badge)",
|
||||
},
|
||||
colors: {
|
||||
accent: "var(--accent)",
|
||||
"accent-alt": "var(--accent-alt)",
|
||||
"accent-pink": "var(--accent-pink)",
|
||||
"accent-pink-alt": "var(--accent-pink-alt)",
|
||||
"accent-red": "var(--accent-red)",
|
||||
"accent-red-alt": "var(--accent-red-alt)",
|
||||
"accent-purple-alt": "var(--accent-purple-alt)",
|
||||
"accent-purple": "var(--accent-purple)",
|
||||
"accent-blue": "var(--accent-blue)",
|
||||
"accent-blue-alt": "var(--accent-blue-alt)",
|
||||
},
|
||||
boxShadow: {
|
||||
panel: "var(--shadow-panel)",
|
||||
},
|
||||
fontFamily: {
|
||||
display: "var(--font-display)",
|
||||
|
|
@ -22,6 +47,9 @@ export default {
|
|||
blur: {
|
||||
xs: "2px",
|
||||
},
|
||||
borderRadius: {
|
||||
"2.5xl": "1.25rem",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||