diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fd73510 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +node_modules/ +.git/ +build/ +dist/ +.svelte-kit/ +.output/ +.vercel/ +.vscode/ + +LICENSE +README.md +Dockerfile +docker-compose.yml +.npmrc +.prettier* +.gitignore +.env.* +.env + +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/.npmrc b/.npmrc deleted file mode 100644 index b6f27f1..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -engine-strict=true diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..f791495 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "css.customData": [".vscode/tailwind.json"] +} diff --git a/.vscode/tailwind.json b/.vscode/tailwind.json new file mode 100644 index 0000000..a45bccc --- /dev/null +++ b/.vscode/tailwind.json @@ -0,0 +1,55 @@ +{ + "version": 1.1, + "atDirectives": [ + { + "name": "@tailwind", + "description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#tailwind" + } + ] + }, + { + "name": "@apply", + "description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#apply" + } + ] + }, + { + "name": "@responsive", + "description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#responsive" + } + ] + }, + { + "name": "@screen", + "description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#screen" + } + ] + }, + { + "name": "@variants", + "description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#variants" + } + ] + } + ] +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..48ae773 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM oven/bun AS builder + +WORKDIR /app + +ARG PUB_HOSTNAME +ARG PUB_PLAUSIBLE_URL + +ENV PUB_HOSTNAME=${PUB_HOSTNAME} +ENV PUB_PLAUSIBLE_URL=${PUB_PLAUSIBLE_URL} + +COPY package.json ./ + +RUN bun install + +COPY . ./ + +RUN bun run build + +FROM oven/bun:alpine + +WORKDIR /app + +COPY --from=builder /app/build ./ + +EXPOSE 3000 + +CMD [ "bun", "run", "start" ] \ No newline at end of file diff --git a/README.md b/README.md index 5f5c010..7972aa8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# VERT +![VERT](static/banner.png) ![Image of VERT on an old computer monitor](/src/lib/assets/VERT_Feature.webp) @@ -6,10 +6,49 @@ VERT is a file conversion utility for the web that uses WebAssembly to convert f VERT is built with Svelte & TypeScript (using [bun](https://bun.sh)). -## Development +## Features -- Clone the project - `git clone https://github.com/not-nullptr/VERT.git` -- Use [bun](https://bun.sh) to install the dependencies - `bun install` -- Copy the contents of `.env.example` into `.env` and make any changes (if wanted) -- Start a dev environment & make your changes - `bun run dev` -- Build and preview for production - `bun run build` & `bun run preview` +- Convert files directly on your device using WebAssembly +- No file size limits +- Supports multiple file formats +- User-friendly interface built with Svelte + +## Getting Started + +### Prerequisites + +Make sure you have the following installed: + +- [Bun](https://bun.sh/) + +### Installation +```sh +# Clone the repository +git clone https://github.com/yourusername/vert.git +cd vert +# Install dependencies +bun i +``` + +### Running Locally + +To run the project locally, run `bun dev`. + +This will start a development server. Open your browser and navigate to `http://localhost:5173` to see the application. + +### Building for Production + +Before building for production, make sure you create a `.env` file in the root of the project with the following content: + +```sh +PUB_HOSTNAME=vert.sh # change to your domain +PUB_PLAUSIBLE_URL=https://plausible.example.com # can be empty if not using Plausible +``` + +To build the project for production, run `bun run build` + +This will build the site to the `build` folder. You can then start the server with `bun ./build/index.js` and navigate to `http://localhost:3000` to see the application. + +## License + +This project is licensed under the AGPL-3.0 License, please see the [LICENSE](LICENSE) file for details. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bd3476c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + vert: + container_name: vert + build: + context: . + args: + PUB_HOSTNAME: "vert.sh" + PUB_PLAUSIBLE_URL: "https://plausible.example.com" + restart: unless-stopped + ports: + - 3000:3000 \ No newline at end of file diff --git a/package.json b/package.json index 2d9c4cb..5eab105 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "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", @@ -41,6 +42,7 @@ "clsx": "^2.1.1", "lucide-svelte": "^0.456.0", "svelte-adapter-bun": "^0.5.2", + "typescript-cookie": "^1.0.6", "wasm-vips": "^0.0.11" } } diff --git a/src/app.html b/src/app.html index 77a5ff5..03c4c3b 100644 --- a/src/app.html +++ b/src/app.html @@ -6,7 +6,7 @@ %sveltekit.head% - +
%sveltekit.body%
diff --git a/src/app.css b/src/app.scss similarity index 68% rename from src/app.css rename to src/app.scss index 00893ec..3501bc1 100644 --- a/src/app.css +++ b/src/app.scss @@ -1,24 +1,16 @@ -@import "tailwindcss/base"; -@import "tailwindcss/components"; -@import "tailwindcss/utilities"; +@tailwind base; +@tailwind components; +@tailwind utilities; @import url(@fontsource/lexend/400.css); @import url(@fontsource/lexend/500.css); @import url(@fontsource/azeret-mono/600.css); :root { - --accent-bg: hsl(303, 73%, 81%); - --accent-fg: hsl(0, 0, 10%); --font-body: "Lexend", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; --font-display: "Azeret Mono", var(--font-body); - --bg: hsl(0, 0%, 100%); - --fg: hsl(0, 0%, 10%); - --fg-muted: hsl(0, 0%, 50%); - --fg-muted-alt: hsl(0, 0%, 75%); - --fg-highlight: hsl(303, 52%, 42%); - --fg-failure: hsl(0, 67%, 49%); --transition: linear( 0, 0.006, @@ -37,6 +29,50 @@ ); } +@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%); +} + +@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%); +} + +@media (prefers-color-scheme: dark) { + body { + @include dark; + } +} + +@media (prefers-color-scheme: light) { + body { + @include light; + } +} + +body.light { + @include light; +} + +body.dark { + @include dark; +} + body { @apply text-foreground bg-background font-body overflow-x-hidden; width: 100vw; diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..d28c64a --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,16 @@ +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: "strict", + }); + theme = ""; + } + const res = await resolve(event, { + transformPageChunk: ({ html }) => html.replace("%theme%", theme), + }); + return res; +}; diff --git a/src/lib/animation/index.ts b/src/lib/animation/index.ts index 6c8fed0..8e7f13b 100644 --- a/src/lib/animation/index.ts +++ b/src/lib/animation/index.ts @@ -55,7 +55,7 @@ export const blur = ( origin: Combination< "top" | "bottom" | "left" | "right" | "center", "top" | "bottom" | "left" | "right" | "center" - >; + > & {}; }> | undefined, dir: { diff --git a/src/lib/components/functional/FancyMenu.svelte b/src/lib/components/functional/FancyMenu.svelte index bfff5e0..e46e618 100644 --- a/src/lib/components/functional/FancyMenu.svelte +++ b/src/lib/components/functional/FancyMenu.svelte @@ -4,6 +4,9 @@ import { duration } from "$lib/animation"; 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: { @@ -16,16 +19,25 @@ let { links, shouldGoBack = null }: Props = $props(); + let hasLoaded = $state(false); + let navWidth = $state(1); let linkCount = $derived(links.length); let activeLinkIndex = $derived( links.findIndex((i) => i.activeMatch($page.url.pathname)), ); + + onMount(async () => { + await tick(); + setTimeout(() => { + hasLoaded = true; + }, 16); + });
{#if activeLinkIndex !== -1}
{/if} {#each links as { name, url } (url)} { if (shouldGoBack) { @@ -55,7 +72,7 @@
{#key name} , diff --git a/src/lib/converters/ffmpeg.svelte.ts b/src/lib/converters/ffmpeg.svelte.ts index 95cda30..da5bc50 100644 --- a/src/lib/converters/ffmpeg.svelte.ts +++ b/src/lib/converters/ffmpeg.svelte.ts @@ -3,6 +3,7 @@ import { Converter } from "./converter.svelte"; import type { OmitBetterStrict } from "$lib/types"; import { FFmpeg } from "@ffmpeg/ffmpeg"; import { browser } from "$app/environment"; +import { log } from "$lib/logger"; export class FFmpegConverter extends Converter { private ffmpeg: FFmpeg = null!; @@ -16,26 +17,27 @@ export class FFmpegConverter extends Converter { ".ogg", ".aac", ".m4a", - ".opus", ".wma", ".m4a", ".amr", ".ac3", - "alac", + ".alac", ".aiff", ]; constructor() { super(); + log(["converters", this.name], `created converter`); if (!browser) return; this.ffmpeg = new FFmpeg(); (async () => { - const baseURL = "https://unpkg.com/@ffmpeg/core@latest/dist/esm"; + 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; })(); } @@ -45,13 +47,29 @@ export class FFmpegConverter extends Converter { to: string, ): Promise { if (!to.startsWith(".")) to = `.${to}`; - // clone input.buffer + const ffmpeg = new FFmpeg(); + const baseURL = + "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm"; + await ffmpeg.load({ + coreURL: `${baseURL}/ffmpeg-core.js`, + wasmURL: `${baseURL}/ffmpeg-core.wasm`, + }); const buf = new Uint8Array(input.buffer); - await this.ffmpeg.writeFile("input", buf); - await this.ffmpeg.exec(["-i", "input", "output" + to]); - const output = (await this.ffmpeg.readFile( + await ffmpeg.writeFile("input", buf); + log( + ["converters", this.name], + `wrote ${input.name} to ffmpeg virtual fs`, + ); + await ffmpeg.exec(["-i", "input", "output" + to]); + log(["converters", this.name], `executed ffmpeg command`); + const output = (await ffmpeg.readFile( "output" + to, )) as unknown as Uint8Array; + log( + ["converters", this.name], + `read ${input.name.split(".").slice(0, -1).join(".") + to} from ffmpeg virtual fs`, + ); + ffmpeg.terminate(); return { ...input, buffer: output.buffer, diff --git a/src/lib/converters/vips.svelte.ts b/src/lib/converters/vips.svelte.ts index 8abe9f6..c109118 100644 --- a/src/lib/converters/vips.svelte.ts +++ b/src/lib/converters/vips.svelte.ts @@ -3,6 +3,7 @@ import { Converter } from "./converter.svelte"; import VipsWorker from "$lib/workers/vips?worker"; import { browser } from "$app/environment"; import type { WorkerMessage, OmitBetterStrict } from "$lib/types"; +import { log } from "$lib/logger"; export class VipsConverter extends Converter { private worker: Worker = browser ? new VipsWorker() : null!; @@ -30,6 +31,7 @@ export class VipsConverter extends Converter { constructor() { super(); + log(["converters", this.name], `created converter`); if (!browser) return; this.worker.onmessage = (e) => { const message: WorkerMessage = e.data; @@ -41,6 +43,7 @@ export class VipsConverter extends Converter { input: OmitBetterStrict, to: string, ): Promise { + log(["converters", this.name], `converting ${input.name} to ${to}`); const res = await this.sendMessage({ type: "convert", input: input as unknown as IFile, @@ -48,6 +51,7 @@ export class VipsConverter extends Converter { }); if (res.type === "finished") { + log(["converters", this.name], `converted ${input.name} to ${to}`); return res.output; } diff --git a/src/lib/logger/index.ts b/src/lib/logger/index.ts new file mode 100644 index 0000000..9cc2063 --- /dev/null +++ b/src/lib/logger/index.ts @@ -0,0 +1,42 @@ +import { browser } from "$app/environment"; + +const randomColorFromStr = (str: string) => { + // generate a pleasant color from a string, using HSL + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + const h = hash % 360; + return `hsl(${h}, 75%, 71%)`; +}; + +const whiteOrBlack = (hsl: string) => { + // determine if the text should be white or black based on the background color + const [, , l] = hsl + .replace("hsl(", "") + .replace(")", "") + .split(",") + .map((v) => parseInt(v)); + 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) + return console.log(prefixes.map((p) => `[${p}]`).join(" "), ...args); + const prefixesWithMeta = prefixes.map((p) => ({ + prefix: p, + bgColor: randomColorFromStr(p), + textColor: whiteOrBlack(randomColorFromStr(p)), + })); + + console.log( + `%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, + ); +}; diff --git a/src/lib/store/index.svelte.ts b/src/lib/store/index.svelte.ts index 9e67895..4c88dce 100644 --- a/src/lib/store/index.svelte.ts +++ b/src/lib/store/index.svelte.ts @@ -1,3 +1,4 @@ +import { log } from "$lib/logger"; import type { IFile } from "$lib/types"; class Files { @@ -11,12 +12,18 @@ class Files { result?: (IFile & { blobUrl: string; animating: boolean }) | null; }[] >([]); - public beenToConverterPage = $state(false); - public shouldShowAlert = $derived( - !this.beenToConverterPage && this.files.length > 0, - ); +} + +class Theme { + public dark = $state(false); + public toggle = () => { + this.dark = !this.dark; + log(["theme"], `set to ${this.dark ? "dark" : "light"}`); + }; } export const files = new Files(); +export const theme = new Theme(); + export const outputFilenameOption = ["default", "original"]; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index dc85a70..8bdfe30 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,14 +1,17 @@ @@ -68,7 +88,7 @@
{#key data.pathname} @@ -105,10 +204,15 @@ start: !$shouldGoBack ? 250 : -250, end: 0, }, + y: { + start: 100, + end: 0, + }, scale: { start: 0.75, end: 1, }, + origin: "top center", }} out:blur={{ duration, @@ -118,10 +222,15 @@ start: 0, end: !$shouldGoBack ? -250 : 250, }, + y: { + start: 0, + end: 100, + }, scale: { start: 1, end: 0.75, }, + origin: "top center", }} >
diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts new file mode 100644 index 0000000..8348af2 --- /dev/null +++ b/src/routes/+layout.ts @@ -0,0 +1,18 @@ +import { browser } from "$app/environment"; +import { theme } from "$lib/store/index.svelte"; +import { getCookie, setCookie } from "typescript-cookie"; + +export const load = ({ data }) => { + if (!browser) return; + const themeStr = getCookie("theme"); + if (typeof themeStr === "undefined") { + theme.dark = window.matchMedia("(prefers-color-scheme: dark)").matches; + setCookie("theme", theme.dark ? "dark" : "light", { + sameSite: "strict", + }); + } else { + theme.dark = themeStr === "dark"; + } + theme.dark = getCookie("theme") === "dark"; + return data; +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 526bfa7..78f7607 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2,6 +2,7 @@ import { goto } from "$app/navigation"; import Uploader from "$lib/components/functional/Uploader.svelte"; import { converters } from "$lib/converters"; + import { log } from "$lib/logger/index.js"; import { files } from "$lib/store/index.svelte"; import { Check } from "lucide-svelte"; @@ -9,31 +10,79 @@ let ourFiles = $state(); - const runUpload = () => { + 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), + ); + 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( + (blob) => { + resolve({ + file: f, + from, + to, + blobUrl: + blob === null + ? "" + : URL.createObjectURL(blob), + id: Math.random().toString(36).substring(2), + }); + }, + "image/jpeg", + 0.75, + ); + }; + + img.onerror = () => { + resolve({ + file: f, + from, + to, + blobUrl: "", + id: Math.random().toString(36).substring(2), + }); + }; + }, + ); + }); + let oldLen = files.files.length; files.files = [ ...files.files, - ...(ourFiles || []).map((f, i) => { - const from = "." + f.name.toLowerCase().split(".").slice(-1); - const converter = converters.find((c) => - c.supportedFormats.includes(from), - ); - const to = - converter?.supportedFormats.find((f) => f !== from) || - converters[0].supportedFormats[0]; - return { - file: f, - from, - to, - blobUrl: URL.createObjectURL(f), - id: Math.random().toString(36).substring(2), - }; - }), + ...(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 && !files.beenToConverterPage) - goto("/convert"); + if (files.files.length > 0) goto("/convert"); }; @@ -83,7 +132,7 @@
- {#each ["Very fast, all processing done on device", "No ads, and open source", "Beautiful and straightforward UI"] as text, i} + {#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}
{@render sellingPoint(text)}
diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index 83c8c62..fb8b32f 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -54,27 +54,36 @@ 🖼️ supported formats

- As of right now, VERT only supports image conversion of most popular - formats. Don't worry though, as we'll add more options and support for - more formats in the future! + As of right now, VERT supports image and audio conversion of most + popular formats. We'll add support for more formats in the future!

- 👨‍💻 source code + 🔗 resources

-

- VERT is licensed under AGPL-3.0, and the source code can be found on GitHub. -

+
    +
  • + Source code (hosted on GitHub, licensed under AGPL-3.0) +
  • + +
  • + Discord server (for chit-chat, suggestions, and support) +
  • +

🎨 credits

@@ -83,7 +92,7 @@ diff --git a/static/banner.png b/static/banner.png new file mode 100644 index 0000000..bb11466 Binary files /dev/null and b/static/banner.png differ diff --git a/tailwind.config.ts b/tailwind.config.ts index 07c48f4..1ec68d4 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,8 +1,8 @@ import type { Config } from "tailwindcss"; +import plugin from "tailwindcss/plugin"; export default { content: ["./src/**/*.{html,js,svelte,ts}"], - theme: { extend: { colors: { @@ -25,5 +25,12 @@ export default { }, }, - plugins: [], + plugins: [ + plugin(function ({ addVariant }) { + addVariant("dynadark", [ + "body:not(.light).dark &", + "@media (prefers-color-scheme: dark) { body:not(.light) &", + ]); + }), + ], } satisfies Config; diff --git a/vite.config.ts b/vite.config.ts index d5bf6b7..d48daa2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,4 +26,11 @@ export default defineConfig({ "@ffmpeg/util", ], }, + css: { + preprocessorOptions: { + scss: { + api: "modern", + }, + }, + }, });