feat: converter page

This commit is contained in:
not-nullptr 2024-11-15 23:19:15 +00:00
parent 0370ff3abf
commit 36c6b19483
14 changed files with 542 additions and 54 deletions

View File

@ -31,16 +31,36 @@
}
@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%);
// general
--accent: hsl(303, 73%, 81%);
// foregrounds
--fg: hsl(0, 0%, 0%);
--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;
--fg-on-accent: hsl(0, 0%, 0%);
// backgrounds
--bg: hsl(0, 0%, 100%);
--bg-gradient: linear-gradient(
to bottom left,
hsla(235, 100%, 50%, 0.3),
hsla(235, 100%, 50%, 0) 75%
),
linear-gradient(
to bottom right,
hsla(353, 100%, 50%, 0.4),
hsla(353, 100%, 50%, 0) 50%
),
linear-gradient(
to bottom,
hsla(287, 100%, 50%, 0.2),
hsla(287, 100%, 50%, 0)
);
--bg-panel: hsl(0, 0%, 100%);
--bg-panel-alt: hsl(0, 0%, 95%);
--bg-panel-accented: color-mix(in srgb, var(--accent) 35%, transparent);
--bg-separator: hsl(0, 0%, 88%);
--bg-button: var(--bg-panel-accented);
}
@mixin dark {
@ -69,10 +89,12 @@
hsla(353, 100%, 50%, 0.07),
hsla(353, 100%, 50%, 0) 50%
);
color-scheme: dark;
--bg-panel: hsl(225, 4%, 18%);
--bg-panel-accented: color-mix(in srgb, var(--accent) 12%, transparent);
--bg-panel-alt: hsl(220, 5%, 16%);
--bg-separator: hsl(214, 4%, 32%);
color-scheme: dark;
}
@media (prefers-color-scheme: dark) {
@ -98,7 +120,6 @@
body {
@apply text-foreground font-body font-semibold overflow-x-hidden;
width: 100vw;
background: var(--bg-gradient);
background-color: var(--bg);
background-size: 100vw 100vh;
}
@ -109,15 +130,15 @@ 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 py-3 rounded-full focus:!outline-none hover:scale-105 duration-200 active:scale-95 disabled:opacity-50 disabled:pointer-events-none;
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,

View File

@ -0,0 +1,36 @@
<script lang="ts">
import { FolderArchiveIcon, RotateCw } from "lucide-svelte";
import Panel from "../visual/Panel.svelte";
import { files } from "$lib/store/index.svelte";
import Dropdown from "./Dropdown.svelte";
</script>
<Panel class="w-full h-20 flex items-center justify-between">
<div class="flex items-center gap-2.5">
<button
onclick={() => files.convertAll()}
class="btn highlight flex gap-3"
disabled={!files.ready}
>
<RotateCw size="24" />
<p>Convert all</p>
</button>
<button
class="btn flex gap-3"
disabled={!files.ready || !files.results}
onclick={() => files.downloadAll()}
>
<FolderArchiveIcon size="24" />
<p>Download all as .zip</p>
</button>
</div>
<div class="flex items-center gap-2">
{#if files.requiredConverters.length === 1}
<p class="whitespace-nowrap">Set all to</p>
<Dropdown
onselect={(r) => files.files.forEach((f) => (f.to = r))}
options={files.files[0]?.converter?.supportedFormats || []}
/>
{/if}
</div>
</Panel>

View File

@ -11,7 +11,11 @@
onselect?: (option: string) => void;
};
let { options, selected = $bindable(), onselect }: Props = $props();
let {
options,
selected = $bindable(options[0]),
onselect,
}: Props = $props();
let open = $state(false);
let hover = $state(false);
@ -31,10 +35,6 @@
toggle();
};
$effect(() => {
selected = selected || options[0];
});
onMount(() => {
const click = (e: MouseEvent) => {
if (dropdown && !dropdown.contains(e.target as Node)) {
@ -49,15 +49,13 @@
<div class="relative w-full min-w-fit" 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 min-w-fit justify-between overflow-hidden relative cursor-pointer px-3 bg-button flex items-center rounded-full p-2 focus:!outline-none"
onclick={toggle}
onmouseenter={() => (hover = true)}
onmouseleave={() => (hover = false)}
>
<!-- <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 text-left flex-grow-0">
{#key selected}
<p
in:blur={{
@ -123,11 +121,11 @@
},
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"
>
{#each options as option}
<button
class="w-full p-2 px-4 text-left hover:bg-foreground-muted-alt brightness-125"
class="w-full p-2 px-4 text-left hover:bg-panel"
onclick={() => select(option)}
>
{option}

View File

@ -6,6 +6,8 @@
import { page } from "$app/stores";
import { MoonIcon, SunIcon } from "lucide-svelte";
import { theme } from "$lib/store/index.svelte";
import { blur, duration } from "$lib/animation";
import { quintOut } from "svelte/easing";
type Props = {
items: {
@ -19,6 +21,49 @@
let { items }: Props = $props();
</script>
{#snippet link(item: (typeof items)[0])}
{@const Icon = item.icon}
<a
href={item.url}
aria-label={item.name}
class={clsx(
"px-4 h-full rounded-xl flex items-center justify-center gap-3 overflow-hidden",
{
"bg-panel-accented": item.activeMatch($page.url.pathname),
},
)}
>
<Icon />
<div class="grid grid-rows-1 grid-cols-1">
{#key item.name}
<p
class="row-start-1 col-start-1"
in:blur={{
blurMultiplier: 6,
duration,
easing: quintOut,
y: {
start: -48,
end: 0,
},
}}
out:blur={{
blurMultiplier: 6,
duration,
easing: quintOut,
y: {
start: 0,
end: 48,
},
}}
>
{item.name}
</p>
{/key}
</div>
</a>
{/snippet}
<Panel class="w-fit h-20 flex items-center gap-3">
<div
class="w-32 h-full bg-accent rounded-xl flex items-center justify-center"
@ -28,20 +73,7 @@
</div>
</div>
{#each items as item (item.url)}
{@const Icon = item.icon}
<a
href={item.url}
aria-label={item.name}
class={clsx(
"w-32 h-full rounded-xl flex items-center justify-center gap-3",
{
"bg-panel-accented": item.activeMatch($page.url.pathname),
},
)}
>
<Icon />
<p>{item.name}</p>
</a>
{@render link(item)}
{/each}
<div class="w-0.5 bg-separator h-full"></div>
<button

View File

@ -1,16 +1,77 @@
<script lang="ts">
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";
type Props = {
class?: string;
};
const { class: classList }: Props = $props();
let dropping = $state(false);
let uploaderButton = $state<HTMLButtonElement>();
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");
};
const uploadFiles = () => {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.accept = converters
.map((c) => c.supportedFormats.join(","))
.join(",");
input.onchange = (e) => {
const oldLength = files.files.length;
files.add(input.files);
if (oldLength !== files.files.length) goto("/convert");
};
input.click();
};
onMount(() => {
const handler = (e: Event) => {
e.preventDefault();
return false;
};
uploaderButton?.addEventListener("dragover", handler);
uploaderButton?.addEventListener("dragenter", handler);
uploaderButton?.addEventListener("dragleave", handler);
uploaderButton?.addEventListener("drop", handler);
return () => {
uploaderButton?.removeEventListener("dragover", handler);
uploaderButton?.removeEventListener("dragenter", handler);
uploaderButton?.removeEventListener("dragleave", handler);
uploaderButton?.removeEventListener("drop", handler);
};
});
</script>
<button class={classList}>
<Panel class="flex justify-center items-center w-full h-full flex-col">
<button
ondragenter={() => (dropping = true)}
ondragleave={() => (dropping = false)}
ondrop={dropFiles}
onclick={uploadFiles}
bind:this={uploaderButton}
class={clsx(`hover:scale-105 active:scale-100 duration-200 ${classList}`, {
"scale-105": dropping,
})}
>
<Panel
class="flex justify-center items-center w-full h-full flex-col pointer-events-none"
>
<div
class="w-16 h-16 bg-accent rounded-full flex items-center justify-center p-4"
>

View File

@ -11,7 +11,10 @@
const links = $derived(Object.entries(items));
</script>
<footer class={classList}>
<footer
class={classList}
style="background: linear-gradient(to bottom, transparent, var(--bg) 100%)"
>
<div
class="w-full h-full flex items-center justify-center text-muted gap-3"
>

View File

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

View File

@ -0,0 +1,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

View File

@ -1,10 +1,166 @@
import { browser } from "$app/environment";
import { converters } from "$lib/converters";
import { log } from "$lib/logger";
import { VertFile } from "$lib/types";
import JSCookie from "js-cookie";
import jsmediatags from "jsmediatags";
import type { TagType } from "jsmediatags/types";
class Files {
public files = $state<VertFile[]>([]);
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),
);
public results = $derived(
this.files.length === 0 ? false : this.files.every((f) => f.result),
);
private _addThumbnail = async (file: VertFile) => {
try {
if (
converters
.find((c) => c.name === "ffmpeg")
?.supportedFormats?.includes(file.from.toLowerCase())
) {
// try to get the thumbnail from the audio via jsmmediatags
const tags = await new Promise<TagType>((resolve, reject) => {
jsmediatags.read(file.file, {
onSuccess: (tag) => resolve(tag),
onError: (error) => reject(error),
});
});
if (tags.tags.picture) {
const blob = new Blob(
[new Uint8Array(tags.tags.picture.data)],
{
type: tags.tags.picture.format,
},
);
const url = URL.createObjectURL(blob);
file.blobUrl = url;
}
} else {
const img = new Image();
img.src = URL.createObjectURL(file.file);
await new Promise((resolve) => {
img.onload = resolve;
});
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return;
const maxSize = 180;
const scale = Math.max(
maxSize / img.width,
maxSize / img.height,
);
canvas.width = img.width * scale;
canvas.height = img.height * scale;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const url = canvas.toDataURL();
file.blobUrl = url;
canvas.remove();
}
} catch (error) {
console.error(error);
}
};
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}`);
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;
if (files.files.length === 1) {
return await files.files[0].download();
}
// 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 a = document.createElement("a");
a.href = url;
a.download = `VERT-Converted_${new Date().toISOString()}.zip`;
a.click();
URL.revokeObjectURL(url);
a.remove();
}
}
class Theme {

View File

@ -18,6 +18,8 @@ export class VertFile {
public blobUrl = $state<string>();
public processing = $state(false);
public converter: Converter | null = null;
constructor(
@ -37,7 +39,9 @@ export class VertFile {
if (!this.converter) throw new Error("No converter found");
this.result = null;
this.progress = 0;
this.processing = true;
const res = await this.converter.convert(this, this.to);
this.processing = false;
this.result = res;
return res;
}

View File

@ -16,7 +16,7 @@
import {
InfoIcon,
MoonIcon,
RefreshCwIcon,
RotateCw,
SettingsIcon,
SunIcon,
UploadIcon,
@ -27,6 +27,8 @@
import Panel from "$lib/components/visual/Panel.svelte";
import Navbar from "$lib/components/functional/Navbar.svelte";
import Footer from "$lib/components/visual/Footer.svelte";
import { page } from "$app/stores";
import ConversionPanel from "$lib/components/functional/ConversionPanel.svelte";
let { children, data } = $props();
let shouldGoBack = writable(false);
@ -54,7 +56,7 @@
: `Convert`,
url: "/convert",
activeMatch: (pathname) => pathname === "/convert",
icon: RefreshCwIcon,
icon: RotateCw,
},
{
name: "Settings",
@ -103,15 +105,27 @@
></script>{/if}
</svelte:head>
<div class="fixed top-8 left-0 w-full flex justify-center">
<div class="absolute top-8 left-0 w-full flex justify-center">
<div class="flex flex-col gap-4">
<Navbar {items} />
{#if items
.find((i) => i.url === "/convert")
?.activeMatch($page.url.pathname)}
<ConversionPanel />
{/if}
</div>
</div>
<div class="w-screen h-screen">
<div
class="fixed top-0 left-0 w-screen h-screen -z-50 pointer-events-none"
style="background: var(--bg-gradient);"
></div>
<div class="min-h-screen">
{@render children()}
</div>
<div class="-mt-14 w-full h-14">
<div class="-mt-14 -z-50 w-full h-14 border-t border-separator">
<Footer
class="w-full h-full"
items={{

View File

@ -11,12 +11,12 @@
</script>
<div
class="fixed -z-50 top-0 left-0 w-full h-full flex items-center justify-center overflow-hidden"
class="absolute -z-50 top-0 left-0 w-full h-full flex items-center justify-center overflow-hidden"
>
<VertVBig class="fill-[--fg] opacity-50" />
</div>
<div class="w-full h-full flex items-center justify-center">
<div class="w-screen h-screen flex items-center justify-center">
<div class="max-w-5xl w-full">
<div class="flex items-center h-[266px] gap-24">
<div class="flex-grow w-full">

View File

@ -0,0 +1,99 @@
<script lang="ts">
import Dropdown from "$lib/components/functional/Dropdown.svelte";
import Panel from "$lib/components/visual/Panel.svelte";
import ProgressBar from "$lib/components/visual/ProgressBar.svelte";
import { files } from "$lib/store/index.svelte";
import { VertFile } from "$lib/types";
import {
Disc2Icon,
DownloadIcon,
ImageIcon,
RotateCwIcon,
XIcon,
} from "lucide-svelte";
</script>
{#snippet fileItem(file: VertFile, index: number)}
{@const isAudio = file.converter?.name === "ffmpeg" || false}
<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 isAudio}
<Disc2Icon size="24" class="flex-shrink-0" />
{:else}
<ImageIcon size="24" class="flex-shrink-0" />
{/if}
<div class="flex-grow">
{#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))}
>
<XIcon size="24" class="text-muted" />
</button>
</div>
<div class="flex gap-4 w-full h-full overflow-hidden relative">
<div class="w-1/2 h-full overflow-hidden rounded-xl">
<img
class="object-cover w-full h-full"
src={file.blobUrl}
alt={file.name}
/>
</div>
</div>
<div
class="absolute top-16 right-0 mr-4 pl-2 h-[calc(100%-83px)] w-[calc(50%-32px)] pr-4 pb-5"
>
<div
class="w-full h-full flex flex-col gap-2 items-center justify-center"
>
<Dropdown
options={file.converter?.supportedFormats || []}
bind:selected={file.to}
/>
<div class="w-full flex items-center justify-around">
<button
class="btn p-0 w-14 h-14"
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>
</Panel>
{/snippet}
<div class="w-full h-full flex justify-center pt-60 pb-20">
<div
class="w-[794px] grid"
style="grid-template-columns: repeat(2, 1fr); grid-auto-rows: 240px; gap: 16px"
>
{#each files.files as file, i (file.id)}
{@render fileItem(file, i)}
{/each}
</div>
</div>

View File

@ -9,6 +9,11 @@ export default {
panel: "var(--bg-panel)",
"panel-accented": "var(--bg-panel-accented)",
separator: "var(--bg-separator)",
button: "var(--bg-button)",
"panel-alt": "var(--bg-panel-alt)",
},
borderColor: {
separator: "var(--bg-separator)",
},
textColor: {
foreground: "var(--fg)",