diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67971c4..409822e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,15 +25,12 @@ jobs: - name: Lint (ESLint) run: npm run lint:check - continue-on-error: true - name: Type check - run: npm run lint:types - continue-on-error: true + run: npm run ts:check - name: Format check (Prettier) run: npm run format:check - continue-on-error: true - name: Package run: npm run package diff --git a/.gitignore b/.gitignore index 9d2e4f1..05f2494 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,6 @@ out/ /playwright-report/ /blob-report/ /playwright/.cache/ + +# Nix +result/ diff --git a/forge.config.ts b/forge.config.ts index 5eaf487..6f22eb7 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -82,7 +82,8 @@ const config: ForgeConfig = { fs.readFileSync(path.resolve(buildPath, "package.json")).toString() ); packageJson.dependencies = { - "electron-store": "^10.1.0", + canvas: "^3.1.2", + conf: "^14.0.0", keytar: "^7.9.0", }; fs.writeFileSync(path.resolve(buildPath, "package.json"), JSON.stringify(packageJson)); diff --git a/package-lock.json b/package-lock.json index c2e0898..800a1b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,10 +25,11 @@ "@tailwindcss/vite": "^4.1.11", "@tanstack/react-query": "^5.84.1", "@uiw/react-color": "^2.7.3", + "canvas": "^3.1.2", "chroma.ts": "^1.0.10", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", - "electron-store": "^10.1.0", + "conf": "^14.0.0", "electron-trpc-experimental": "^1.0.0-alpha.0", "keytar": "^7.9.0", "lucide-react": "^0.536.0", @@ -41,7 +42,8 @@ "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", "tw-animate-css": "^1.3.6", - "use-debounce": "^10.0.3", + "use-debounce": "^10.0.5", + "uuid": "^11.1.0", "zod": "^4.0.14" }, "devDependencies": { @@ -3651,9 +3653,9 @@ } }, "node_modules/@trpc/server": { - "version": "11.4.3", - "resolved": "https://registry.npmjs.org/@trpc/server/-/server-11.4.3.tgz", - "integrity": "sha512-wnWq3wiLlMOlYkaIZz+qbuYA5udPTLS4GVVRyFkr6aT83xpdCHyVtURT+u4hSoIrOXQM9OPCNXSXsAujWZDdaw==", + "version": "11.4.4", + "resolved": "https://registry.npmjs.org/@trpc/server/-/server-11.4.4.tgz", + "integrity": "sha512-VkJb2xnb4rCynuwlCvgPBh5aM+Dco6fBBIo6lWAdJJRYVwtyE5bxNZBgUvRRz/cFSEAy0vmzLxF7aABDJfK5Rg==", "funding": [ "https://trpc.io/sponsor" ], @@ -5238,6 +5240,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.1.2.tgz", + "integrity": "sha512-Z/tzFAcBzoCvJlOSlCnoekh1Gu8YMn0J51+UAuXJAbW1Z6I9l2mZgdD7738MepoeeIcUdDtbMnOg6cC7GJxy/g==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": "^18.12.0 || >= 20.9.0" + } + }, + "node_modules/canvas/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -6381,22 +6403,6 @@ "node": ">=10" } }, - "node_modules/electron-store": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/electron-store/-/electron-store-10.1.0.tgz", - "integrity": "sha512-oL8bRy7pVCLpwhmXy05Rh/L6O93+k9t6dqSw0+MckIc3OmCTZm6Mp04Q4f/J0rtu84Ky6ywkR8ivtGOmrq+16w==", - "license": "MIT", - "dependencies": { - "conf": "^14.0.0", - "type-fest": "^4.41.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.194", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.194.tgz", @@ -12848,6 +12854,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", diff --git a/package.json b/package.json index b50201e..bfd2f4a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "walltone", - "productName": "Walltone", + "productName": "walltone", "version": " 0.0.1", "description": "Theme Manager", "main": ".vite/build/main.js", @@ -15,9 +15,9 @@ "scripts": { "lint-staged": "lint-staged", "prepare": "husky", - "lint:types": "tsc --noEmit", "lint:check": "eslint --max-warnings=0", "lint:fix": "eslint --fix", + "ts:check": "tsc --noEmit", "format:check": "prettier --check \"**/*.{ts,tsx,js,css,yaml}\"", "format:write": "prettier --write \"**/*.{ts,tsx,js,css,yaml}\"", "start": "electron-forge start", @@ -85,10 +85,11 @@ "@tailwindcss/vite": "^4.1.11", "@tanstack/react-query": "^5.84.1", "@uiw/react-color": "^2.7.3", + "canvas": "^3.1.2", "chroma.ts": "^1.0.10", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", - "electron-store": "^10.1.0", + "conf": "^14.0.0", "electron-trpc-experimental": "^1.0.0-alpha.0", "keytar": "^7.9.0", "lucide-react": "^0.536.0", @@ -101,7 +102,11 @@ "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", "tw-animate-css": "^1.3.6", - "use-debounce": "^10.0.3", + "use-debounce": "^10.0.5", + "uuid": "^11.1.0", "zod": "^4.0.14" + }, + "overrides": { + "@trpc/server": "11.4.4" } } diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index b880d9d..91b9a6c 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -6,9 +6,11 @@ pkgdesc="Wallpaper and theme management application" arch=("x86_64") url="https://github.com/kasper24/walltone" license=("GPL3") -depends=("nss" "libsecret" "swaybg" "mpvpaper" "linux-wallpaperengine" "cage" "grim" "wayland-utils") +depends=("nss" "libsecret" "cairo" "pango" "libjpeg-turbo" +"giflib" "libsvgtiny" "swaybg" "mpvpaper" +"linux-wallpaperengine" "cage" "grim" "wayland-utils") makedepends=("npm" "nodejs" "git") -source=("$pkgname::git+$url.git#branch=dev") +source=("$pkgname::git+$url.git") sha256sums=("SKIP") pkgver() { diff --git a/packaging/nix/package.nix b/packaging/nix/package.nix index 955041a..445a148 100644 --- a/packaging/nix/package.nix +++ b/packaging/nix/package.nix @@ -3,6 +3,13 @@ fetchurl, buildNpmPackage, libsecret, + pixman, + cairo, + pango, + libjpeg, + libpng, + librsvg, + giflib, pkg-config, makeWrapper, electron-bin, @@ -39,7 +46,7 @@ buildNpmPackage rec { src = ../../.; - npmDepsHash = "sha256-vs5gcGG7jHKu7qs80JFXX1PTr5alMSqjpCclU4+WJYc="; + npmDepsHash = "sha256-VlDOLuyr1qE2zTlXYdzBI1VsAzZMVDfODWyUTaNJCKA"; dontNpmBuild = true; makeCacheWritable = true; @@ -59,6 +66,15 @@ buildNpmPackage rec { buildInputs = [ libsecret + + # Node-Canvas dependencies + pixman + cairo + pango + libjpeg + libpng + librsvg + giflib ]; nativeBuildInputs = [ diff --git a/result b/result deleted file mode 120000 index 99875a7..0000000 --- a/result +++ /dev/null @@ -1 +0,0 @@ -/nix/store/j1zsmic4f55sz9ri951xka54bl7grqjy-walltone-unstable-2025-08-02 \ No newline at end of file diff --git a/src/electron/main/index.ts b/src/electron/main/index.ts index 5e71d45..418c52c 100644 --- a/src/electron/main/index.ts +++ b/src/electron/main/index.ts @@ -1,18 +1,20 @@ import { app, BrowserWindow, globalShortcut } from "electron"; import { createIPCHandler } from "electron-trpc-experimental/main"; -import { appRouter } from "@electron/main/trpc/routes/base.js"; +import { appRouter, caller } from "@electron/main/trpc/routes/index.js"; import { registerProtocols } from "./setup/protocols.js"; import { createTray } from "./setup/tray.js"; import { createWindow } from "./setup/window.js"; let isQuitting = false; -const initializeApp = () => { +const initializeApp = async () => { const mainWindow = createWindow(); createTray(mainWindow); createIPCHandler({ router: appRouter, windows: [mainWindow] }); + await caller.wallpaper.restoreOnStart(); + mainWindow.once("ready-to-show", () => { mainWindow.show(); }); diff --git a/src/electron/main/lib/index.ts b/src/electron/main/lib/index.ts index ce1c49f..5ac8d50 100644 --- a/src/electron/main/lib/index.ts +++ b/src/electron/main/lib/index.ts @@ -1,4 +1,5 @@ import { spawn } from "child_process"; +import { TRPCError } from "@trpc/server"; const execute = ({ command, @@ -72,4 +73,20 @@ const santize = (str: string) => { return str.replace(/[^a-z0-9-]/gi, "").toLowerCase(); }; -export { execute, killProcess, santize }; +const renderString = async (content: string, context: Record) => { + return content.replace(/\$\{([\s\S]+?)\}/g, (_, expr) => { + try { + const fn = new Function(...Object.keys(context), `return (${expr})`); + return fn(...Object.values(context)); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to evaluate: ${expr}: ${errorMessage}`, + cause: error, + }); + } + }); +}; + +export { execute, killProcess, santize, renderString }; diff --git a/src/electron/main/setup/window.ts b/src/electron/main/setup/window.ts index 696a1e9..8b7a761 100644 --- a/src/electron/main/setup/window.ts +++ b/src/electron/main/setup/window.ts @@ -18,6 +18,7 @@ const createWindow = () => { allowRunningInsecureContent: false, nodeIntegration: false, nodeIntegrationInSubFrames: false, + nodeIntegrationInWorker: true, webSecurity: true, preload: path.join(import.meta.dirname, "preload.js"), }, diff --git a/src/electron/main/trpc/routes/api/index.ts b/src/electron/main/trpc/routes/api/index.ts new file mode 100644 index 0000000..aa079ba --- /dev/null +++ b/src/electron/main/trpc/routes/api/index.ts @@ -0,0 +1,12 @@ +import { router } from "@electron/main/trpc/index.js"; +import { pexelsRouter } from "./pexels/index.js"; +import { unsplashRouter } from "./unsplash/index.js"; +import { wallhavenRouter } from "./wallhaven/index.js"; +import { wallpaperEngineRouter } from "./wallpaper-engine/index.js"; + +export const apiRouter = router({ + pexels: pexelsRouter, + unsplash: unsplashRouter, + wallhaven: wallhavenRouter, + wallpaperEngine: wallpaperEngineRouter, +}); diff --git a/src/electron/main/trpc/routes/api/pexels.ts b/src/electron/main/trpc/routes/api/pexels/index.ts similarity index 92% rename from src/electron/main/trpc/routes/api/pexels.ts rename to src/electron/main/trpc/routes/api/pexels/index.ts index b4e7d7a..0bc7168 100644 --- a/src/electron/main/trpc/routes/api/pexels.ts +++ b/src/electron/main/trpc/routes/api/pexels/index.ts @@ -1,7 +1,7 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { publicProcedure, router } from "@electron/main/trpc/index.js"; -import { type DownloadableWallpaper } from "@electron/main/trpc/routes/theme.js"; +import { type ApiWallpaper } from "@electron/main/trpc/routes/wallpaper/index.js"; interface PexelsPhoto { id: number; @@ -67,8 +67,9 @@ interface PexelsSearchResponse { next_page?: string; } -const transformPhotos = (photos: PexelsPhoto[]): DownloadableWallpaper[] => { +const transformPhotos = (photos: PexelsPhoto[]): ApiWallpaper[] => { return photos.map((photo) => ({ + type: "api", id: photo.id.toString(), name: photo.alt || `Photo by ${photo.photographer}`, previewPath: photo.src.large, @@ -76,7 +77,7 @@ const transformPhotos = (photos: PexelsPhoto[]): DownloadableWallpaper[] => { })); }; -const transformVideos = (videos: PexelsVideo[]): DownloadableWallpaper[] => { +const transformVideos = (videos: PexelsVideo[]): ApiWallpaper[] => { return videos.map((video) => { // Get the highest quality video file const bestVideo = video.video_files.reduce((prev, current) => { @@ -86,6 +87,7 @@ const transformVideos = (videos: PexelsVideo[]): DownloadableWallpaper[] => { }); return { + type: "api", id: video.id.toString(), name: `Video by ${video.user.name}`, previewPath: video.image, @@ -152,7 +154,7 @@ export const pexelsRouter = router({ const data: PexelsSearchResponse = await response.json(); const numberOfPages = Math.ceil(data.total_results / (input.perPage || 30)); - let transformedData: DownloadableWallpaper[] = []; + let transformedData: ApiWallpaper[] = []; if (input.type === "photos" && data.photos) { transformedData = transformPhotos(data.photos as PexelsPhoto[]); } else if (input.type === "videos" && data.videos) { @@ -164,7 +166,7 @@ export const pexelsRouter = router({ currentPage: data.page, prevPage: data.page > 1 ? data.page - 1 : null, nextPage: data.page < numberOfPages ? data.page + 1 : null, - total: data.total_results, + totalItems: data.total_results, totalPages: numberOfPages, }; } catch (error) { diff --git a/src/electron/main/trpc/routes/api/unsplash.ts b/src/electron/main/trpc/routes/api/unsplash/index.ts similarity index 92% rename from src/electron/main/trpc/routes/api/unsplash.ts rename to src/electron/main/trpc/routes/api/unsplash/index.ts index 321d99f..e3f1c63 100644 --- a/src/electron/main/trpc/routes/api/unsplash.ts +++ b/src/electron/main/trpc/routes/api/unsplash/index.ts @@ -1,7 +1,7 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { publicProcedure, router } from "@electron/main/trpc/index.js"; -import { type DownloadableWallpaper } from "@electron/main/trpc/routes/theme.js"; +import { type ApiWallpaper } from "@electron/main/trpc/routes/wallpaper/index.js"; interface UnsplashPhoto { id: string; @@ -89,7 +89,6 @@ interface UnsplashPhoto { status: string; } >; - z; user: { id: string; updated_at: string; @@ -153,8 +152,9 @@ interface UnsplashSearchResult { results: UnsplashPhoto[]; } -const transformPhotos = (photos: UnsplashPhoto[]): DownloadableWallpaper[] => { +const transformWallpapers = (photos: UnsplashPhoto[]): ApiWallpaper[] => { return photos.map((photo) => ({ + type: "api", id: photo.id, name: photo.alt_description || photo.description || `Photo by ${photo.user.name}`, previewPath: photo.urls.regular, @@ -165,8 +165,8 @@ const transformPhotos = (photos: UnsplashPhoto[]): DownloadableWallpaper[] => { const unsplashSearchParamsSchema = z.object({ apiKey: z.string().min(1, "API Key is required"), query: z.string(), - page: z.number().min(1).optional().default(1), - perPage: z.number().optional().default(30), + page: z.number().min(1), + perPage: z.number().min(1).optional().default(30), orderBy: z.enum(["relevant", "latest"]).optional().default("relevant"), orientation: z.enum(["landscape", "portrait", "squarish"]).optional(), color: z @@ -199,8 +199,6 @@ export const unsplashRouter = router({ if (input.orientation) params.set("orientation", input.orientation); if (input.color) params.set("color", input.color); - console.log(`Unsplash API URL: ${url.toString()}`); - try { const response = await fetch(url.toString()); if (!response.ok) @@ -213,15 +211,15 @@ export const unsplashRouter = router({ // Normalize the response format const photos = Array.isArray(data) ? data : data.results; - const total = Array.isArray(data) ? photos.length : data.total; + const totalItems = Array.isArray(data) ? photos.length : data.total; const totalPages = Array.isArray(data) ? Infinity : data.total_pages; return { - data: photos ? transformPhotos(photos) : [], + data: photos ? transformWallpapers(photos) : [], currentPage: input.page, prevPage: input.page > 1 ? input.page - 1 : null, nextPage: input.page < totalPages ? input.page + 1 : null, - total, + totalItems, totalPages, }; } catch (error) { diff --git a/src/electron/main/trpc/routes/api/wallhaven.ts b/src/electron/main/trpc/routes/api/wallhaven/index.ts similarity index 94% rename from src/electron/main/trpc/routes/api/wallhaven.ts rename to src/electron/main/trpc/routes/api/wallhaven/index.ts index 736c8c6..b074956 100644 --- a/src/electron/main/trpc/routes/api/wallhaven.ts +++ b/src/electron/main/trpc/routes/api/wallhaven/index.ts @@ -1,7 +1,7 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { publicProcedure, router } from "@electron/main/trpc/index.js"; -import { type DownloadableWallpaper } from "@electron/main/trpc/routes/theme.js"; +import { type ApiWallpaper } from "@electron/main/trpc/routes/wallpaper/index.js"; export type WallhavenSorting = "date_added" | "random" | "views" | "favorites" | "toplist"; export type WallhavenCategory = "general" | "anime" | "people"; @@ -58,8 +58,9 @@ const convertPurity = (purity: WallhavenPurity[]): string => { return `${sfw ? "1" : "0"}${sketchy ? "1" : "0"}${nsfw ? "1" : "0"}`; }; -const transformWallpaper = (wallpapers: WallhavenWallpaper[]): DownloadableWallpaper[] => { +const transformWallpapers = (wallpapers: WallhavenWallpaper[]): ApiWallpaper[] => { return wallpapers.map((wallpaper) => ({ + type: "api", id: wallpaper.id, name: wallpaper.id, previewPath: wallpaper.thumbs.large, @@ -112,11 +113,11 @@ export const wallhavenRouter = router({ const totalPages = Math.ceil(data.meta.total / data.meta.per_page); return { - data: transformWallpaper(data.data), + data: transformWallpapers(data.data), currentPage: data.meta.current_page, prevPage: data.meta.current_page > 1 ? data.meta.current_page - 1 : null, nextPage: data.meta.current_page < totalPages ? data.meta.current_page + 1 : null, - total: data.meta.total, + totalItems: data.meta.total, totalPages, }; } catch (error) { diff --git a/src/electron/main/trpc/routes/api/wallpaper-engine.ts b/src/electron/main/trpc/routes/api/wallpaper-engine/index.ts similarity index 94% rename from src/electron/main/trpc/routes/api/wallpaper-engine.ts rename to src/electron/main/trpc/routes/api/wallpaper-engine/index.ts index 3f9db8d..5fead17 100644 --- a/src/electron/main/trpc/routes/api/wallpaper-engine.ts +++ b/src/electron/main/trpc/routes/api/wallpaper-engine/index.ts @@ -1,7 +1,7 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { publicProcedure, router } from "@electron/main/trpc/index.js"; -import { type BaseWallpaper } from "@electron/main/trpc/routes/theme.js"; +import { type BaseWallpaper } from "@electron/main/trpc/routes/wallpaper/index.js"; interface WallpaperEngineWorkshopItem { result: number; @@ -72,6 +72,7 @@ interface WallpaperEngineWorkshopSearchResponse { const transformWallpapers = (wallpapers: WallpaperEngineWorkshopItem[]): BaseWallpaper[] => { return wallpapers.map((wallpaper) => ({ + type: "api", id: wallpaper.publishedfileid, name: wallpaper.title, previewPath: wallpaper.preview_url, @@ -81,6 +82,7 @@ const transformWallpapers = (wallpapers: WallpaperEngineWorkshopItem[]): BaseWal const searchSchema = z.object({ apiKey: z.string().min(1, "API Key is required"), page: z.number().min(1), + perPage: z.number().min(1).optional().default(100), query: z.string().optional(), tags: z.array(z.string()).optional(), sorting: z.string().optional(), @@ -94,8 +96,6 @@ const subscriptionSchema = z.object({ export const wallpaperEngineRouter = router({ search: publicProcedure.input(searchSchema).query(async ({ input }) => { - const ITEMS_PER_PAGE = 100; - const url = new URL("https://api.steampowered.com/IPublishedFileService/QueryFiles/v1"); const params = url.searchParams; @@ -103,7 +103,7 @@ export const wallpaperEngineRouter = router({ params.set("creator_appid", "431960"); params.set("appid", "431960"); params.set("page", `${input.page}`); - params.set("numperpage", `${ITEMS_PER_PAGE}`); + params.set("numperpage", `${input.perPage}`); params.set("format", "json"); params.set("return_tags", "true"); params.set("return_previews", "true"); @@ -124,7 +124,7 @@ export const wallpaperEngineRouter = router({ }); const data: WallpaperEngineWorkshopSearchResponse = await response.json(); - const numberOfPages = Math.ceil(data.response.total / ITEMS_PER_PAGE); + const numberOfPages = Math.ceil(data.response.total / input.perPage); return { data: data.response.publishedfiledetails @@ -133,7 +133,7 @@ export const wallpaperEngineRouter = router({ currentPage: input.page, prevPage: input.page > 1 ? input.page - 1 : null, nextPage: input.page < numberOfPages ? input.page + 1 : null, - total: data.response.total, + totalItems: data.response.total, totalPages: numberOfPages, }; } catch (error) { diff --git a/src/electron/main/trpc/routes/base.ts b/src/electron/main/trpc/routes/base.ts deleted file mode 100644 index e2df5f6..0000000 --- a/src/electron/main/trpc/routes/base.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; -import { router, createCallerFactory } from "@electron/main/trpc/index.js"; -import { pexelsRouter } from "./api/pexels.js"; -import { unsplashRouter } from "./api/unsplash.js"; -import { wallhavenRouter } from "./api/wallhaven.js"; -import { wallpaperEngineRouter } from "./api/wallpaper-engine.js"; -import { fileRouter } from "./file.js"; -import { monitorRouter } from "./monitor.js"; -import { settingsRouter } from "./settings.js"; -import { themeRouter } from "./theme.js"; - -export const appRouter = router({ - api: router({ - pexels: pexelsRouter, - unsplash: unsplashRouter, - wallhaven: wallhavenRouter, - wallpaperEngine: wallpaperEngineRouter, - }), - file: fileRouter, - monitor: monitorRouter, - settings: settingsRouter, - theme: themeRouter, -}); - -export const caller = createCallerFactory(appRouter)({}); - -export type AppRouter = typeof appRouter; - -export type RouterInputs = inferRouterInputs; -export type RouterOutputs = inferRouterOutputs; diff --git a/src/electron/main/trpc/routes/file.ts b/src/electron/main/trpc/routes/file/index.ts similarity index 100% rename from src/electron/main/trpc/routes/file.ts rename to src/electron/main/trpc/routes/file/index.ts diff --git a/src/electron/main/trpc/routes/index.ts b/src/electron/main/trpc/routes/index.ts new file mode 100644 index 0000000..c0d78ff --- /dev/null +++ b/src/electron/main/trpc/routes/index.ts @@ -0,0 +1,24 @@ +import { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; +import { router, createCallerFactory } from "@electron/main/trpc/index.js"; +import { apiRouter } from "./api/index.js"; +import { fileRouter } from "./file/index.js"; +import { monitorRouter } from "./monitor/index.js"; +import { settingsRouter } from "./settings/index.js"; +import { wallpaperRouter } from "./wallpaper/index.js"; +import { themeRouter } from "./theme/index.js"; + +export const appRouter = router({ + api: apiRouter, + file: fileRouter, + monitor: monitorRouter, + settings: settingsRouter, + theme: themeRouter, + wallpaper: wallpaperRouter, +}); + +export const caller = createCallerFactory(appRouter)({}); + +export type AppRouter = typeof appRouter; + +export type RouterInputs = inferRouterInputs; +export type RouterOutputs = inferRouterOutputs; diff --git a/src/electron/main/trpc/routes/monitor.ts b/src/electron/main/trpc/routes/monitor/index.ts similarity index 100% rename from src/electron/main/trpc/routes/monitor.ts rename to src/electron/main/trpc/routes/monitor/index.ts diff --git a/src/electron/main/trpc/routes/settings.ts b/src/electron/main/trpc/routes/settings.ts deleted file mode 100644 index 35f04f6..0000000 --- a/src/electron/main/trpc/routes/settings.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { dialog } from "electron"; -import Store from "electron-store"; -import keytar from "keytar"; -import { z } from "zod"; -import { TRPCError } from "@trpc/server"; -import { publicProcedure, router } from "@electron/main/trpc/index.js"; - -const store = new Store({ name: "settings" }); - -const filePicker = async (type: "file" | "folder"): Promise => { - const result = await dialog.showOpenDialog({ - properties: [type === "folder" ? "openDirectory" : "openFile"], - }); - - if (result.canceled) return null; - return result.filePaths[0]; -}; - -const getNestedValue = (obj: unknown, path: (string | number)[]): unknown => { - return path.reduce((acc: unknown, key: string | number) => { - if (typeof acc === "object" && acc !== null) { - return (acc as Record)[key]; - } - return undefined; - }, obj); -}; - -const setValue = async (key: string, value: unknown, encrypt: boolean) => { - try { - if (encrypt) { - if (typeof value !== "string") - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Value for encrypted setting '${key}' must be a string.`, - }); - - await keytar.setPassword("walltone", key, value as string); - } else { - store.set(key, value); - } - } catch (error) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to set setting ${key}`, - cause: error, - }); - } -}; - -export const settingKeySchema = z.enum([ - "unsplash.apiKey", - "pexels.apiKey", - "wallpaperEngine.apiKey", - "wallpaperEngine.assetsFolder", - "wallpaperEngine.wallpaperFolders", - "image.wallpaperFolders", - "video.wallpaperFolders", - "theme.templates", - "theme.wallpaperCopyDestinations", - "theme.restoreOnStart", - "theme.lastWallpaperCmd", -]); - -const getSchema = z.object({ - key: settingKeySchema, - path: z.array(z.union([z.string(), z.number()])).optional(), - decrypt: z.boolean().default(false), -}); - -const setSchema = z.object({ - key: settingKeySchema, - path: z.array(z.union([z.string(), z.number()])).optional(), - value: z.any().optional(), - filePicker: z.enum(["file", "folder"]).optional(), - encrypt: z.boolean().default(false), -}); - -const deleteSchema = z.object({ - key: settingKeySchema, - path: z.array(z.union([z.string(), z.number()])).optional(), - index: z.number().int().nonnegative(), -}); - -export const settingsRouter = router({ - get: publicProcedure.input(getSchema).query(async ({ input }) => { - try { - if (input.decrypt) return await keytar.getPassword("walltone", input.key); - - let settingValue = store.get(input.key); - if (input.path) settingValue = getNestedValue(settingValue, input.path); - - if (!settingValue) return null; - - return settingValue; - } catch (error) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to get value ${input.key}`, - cause: error, - }); - } - }), - - set: publicProcedure.input(setSchema).mutation(async ({ input }) => { - try { - const settingValue = store.get(input.key); - const newValue = - input.value ?? (input.filePicker ? await filePicker(input.filePicker) : null); - - if (input.path) { - const path = [...input.path]; - const lastKey = path.pop() as string | number; - const nestedValue = getNestedValue(settingValue, path); - - if (Array.isArray(nestedValue) && typeof lastKey === "number") { - nestedValue[lastKey] = newValue; - } else if ( - typeof nestedValue === "object" && - nestedValue !== null && - typeof lastKey === "string" - ) { - (nestedValue as Record)[lastKey] = newValue; - } else { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invalid path for setting ${input.key}`, - }); - } - - await setValue(input.key, settingValue, input.encrypt); - } else { - await setValue(input.key, newValue, input.encrypt); - } - } catch (error) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to set value to ${input.key}`, - cause: error, - }); - } - }), - - add: publicProcedure.input(z.object(setSchema)).mutation(async ({ input }) => { - try { - let settingValue = store.get(input.key) || []; - if (input.path) settingValue = getNestedValue(settingValue, input.path); - - if (!Array.isArray(settingValue)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Setting ${input.key} is not an array`, - }); - } - - const newValue = - input.value ?? (input.filePicker ? await filePicker(input.filePicker) : null); - settingValue.push(newValue); - await setValue(input.key, settingValue, input.encrypt); - } catch (error) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to add value to ${input.key}`, - cause: error, - }); - } - }), - - delete: publicProcedure.input(deleteSchema).mutation(async ({ input }) => { - try { - let settingValue = store.get(input.key); - if (input.path) settingValue = getNestedValue(settingValue, input.path); - - if (!Array.isArray(settingValue)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Setting ${input.key} is not an array`, - }); - } - - settingValue.splice(input.index, 1); - await setValue(input.key, settingValue, false); - } catch (error) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to delete value ${input.key}`, - cause: error, - }); - } - }), -}); diff --git a/src/electron/main/trpc/routes/settings/index.ts b/src/electron/main/trpc/routes/settings/index.ts new file mode 100644 index 0000000..86488c5 --- /dev/null +++ b/src/electron/main/trpc/routes/settings/index.ts @@ -0,0 +1,288 @@ +import { dialog } from "electron"; +import keytar from "keytar"; +import { z } from "zod"; +import Conf, { Schema } from "conf"; +import { TRPCError } from "@trpc/server"; +import { publicProcedure, router } from "@electron/main/trpc/index.js"; + +export interface SettingsSchema { + /** Settings for the application's appearance and startup behavior. */ + app: { + uiTheme: "light" | "dark"; + restoreWallpaperOnStart: boolean; + }; + + /** Settings that control how themes are generated from an image. */ + themeGeneration: { + quantizeLibrary: "material" | "quantize"; + base16: { + accentMinSaturation: number; + accentMaxSaturation: number; + accentMinLuminance: number; + accentMaxLuminance: number; + accentSaturation: number; + accentDarken: number; + accentLighten: number; + backgroundSaturation: number; + backgroundDarken: number; + backgroundLighten: number; + }; + }; + + /** Settings for what to do after a theme is generated. */ + themeOutput: { + templates: { + src: string; + dest: string; + postHook: string; + }[]; + wallpaperCopyDestinations: string[]; + }; + + /** Settings for all wallpaper sources. */ + wallpaperSources: { + imageFolders: string[]; + videoFolders: string[]; + wallpaperEngineAssetsFolder: string; + wallpaperEngineFolders: string[]; + }; + + /** Internal state, not typically edited by the user. */ + internal: { + lastWallpaperCmd: { + command: string; + args: string[]; + }; + }; + + apiKeys?: { + pexels: string; + unsplash: string; + wallpaperEngine: string; + }; +} + +const schema: Schema = { + app: { + type: "object", + properties: { + uiTheme: { type: "string", enum: ["light", "dark"], default: "dark" }, + restoreWallpaperOnStart: { type: "boolean", default: true }, + }, + default: {}, + }, + themeGeneration: { + type: "object", + properties: { + quantizeLibrary: { + type: "string", + enum: ["material", "quantize"], + default: "material", + }, + base16: { + type: "object", + properties: { + accentMinSaturation: { type: "number", default: 0.3, minimum: 0, maximum: 1 }, + accentMaxSaturation: { type: "number", default: 1, minimum: 0, maximum: 1 }, + accentMinLuminance: { type: "number", default: 0.08, minimum: 0, maximum: 1 }, + accentMaxLuminance: { type: "number", default: 0.8, minimum: 0, maximum: 1 }, + accentSaturation: { type: "number", default: 0.5, minimum: -10, maximum: 10 }, + accentDarken: { type: "number", default: 0, minimum: -10, maximum: 10 }, + accentLighten: { type: "number", default: 0, minimum: -10, maximum: 10 }, + backgroundSaturation: { type: "number", default: 0, minimum: -10, maximum: 10 }, + backgroundDarken: { type: "number", default: 0, minimum: -10, maximum: 10 }, + backgroundLighten: { type: "number", default: 0, minimum: -10, maximum: 10 }, + }, + default: {}, + }, + }, + default: {}, + }, + themeOutput: { + type: "object", + properties: { + templates: { + type: "array", + default: [], + items: { + type: "object", + properties: { + src: { type: "string", default: "" }, + dest: { type: "string", default: "" }, + postHook: { type: "string", default: "" }, + }, + }, + }, + wallpaperCopyDestinations: { type: "array", default: [], items: { type: "string" } }, + }, + default: {}, + }, + wallpaperSources: { + type: "object", + properties: { + imageFolders: { type: "array", default: [], items: { type: "string" } }, + videoFolders: { type: "array", default: [], items: { type: "string" } }, + wallpaperEngineAssetsFolder: { type: "string", default: "" }, + wallpaperEngineFolders: { type: "array", default: [], items: { type: "string" } }, + }, + default: {}, + }, + internal: { + type: "object", + properties: { + lastWallpaperCmd: { + type: "object", + properties: { + command: { type: "string", default: "" }, + args: { type: "array", default: [], items: { type: "string" } }, + }, + default: {}, + }, + }, + default: {}, + }, +}; + +type Paths = T extends object + ? { + [K in keyof T]-?: K extends string ? `${K}` | `${K}${PathContinuation}` : never; + }[keyof T] + : never; + +type PathContinuation = T extends (infer E)[] + ? `[${number}]` | `[${number}]${PathContinuation}` + : T extends object + ? `.${Paths}` + : ""; + +export type SettingKey = Paths; + +const store = new Conf({ + projectName: "walltone", + projectVersion: "0.0.1", + projectSuffix: "", + schema, +}); + +const filePicker = async (type: "file" | "folder"): Promise => { + const result = await dialog.showOpenDialog({ + properties: [type === "folder" ? "openDirectory" : "openFile"], + }); + + if (result.canceled) return null; + return result.filePaths[0]; +}; + +const getSchema = z.object({ + key: z.string(), + decrypt: z.boolean().default(false), +}); + +const setSchema = z.object({ + key: z.string(), + value: z.any().optional(), + filePicker: z.enum(["file", "folder"]).optional(), + encrypt: z.boolean().default(false), +}); + +const addSchema = z.object({ + key: z.string(), + value: z.any().optional(), + filePicker: z.enum(["file", "folder"]).optional(), +}); + +const deleteSchema = z.object({ + key: z.string(), + index: z.number().nonnegative(), + encrypted: z.boolean().default(false), +}); + +export const settingsRouter = router({ + get: publicProcedure.input(getSchema).query(async ({ input }) => { + try { + if (input.decrypt) { + return keytar.getPassword("walltone", input.key); + } + return store.get(input.key); + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to get value ${input.key}`, + cause: error, + }); + } + }), + + set: publicProcedure.input(setSchema).mutation(async ({ input }) => { + try { + if (input.filePicker) { + const selectedPath = await filePicker(input.filePicker); + if (!selectedPath) return; + input.value = selectedPath; + } + + if (input.encrypt) { + await keytar.setPassword("walltone", input.key, String(input.value ?? "")); + } else { + store.set(input.key, input.value); + } + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to set value at ${input.key}`, + cause: error, + }); + } + }), + + add: publicProcedure.input(addSchema).mutation(async ({ input }) => { + try { + if (input.filePicker) { + const selectedPath = await filePicker(input.filePicker); + if (!selectedPath) return; + input.value = selectedPath; + } + const targetArray = store.get(input.key) as unknown[]; + if (!Array.isArray(targetArray)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Cannot add to target of type ${typeof targetArray}`, + }); + } + + targetArray.push(input.value); + store.set(input.key, targetArray); + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to add value at ${input.key}`, + cause: error, + }); + } + }), + + delete: publicProcedure.input(deleteSchema).mutation(async ({ input }) => { + try { + if (input.encrypted) { + await keytar.deletePassword("walltone", input.key); + } else { + const arr = store.get(input.key); + if (!Array.isArray(arr)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Cannot delete from target of type ${typeof arr}`, + }); + } + + arr.splice(input.index, 1); + store.set(input.key, arr); + } + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to delete value at ${input.key}`, + cause: error, + }); + } + }), +}); diff --git a/src/electron/main/trpc/routes/theme/generator/base16.ts b/src/electron/main/trpc/routes/theme/generator/base16.ts new file mode 100644 index 0000000..1cbc96c --- /dev/null +++ b/src/electron/main/trpc/routes/theme/generator/base16.ts @@ -0,0 +1,131 @@ +import * as chroma from "chroma.ts"; +import { type SettingsSchema } from "@electron/main/trpc/routes/settings/index.js"; + +type Base16Settings = SettingsSchema["themeGeneration"]["base16"]; + +const base16ColorKeys = [ + "base00", + "base01", + "base02", + "base03", + "base04", + "base05", + "base06", + "base07", + "base08", + "base09", + "base0A", + "base0B", + "base0C", + "base0D", + "base0E", + "base0F", +]; + +const generateBase16Theme = ( + primaryColor: chroma.Color, + palette: chroma.Color[], + base16Settings: Base16Settings +) => { + const colors = adjustColors(primaryColor, palette, base16Settings); + + const darkColors = Object.fromEntries( + base16ColorKeys.map((key, i) => [key, colors.dark[i].hex()]) + ); + const lightColors = Object.fromEntries( + base16ColorKeys.map((key, i) => [key, colors.light[i].hex()]) + ); + + return { + base16: { + dark: darkColors, + light: lightColors, + }, + }; +}; + +const adjustColors = ( + primaryColor: chroma.Color, + palette: chroma.Color[], + base16Settings: Base16Settings +) => { + const backgroundColors = adjustBackgroundColors(primaryColor, palette, base16Settings); + const accentColors = adjustAccentColors(palette, base16Settings); + + return { + dark: [...backgroundColors.dark, ...accentColors], + light: [...backgroundColors.light, ...accentColors], + }; +}; + +const adjustBackgroundColors = ( + primaryColor: chroma.Color, + palette: chroma.Color[], + base16Settings: Base16Settings +) => { + const [bgH] = primaryColor.hsl(); + + // Configuration for dark and light theme saturation/lightness levels. + const levels = { + dark: [ + { s: 0.2, l: 0.05 }, + { s: 0.2, l: 0.07 }, + { s: 0.2, l: 0.15 }, + { s: 0.2, l: 0.3 }, + { s: 0.2, l: 0.6 }, + { s: 0.2, l: 0.7 }, + { s: 0.2, l: 0.8 }, + { s: 0.1, l: 0.9 }, + ], + light: [ + { s: 1.0, l: 0.9 }, + { s: 1.0, l: 0.85 }, + { s: 0.4, l: 0.7 }, + { s: 0.2, l: 0.5 }, + { s: 0.2, l: 0.4 }, + { s: 0.2, l: 0.3 }, + { s: 0.2, l: 0.2 }, + { s: 0.1, l: 0.1 }, + ], + }; + + const createVariant = (variant: "dark" | "light") => + levels[variant].map((level, i) => + palette[i] + .set("hsl.h", bgH) + .set("hsl.s", level.s) + .set("hsl.l", level.l) + .saturate(base16Settings.backgroundSaturation) + .darker(base16Settings.backgroundDarken) + .brighter(base16Settings.backgroundLighten) + ); + + return { + dark: createVariant("dark"), + light: createVariant("light"), + }; +}; + +const adjustAccentColors = (palette: chroma.Color[], base16Settings: Base16Settings) => { + const accentColors = palette.filter((color) => { + const [_, saturation, __] = color.hsl(); + const luminance = color.luminance(); + return ( + saturation >= base16Settings.accentMinSaturation && + saturation <= base16Settings.accentMaxSaturation && + luminance >= base16Settings.accentMinLuminance && + luminance <= base16Settings.accentMaxLuminance + ); + }); + + return (accentColors.length < 8 ? palette : accentColors) + .map((color) => { + return color + .saturate(base16Settings.accentSaturation) + .darker(base16Settings.accentDarken) + .brighter(base16Settings.accentLighten); + }) + .slice(0, 8); +}; + +export default generateBase16Theme; diff --git a/src/electron/main/trpc/routes/theme/generator/index.ts b/src/electron/main/trpc/routes/theme/generator/index.ts new file mode 100644 index 0000000..93e5d98 --- /dev/null +++ b/src/electron/main/trpc/routes/theme/generator/index.ts @@ -0,0 +1,121 @@ +import { parentPort } from "worker_threads"; +import { createCanvas, loadImage } from "canvas"; +import quantize, { ColorMap } from "quantize"; +import { + argbFromRgb, + hexFromArgb, + QuantizerCelebi, + Score, +} from "@material/material-color-utilities"; +import * as chroma from "chroma.ts"; +import { type SettingsSchema } from "@electron/main/trpc/routes/settings/index.js"; +import generateMaterialTheme from "./material.js"; +import generateBase16Theme from "./base16.js"; + +type Base16Settings = SettingsSchema["themeGeneration"]["base16"]; +type MaterialPixel = number; +type QuantizePixel = [number, number, number]; +type QuantizeLib = "material" | "quantize"; + +const getBytesFromImageSrc = async (imageSrc: string) => { + const image = await loadImage(imageSrc); + const canvas = createCanvas(image.width, image.height); + const context = canvas.getContext("2d"); + context.drawImage(image, 0, 0); + return context.getImageData(0, 0, image.width, image.height).data; +}; + +const getPixelsFromBytes = ( + imageBytes: Uint8ClampedArray, + quality: number, + pixelsFormat: QuantizeLib +) => { + const pixelCount = imageBytes.length / 4; + const pixels = []; + + for (let i = 0; i < pixelCount; i += quality) { + const offset = i * 4; + const r = imageBytes[offset + 0], + g = imageBytes[offset + 1], + b = imageBytes[offset + 2], + a = imageBytes[offset + 3]; + + // Skip transparent or nearly transparent and nearly white pixels + if (a < 125 || (r > 250 && g > 250 && b > 250)) continue; + + if (pixelsFormat === "material") pixels.push(argbFromRgb(r, g, b)); + else pixels.push([r, g, b]); + } + + return pixels; +}; + +const getPrimaryColorFromPixels = ( + pixels: MaterialPixel[] | QuantizePixel[], + quantizeLib: QuantizeLib +) => { + if (quantizeLib === "material") { + const result = QuantizerCelebi.quantize(pixels as MaterialPixel[], 128); + const ranked = Score.score(result); + return chroma.color(hexFromArgb(ranked[0])); + } else { + const result = quantize(pixels as QuantizePixel[], 4) as ColorMap; + return chroma.color(result.palette()[0]); + } +}; + +const getPaletteFromPixels = ( + pixels: MaterialPixel[] | QuantizePixel[], + count: number, + quantizeLib: QuantizeLib +): chroma.Color[] => { + if (quantizeLib === "material") { + const materialPalette = QuantizerCelebi.quantize(pixels as MaterialPixel[], count); + return Array.from(materialPalette.keys()).map((color) => chroma.color(hexFromArgb(color))); + } else { + const quantizePalette = quantize(pixels as QuantizePixel[], count) as ColorMap; + return quantizePalette.palette().map((color) => chroma.color(color)); + } +}; + +export const generateThemes = async ( + imageSrc: string, + quantizeLib: QuantizeLib, + base16Settings: Base16Settings +) => { + const bytes = await getBytesFromImageSrc(imageSrc); + const pixels = + quantizeLib === "material" + ? (getPixelsFromBytes(bytes, 1, "material") as MaterialPixel[]) + : (getPixelsFromBytes(bytes, 1, "quantize") as QuantizePixel[]); + const primaryColor = getPrimaryColorFromPixels(pixels, quantizeLib); + const palette = getPaletteFromPixels(pixels, 128, quantizeLib); + + const base16Theme = generateBase16Theme(primaryColor, palette, base16Settings); + const materialTheme = generateMaterialTheme(primaryColor); + + return { + ...base16Theme, + ...materialTheme, + }; +}; + +parentPort?.on( + "message", + async ({ + imageSrc, + quantizeLibrary, + base16Settings, + }: { + quantizeLibrary: QuantizeLib; + imageSrc: string; + base16Settings: Base16Settings; + }) => { + try { + const themes = await generateThemes(imageSrc, quantizeLibrary, base16Settings); + parentPort?.postMessage({ status: "success", data: themes }); + } catch (error) { + parentPort?.postMessage({ status: "error", error: (error as Error).message }); + } + } +); diff --git a/src/electron/main/trpc/routes/theme/generator/material.ts b/src/electron/main/trpc/routes/theme/generator/material.ts new file mode 100644 index 0000000..a7514f3 --- /dev/null +++ b/src/electron/main/trpc/routes/theme/generator/material.ts @@ -0,0 +1,29 @@ +import { + Theme as MaterialTheme, + argbFromHex, + hexFromArgb, + themeFromSourceColor, +} from "@material/material-color-utilities"; +import * as chroma from "chroma.ts"; + +const generateMaterialTheme = (primaryColor: chroma.Color) => { + const theme = themeFromSourceColor(argbFromHex(primaryColor.hex())); + + return { + material: { + dark: getThemeColors(theme, "dark"), + light: getThemeColors(theme, "light"), + }, + }; +}; + +const getThemeColors = (theme: MaterialTheme, variant: "light" | "dark") => { + const colors: Record = {}; + for (const [key, value] of Object.entries(theme.schemes[variant].toJSON())) { + const color = chroma.color(hexFromArgb(value)).hex(); + colors[key] = color; + } + return colors; +}; + +export default generateMaterialTheme; diff --git a/src/electron/main/trpc/routes/theme/index.ts b/src/electron/main/trpc/routes/theme/index.ts new file mode 100644 index 0000000..1919d3b --- /dev/null +++ b/src/electron/main/trpc/routes/theme/index.ts @@ -0,0 +1,231 @@ +import path from "path"; +import { promises as fs } from "fs"; +import { Worker } from "worker_threads"; +import z from "zod"; +import { TRPCError } from "@trpc/server"; +import { color } from "chroma.ts"; +import { execute, santize, renderString } from "@electron/main/lib/index.js"; +import { publicProcedure, router } from "@electron/main/trpc/index.js"; +import { caller } from "@electron/main/trpc/routes/index.js"; +import { type SettingsSchema } from "@electron/main/trpc/routes/settings/index.js"; + +const hexColor = () => z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format"); + +const base16VariantSchema = z.object({ + base00: hexColor(), + base01: hexColor(), + base02: hexColor(), + base03: hexColor(), + base04: hexColor(), + base05: hexColor(), + base06: hexColor(), + base07: hexColor(), + base08: hexColor(), + base09: hexColor(), + base0A: hexColor(), + base0B: hexColor(), + base0C: hexColor(), + base0D: hexColor(), + base0E: hexColor(), + base0F: hexColor(), +}); + +const materialVariantSchema = z.object({ + primary: hexColor(), + onPrimary: hexColor(), + primaryContainer: hexColor(), + onPrimaryContainer: hexColor(), + secondary: hexColor(), + onSecondary: hexColor(), + secondaryContainer: hexColor(), + onSecondaryContainer: hexColor(), + tertiary: hexColor(), + onTertiary: hexColor(), + tertiaryContainer: hexColor(), + onTertiaryContainer: hexColor(), + error: hexColor(), + onError: hexColor(), + errorContainer: hexColor(), + onErrorContainer: hexColor(), + background: hexColor(), + onBackground: hexColor(), + surface: hexColor(), + onSurface: hexColor(), + surfaceVariant: hexColor(), + onSurfaceVariant: hexColor(), + outline: hexColor(), + outlineVariant: hexColor(), + shadow: hexColor(), + scrim: hexColor(), + inverseSurface: hexColor(), + inverseOnSurface: hexColor(), + inversePrimary: hexColor(), +}); + +const themeSchema = z.object({ + base16: z.object({ + dark: base16VariantSchema, + light: base16VariantSchema, + }), + material: z.object({ + dark: materialVariantSchema, + light: materialVariantSchema, + }), +}); + +const generateSchema = z.object({ + imageSrc: z.string(), +}); + +const setSchema = z.object({ + wallpaper: z.any(), + theme: themeSchema, +}); + +export type ThemeType = "base16" | "material"; +export type ThemePolarity = "dark" | "light"; +export type Theme = z.infer; + +function themeToChroma(theme: T): T { + if (typeof theme === "string" && /^#[0-9a-fA-F]{6}$/.test(theme)) { + const c = color(theme); + c.toString = () => c.hex(); + return c as unknown as T; + } + + if (Array.isArray(theme)) { + return theme.map((item) => themeToChroma(item)) as unknown as T; + } + + if (typeof theme === "object" && theme !== null) { + const result = {} as { [K in keyof T]: T[K] }; + for (const key in theme) { + if (Object.prototype.hasOwnProperty.call(theme, key)) { + result[key as keyof T] = themeToChroma(theme[key as keyof T]); + } + } + return result as T; + } + + return theme; +} + +export const themeRouter = router({ + generate: publicProcedure + .input(generateSchema) + .output(themeSchema) + .query(async ({ input }) => { + const imageSrc = input.imageSrc.replace("image://", "").replace("video://", ""); + const quantizeLibrary = await caller.settings.get({ + key: "themeGeneration.quantizeLibrary", + }); + const base16Settings = await caller.settings.get({ key: "themeGeneration.base16" }); + + return await new Promise((resolve, reject) => { + const workerPath = path.join(import.meta.dirname, "theme-generator.js"); + const worker = new Worker(workerPath); + + worker.on("message", (event) => { + const result = event.data; + if (event.status === "success") { + resolve(result); + } else { + reject(new Error(result.error || "Worker failed with an unknown error.")); + } + worker.terminate(); + }); + + worker.on("error", (error) => { + reject(error); + worker.terminate(); + }); + + worker.postMessage({ imageSrc, quantizeLibrary, base16Settings }); + }); + }), + + set: publicProcedure.input(setSchema).mutation(async ({ input }) => { + const templates = (await caller.settings.get({ + key: "themeOutput.templates", + })) as SettingsSchema["themeOutput"]["templates"]; + + if (!templates) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Templates are not set.", + }); + + await Promise.all( + templates.map(async (tpl) => { + if (!tpl.src || !tpl.dest) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invalid template configuration: ${JSON.stringify(tpl)}`, + }); + } + + try { + const content = await fs.readFile(tpl.src, "utf-8"); + const rendered = await renderString(content, { + wallpaper: { + ...input.wallpaper, + id: santize(input.wallpaper.id), + name: santize(input.wallpaper.name), + }, + theme: themeToChroma(input.theme), + }); + + try { + const destination = await renderString(tpl.dest, { + wallpaper: { + ...input.wallpaper, + id: santize(input.wallpaper.id), + name: santize(input.wallpaper.name), + }, + theme: themeToChroma(input.theme), + }); + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.writeFile(destination, rendered, "utf-8"); + + if (tpl.postHook) { + try { + const postHook = await renderString(tpl.postHook, { + wallpaper: { + ...input.wallpaper, + id: santize(input.wallpaper.id), + name: santize(input.wallpaper.name), + }, + theme: themeToChroma(input.theme), + }); + const [cmd, ...args] = postHook.split(" "); + await execute({ command: cmd, args, shell: true }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error occurred"; + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Error running post-hook command: ${tpl.postHook}: ${errorMessage}`, + cause: error, + }); + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Error writing file to ${tpl.dest}: ${errorMessage}`, + cause: error, + }); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Error reading template file ${tpl.src}: ${errorMessage}`, + cause: error, + }); + } + }) + ); + }), +}); diff --git a/src/electron/main/trpc/routes/theme.ts b/src/electron/main/trpc/routes/wallpaper/index.ts similarity index 72% rename from src/electron/main/trpc/routes/theme.ts rename to src/electron/main/trpc/routes/wallpaper/index.ts index 6ffd929..7534301 100644 --- a/src/electron/main/trpc/routes/theme.ts +++ b/src/electron/main/trpc/routes/wallpaper/index.ts @@ -2,11 +2,10 @@ import path from "path"; import { promises as fs } from "fs"; import z from "zod"; import { TRPCError } from "@trpc/server"; -import { color } from "chroma.ts"; -import { execute, killProcess, santize } from "@electron/main/lib/index.js"; +import { execute, killProcess, santize, renderString } from "@electron/main/lib/index.js"; import { publicProcedure, router } from "@electron/main/trpc/index.js"; -import { caller } from "./base.js"; -import { settingKeySchema } from "./settings.js"; +import { caller } from "@electron/main/trpc/routes/index.js"; +import { type SettingsSchema, type SettingKey } from "@electron/main/trpc/routes/settings/index.js"; const SUPPORTED_IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"]; const SUPPORTED_VIDEO_EXTENSIONS = [".mp4", ".mkv", ".webm", ".avi", ".mov"]; @@ -15,30 +14,53 @@ const CAGE_INIT_TIME = 5; const CAGE_SCREENSHOT_PATH = "/tmp/walltone-wallpaper-screenshot.png"; export interface BaseWallpaper { + type: "image" | "video" | "wallpaper-engine" | "api"; id: string; name: string; previewPath: string; } -export interface DownloadableWallpaper extends BaseWallpaper { +export interface ApiWallpaper extends BaseWallpaper { + type: "api"; downloadUrl: string; } -export interface LibraryWallpaper extends BaseWallpaper { - type: "image" | "video" | "wallpaper-engine"; +interface ImageWallpaper extends BaseWallpaper { + type: "image"; path: string; dateAdded: number; tags: string[]; } -export interface WallpaperData { - data: BaseWallpaper[]; +interface VideoWallpaper extends BaseWallpaper { + type: "video"; + path: string; + dateAdded: number; + tags: string[]; +} + +interface WallpaperEngineWallpaper extends BaseWallpaper { + type: "wallpaper-engine"; + path: string; + dateAdded: number; + tags: string[]; + workshopId: string; + file: string; + sceneType: string; +} + +export type LibraryWallpaper = ImageWallpaper | VideoWallpaper | WallpaperEngineWallpaper; + +export interface WallpaperData { + data: T[]; currentPage: number; prevPage: number | null; nextPage: number | null; + totalItems: number; + totalPages: number; } -const searchWallpapesSchema = z.object({ +const searchWallpapersSchema = z.object({ type: z.enum(["image", "video", "wallpaper-engine", "all"]), page: z.number().min(1).default(1), limit: z.number().min(1).default(10), @@ -81,19 +103,14 @@ const setWallpaperSchema = z.object({ .optional(), }); -const setThemeSchema = z.object({ - wallpaper: z.any(), - theme: z.any(), -}); - -export const themeRouter = router({ - searchWallpapers: publicProcedure.input(searchWallpapesSchema).query(async ({ input }) => { +export const wallpaperRouter = router({ + search: publicProcedure.input(searchWallpapersSchema).query(async ({ input }) => { const wallpapers: LibraryWallpaper[] = []; if (input.type === "image" || input.type === "all") { const imageWallpapers = await getMediaWallpapers( "image", - "image.wallpaperFolders", + "wallpaperSources.imageFolders", SUPPORTED_IMAGE_EXTENSIONS ); wallpapers.push(...imageWallpapers); @@ -102,7 +119,7 @@ export const themeRouter = router({ if (input.type === "video" || input.type === "all") { const videoWallpapers = await getMediaWallpapers( "video", - "video.wallpaperFolders", + "wallpaperSources.videoFolders", SUPPORTED_VIDEO_EXTENSIONS ); wallpapers.push(...videoWallpapers); @@ -124,7 +141,7 @@ export const themeRouter = router({ return paginateData(sortedWallpapers, input.page, input.limit); }), - setWallpaper: publicProcedure.input(setWallpaperSchema).mutation(async ({ input }) => { + set: publicProcedure.input(setWallpaperSchema).mutation(async ({ input }) => { killProcess("swaybg"); killProcess("mpvpaper"); killProcess("linux-wallpaperengine"); @@ -143,9 +160,9 @@ export const themeRouter = router({ break; case "wallpaper-engine": { const assetsPath = await caller.settings.get({ - key: "wallpaperEngine.assetsFolder", + key: "wallpaperSources.wallpaperEngineAssetsFolder", }); - if (!assetsPath) + if (typeof assetsPath !== "string" || !assetsPath) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Wallpaper Engine assets folder is not set.", @@ -174,112 +191,33 @@ export const themeRouter = router({ } }), - restoreWallpaperOnStart: publicProcedure.mutation(async () => { + restoreOnStart: publicProcedure.mutation(async () => { const restoreOnStart = await caller.settings.get({ - key: "theme.restoreOnStart", + key: "app.restoreWallpaperOnStart", }); if (!restoreOnStart) return; - const lastWallpaperCmd = (await caller.settings.get({ - key: "theme.lastWallpaperCmd", - })) as { command: string; args: string[] } | undefined; + const lastWallpaperCmd = await caller.settings.get({ + key: "internal.lastWallpaperCmd", + }); - if (lastWallpaperCmd) { + if (lastWallpaperCmd.command) { killProcess("swaybg"); killProcess("mpvpaper"); killProcess("linux-wallpaperengine"); execute(lastWallpaperCmd); } }), - - setTheme: publicProcedure.input(setThemeSchema).mutation(async ({ input }) => { - const templates = - ((await caller.settings.get({ - key: "theme.templates", - })) as { src: string; dest: string; postHook: string }[]) || []; - if (!templates) - throw new TRPCError({ - code: "NOT_FOUND", - message: "Templates are not set.", - }); - - await Promise.all( - templates.map(async (tpl) => { - if (!tpl.src || !tpl.dest) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invalid template configuration: ${JSON.stringify(tpl)}`, - }); - } - - try { - const content = await fs.readFile(tpl.src, "utf-8"); - const rendered = await renderString(content, { - wallpaper: { - id: santize(input.wallpaper.id), - name: santize(input.wallpaper.name), - }, - theme: input.theme, - }); - - try { - const destination = await renderString(tpl.dest, { - wallpaper: { - id: santize(input.wallpaper.id), - name: santize(input.wallpaper.name), - }, - theme: input.theme, - }); - await fs.mkdir(path.dirname(destination), { recursive: true }); - await fs.writeFile(destination, rendered, "utf-8"); - - if (tpl.postHook) { - try { - const postHook = await renderString(tpl.postHook, { - wallpaper: input.wallpaper, - theme: input.theme, - }); - const [cmd, ...args] = postHook.split(" "); - await execute({ command: cmd, args, shell: true }); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Unknown error occurred"; - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Error running post-hook command: ${tpl.postHook}: ${errorMessage}`, - cause: error, - }); - } - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Error writing file to ${tpl.dest}: ${errorMessage}`, - cause: error, - }); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Error reading template file ${tpl.src}: ${errorMessage}`, - cause: error, - }); - } - }) - ); - }), }); -const paginateData = ( - data: LibraryWallpaper[], +const paginateData = ( + data: T[], page: number, itemsPerPage: number -): WallpaperData => { +): WallpaperData => { const currentPage = page; const totalItems = data.length; - const numberOfPages = Math.ceil(totalItems / itemsPerPage); + const totalPages = Math.ceil(totalItems / itemsPerPage); const startIndex = (page - 1) * itemsPerPage; const endIndex = startIndex + itemsPerPage; const paginatedData = data.slice(startIndex, endIndex); @@ -288,19 +226,21 @@ const paginateData = ( data: paginatedData, currentPage, prevPage: currentPage > 1 ? currentPage - 1 : null, - nextPage: currentPage < numberOfPages ? currentPage + 1 : null, + nextPage: currentPage < totalPages ? currentPage + 1 : null, + totalItems, + totalPages, }; }; const getMediaWallpapers = async ( mediaType: "image" | "video", - settingsKey: z.infer, + settingsKey: SettingKey, fileTypes: string[] ) => { const wallpapers: LibraryWallpaper[] = []; - const folders = (await caller.settings.get({ + const folders = await caller.settings.get({ key: settingsKey, - })) as string[]; + }); if (!folders || !Array.isArray(folders) || folders.length === 0) { throw new TRPCError({ @@ -355,10 +295,10 @@ const searchForFiles = async ( }; const getWallpaperEngineWallpapers = async () => { - const wallpapers: LibraryWallpaper[] = []; - const folders = (await caller.settings.get({ - key: "wallpaperEngine.wallpaperFolders", - })) as string[]; + const wallpapers: WallpaperEngineWallpaper[] = []; + const folders = await caller.settings.get({ + key: "wallpaperSources.wallpaperEngineFolders", + }); if (!folders || !Array.isArray(folders) || folders.length === 0) { throw new TRPCError({ code: "NOT_FOUND", @@ -468,7 +408,7 @@ const setImageWallpaper = async ( }); await caller.settings.set({ - key: "theme.lastWallpaperCmd", + key: "internal.lastWallpaperCmd", value: { command: "swaybg", args, @@ -532,7 +472,7 @@ const setVideoWallpaper = async ( args.push(videoPath); await caller.settings.set({ - key: "theme.lastWallpaperCmd", + key: "internal.lastWallpaperCmd", value: { command: "mpvpaper", args, @@ -634,8 +574,8 @@ const copyWallpaperToDestinations = async ( wallpaperPath: string ) => { const wallpaperDestinations = (await caller.settings.get({ - key: "theme.wallpaperCopyDestinations", - })) as string[]; + key: "themeOutput.wallpaperCopyDestinations", + })) as SettingsSchema["themeOutput"]["wallpaperCopyDestinations"]; await Promise.all( wallpaperDestinations.map(async (destination) => { @@ -650,19 +590,3 @@ const copyWallpaperToDestinations = async ( }) ); }; - -const renderString = async (content: string, context: Record) => { - return content.replace(/\$\{([\s\S]+?)\}/g, (_, expr) => { - try { - const fn = new Function(...Object.keys(context), "color", `return (${expr})`); - return fn(...Object.values(context), color); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to evaluate: ${expr}: ${errorMessage}`, - cause: error, - }); - } - }); -}; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9d0fa22..f65d250 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2,11 +2,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ThemeProvider } from "@renderer/providers/theme/provider.js"; import { Toaster } from "@renderer/components/ui/sonner.js"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@renderer/components/ui/tabs.js"; +import { CurrentTabProvider } from "@renderer/providers/current-tab/provider.js"; +import { useCurrentTab } from "@renderer/providers/current-tab/hook.js"; import ExploreTab from "@renderer/tabs/explore/index.js"; import SettingsTab from "@renderer/tabs/settings/index.js"; import LibraryTab from "@renderer/tabs/library/index.js"; -import { CurrentTabProvider } from "./providers/current-tab/provider.js"; -import { useCurrentTab } from "./providers/current-tab/hook.js"; const queryClient = new QueryClient(); diff --git a/src/renderer/components/wallpaper-dialog/apply-dialog.tsx b/src/renderer/components/wallpaper-dialog/apply-dialog.tsx index 4991eb6..d5cd1c4 100644 --- a/src/renderer/components/wallpaper-dialog/apply-dialog.tsx +++ b/src/renderer/components/wallpaper-dialog/apply-dialog.tsx @@ -1,5 +1,7 @@ import React from "react"; import { Check, Monitor as MonitorIcon, Loader2 } from "lucide-react"; +import { type Monitor } from "@electron/main/trpc/routes/monitor/index.js"; +import { type BaseWallpaper } from "@electron/main/trpc/routes/wallpaper/index.js"; import { DialogContent, DialogDescription, @@ -8,8 +10,6 @@ import { DialogFooter, DialogClose, } from "@renderer/components/ui/dialog.js"; -import { type Monitor } from "@electron/main/trpc/routes/monitor.js"; -import { type BaseWallpaper } from "@electron/main/trpc/routes/theme.js"; import { Button } from "@renderer/components/ui/button.js"; import { Checkbox } from "@renderer/components/ui/checkbox.js"; import { Card, CardContent, CardHeader, CardTitle } from "@renderer/components/ui/card.js"; @@ -32,14 +32,14 @@ import { SetDynamicControlValues, } from "./types.js"; -const ApplyWallpaperDialog = ({ +const ApplyWallpaperDialog = ({ wallpaper, onApply, scalingOptions, controlDefinitions, }: { - wallpaper: BaseWallpaper; - onApply?: OnWallpaperApply; + wallpaper: T; + onApply?: OnWallpaperApply; scalingOptions?: { key: string; text: string }[]; controlDefinitions?: DynamicControlDefinition[]; }) => { diff --git a/src/renderer/components/wallpaper-dialog/hooks.tsx b/src/renderer/components/wallpaper-dialog/hooks.tsx index 68c8e64..7c2b75a 100644 --- a/src/renderer/components/wallpaper-dialog/hooks.tsx +++ b/src/renderer/components/wallpaper-dialog/hooks.tsx @@ -1,34 +1,89 @@ import React from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { type BaseWallpaper } from "@electron/main/trpc/routes/theme.js"; +import type { ThemeType, ThemePolarity, Theme } from "@electron/main/trpc/routes/theme/index.js"; +import { type BaseWallpaper } from "@electron/main/trpc/routes/wallpaper/index.js"; import { type OnWallpaperApply, type OnWallpaperDownload, } from "@renderer/components/wallpapers-grid/types.js"; -import generateThemes, { - ThemeTypes, - ThemeVariants, - type Theme, -} from "@renderer/lib/theme/index.js"; + import { client } from "@renderer/lib/trpc.js"; import { DynamicControlValues } from "./types.js"; -export const useThemeGeneration = () => { +export const getSrcForThemeGeneration = (imageSrc: string): Promise => { + return new Promise((resolve, reject) => { + if (imageSrc.startsWith("video://")) { + const video = document.createElement("video"); + video.src = imageSrc; + video.crossOrigin = "anonymous"; // Required for canvas security + video.muted = true; + + const onSeeked = () => { + try { + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) { + return reject(new Error("Could not get 2D canvas context.")); + } + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + // The dataURL is a base64 string representing the image. + const dataUrl = canvas.toDataURL("image/png"); + resolve(dataUrl); + } catch (e) { + reject(e); + } finally { + // Clean up to prevent memory leaks. + video.removeEventListener("seeked", onSeeked); + video.removeEventListener("error", onError); + } + }; + + const onError = (e: Event | string) => { + reject(new Error(`Failed to load video for theme generation: ${e}`)); + video.removeEventListener("seeked", onSeeked); + video.removeEventListener("error", onError); + }; + + video.addEventListener("seeked", onSeeked); + video.addEventListener("error", onError); + + // Once the video metadata is loaded, we can seek to a frame. + video.onloadedmetadata = () => { + video.currentTime = 1; // Seek to the 1-second mark. + }; + + video.load(); + } else { + resolve(imageSrc); + } + }); +}; + +export const useThemeGeneration = (imageSrc: string, enabled: boolean) => { const [theme, setTheme] = React.useState(); - const generateThemeFromImage = React.useCallback( - async (element: HTMLImageElement | HTMLVideoElement) => { - const generatedTheme = await generateThemes(element); - setTheme(generatedTheme); + const { data } = useQuery({ + queryKey: ["theme", imageSrc], + enabled, + queryFn: async () => { + return await client.theme.generate.query({ + imageSrc: await getSrcForThemeGeneration(imageSrc), + }); }, - [] - ); + }); + + React.useEffect(() => { + if (data) { + setTheme(data); + } + }, [data]); return { theme, setTheme, - generateThemeFromImage, }; }; @@ -60,8 +115,8 @@ export const useColorEditor = () => { }; export const useThemeEditor = (theme: Theme | undefined, selectedColorKey: string | undefined) => { - const [activeTheme, setActiveTheme] = React.useState("base16"); - const [activeVariant, setActiveVariant] = React.useState("dark"); + const [activeTheme, setActiveTheme] = React.useState("base16"); + const [activeVariant, setActiveVariant] = React.useState("dark"); const updateThemeColor = React.useCallback( (newColor: string) => { @@ -181,9 +236,9 @@ export const useMonitorSelection = (scalingOptions?: { key: string; text: string }; }; -export const useWallpaperActions = (wallpaper: BaseWallpaper) => { +export const useWallpaperActions = (wallpaper: T) => { const downloadMutation = useMutation({ - mutationFn: async (onDownload: OnWallpaperDownload) => { + mutationFn: async (onDownload: OnWallpaperDownload) => { return await onDownload(wallpaper); }, onSuccess: () => { @@ -195,8 +250,8 @@ export const useWallpaperActions = (wallpaper: BaseWallpaper) => { }); const setThemeMutation = useMutation({ - mutationFn: async (theme: GeneratedTheme) => { - await client.theme.setTheme.mutate({ + mutationFn: async (theme: Theme) => { + await client.theme.set.mutate({ wallpaper: wallpaper, theme: theme, }); @@ -215,7 +270,7 @@ export const useWallpaperActions = (wallpaper: BaseWallpaper) => { monitorConfigs, controlValues, }: { - onApply: OnWallpaperApply; + onApply: OnWallpaperApply; monitorConfigs: { name: string; scalingMethod: string }[]; controlValues?: DynamicControlValues; }) => { diff --git a/src/renderer/components/wallpaper-dialog/index.tsx b/src/renderer/components/wallpaper-dialog/index.tsx index 27c013f..d01affd 100644 --- a/src/renderer/components/wallpaper-dialog/index.tsx +++ b/src/renderer/components/wallpaper-dialog/index.tsx @@ -2,7 +2,8 @@ import React from "react"; import { Palette, Save, Wallpaper, Copy, X, PaletteIcon } from "lucide-react"; import { toast } from "sonner"; import { Slider } from "@uiw/react-color"; -import { type BaseWallpaper } from "@electron/main/trpc/routes/theme.js"; +import type { ThemeType, ThemePolarity, Theme } from "@electron/main/trpc/routes/theme/index.js"; +import { type BaseWallpaper } from "@electron/main/trpc/routes/wallpaper/index.js"; import { DialogContent, DialogDescription, @@ -17,7 +18,6 @@ import { Badge } from "@renderer/components/ui/badge.js"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@renderer/components/ui/tabs.js"; import { ScrollArea } from "@renderer/components/ui/scroll-area.js"; import LoadingButton from "@renderer/components/ui/loading-button.js"; -import { Theme, ThemeTypes, ThemeVariants } from "@renderer/lib/theme/index.js"; import { type OnWallpaperApply, type OnWallpaperDownload, @@ -31,20 +31,22 @@ import { import ApplyWallpaperDialog from "./apply-dialog.js"; import { type DynamicControlDefinition } from "./types.js"; -const WallpaperDialog = ({ +const WallpaperDialog = ({ wallpaper, onApply, onDownload, scalingOptions, controlDefinitions, + isOpen, }: { - wallpaper: BaseWallpaper; - onApply?: OnWallpaperApply; - onDownload?: OnWallpaperDownload; + wallpaper: T; + onApply?: OnWallpaperApply; + onDownload?: OnWallpaperDownload; scalingOptions?: { key: string; text: string }[]; controlDefinitions?: DynamicControlDefinition[]; + isOpen: boolean; }) => { - const { theme, setTheme, generateThemeFromImage } = useThemeGeneration(); + const { theme, setTheme } = useThemeGeneration(wallpaper.previewPath, isOpen); const { selectedColor, selectedColorKey, selectColor, updateColor, clearSelection } = useColorEditor(); const { activeTheme, setActiveTheme, activeVariant, setActiveVariant, updateThemeColor } = @@ -70,76 +72,78 @@ const WallpaperDialog = ({ return ( -
-
-
- -
-
- + +
+
-
- setActiveTheme(value as ThemeTypes)} - className="flex flex-1 flex-col" - > - - Base 16 - Material - +
+
+ +
-
- -
+
+ setActiveTheme(value as ThemeType)} + className="flex flex-1 flex-col" + > + + Base 16 + Material + -
- - + - +
- - - -
- +
+ + + + + + + +
+ +
-
-
- -
+
+ +
+ ); }; -const Header = ({ wallpaper }: { wallpaper: BaseWallpaper }) => { +const Header = ({ wallpaper }: { wallpaper: T }) => { return ( @@ -151,28 +155,7 @@ const Header = ({ wallpaper }: { wallpaper: BaseWallpaper }) => { ); }; -const WallpaperImage = ({ - wallpaper, - onThemeGenerated, -}: { - wallpaper: BaseWallpaper; - onThemeGenerated: (element: HTMLImageElement | HTMLVideoElement) => Promise; -}) => { - const imageRef = React.useRef(null); - const videoRef = React.useRef(null); - - const handleImageLoad = React.useCallback(async () => { - if (imageRef.current) { - await onThemeGenerated(imageRef.current); - } - }, [onThemeGenerated]); - - const handleVideoLoad = React.useCallback(async () => { - if (videoRef.current) { - await onThemeGenerated(videoRef.current); - } - }, [onThemeGenerated]); - +const WallpaperImage = ({ wallpaper }: { wallpaper: T }) => { return ( @@ -181,8 +164,6 @@ const WallpaperImage = ({ className="h-48 w-full rounded-lg object-cover sm:h-56 md:h-64" src={wallpaper.previewPath} alt={wallpaper.name} - ref={imageRef} - onLoad={handleImageLoad} /> ) : ( )} @@ -262,9 +241,9 @@ const ThemePanel = ({ selectedColor, isLoading = false, }: { - theme?: Theme[ThemeTypes]; - activeVariant: ThemeVariants; - setActiveVariant: (variant: ThemeVariants) => void; + theme?: Theme[ThemeType]; + activeVariant: ThemePolarity; + setActiveVariant: (variant: ThemePolarity) => void; onColorSelect: (colorValue: string, colorKey: string) => void; selectedColor?: string; isLoading?: boolean; @@ -335,8 +314,8 @@ const ThemeColors = ({ onColorSelect, selectedColor, }: { - theme?: Theme[ThemeTypes]; - activeVariant: ThemeVariants; + theme?: Theme[ThemeType]; + activeVariant: ThemePolarity; onColorSelect: (colorValue: string, colorKey: string) => void; selectedColor?: string; }) => { @@ -376,7 +355,7 @@ const ThemeColors = ({ ); }; -const WallpaperActions = ({ +const WallpaperActions = ({ wallpaper, theme, onApply, @@ -384,10 +363,10 @@ const WallpaperActions = ({ scalingOptions, controlDefinitions, }: { - wallpaper: BaseWallpaper; + wallpaper: T; theme?: Theme; - onApply?: OnWallpaperApply; - onDownload?: OnWallpaperDownload; + onApply?: OnWallpaperApply; + onDownload?: OnWallpaperDownload; scalingOptions?: { key: string; text: string }[]; controlDefinitions?: DynamicControlDefinition[]; }) => { @@ -397,7 +376,7 @@ const WallpaperActions = ({
setThemeMutation.mutate(theme)} + onClick={() => setThemeMutation.mutate(theme!)} className="flex-1 text-sm" disabled={!theme} > diff --git a/src/renderer/components/wallpaper-dialog/types.ts b/src/renderer/components/wallpaper-dialog/types.ts index 6db7a56..aef50dd 100644 --- a/src/renderer/components/wallpaper-dialog/types.ts +++ b/src/renderer/components/wallpaper-dialog/types.ts @@ -2,8 +2,8 @@ export interface DynamicControlDefinition { type: "range" | "boolean" | "select"; key: string; title: string; - description?: string; - defaultValue?: string | number | boolean; + description: string; + defaultValue: string | number | boolean; options?: { min?: number; max?: number; @@ -13,9 +13,8 @@ export interface DynamicControlDefinition { } export interface DynamicControlValues { - [key: string]: string | number | boolean | undefined; + [key: string]: string | number | boolean; } -export type SetDynamicControlValues = React.Dispatch< - React.SetStateAction ->; +// export type SetDynamicControlValues = React.Dispatch; +export type SetDynamicControlValues = React.Dispatch>; diff --git a/src/renderer/components/wallpapers-grid/content-components.tsx b/src/renderer/components/wallpapers-grid/content-components.tsx index c527cc3..70e34a1 100644 --- a/src/renderer/components/wallpapers-grid/content-components.tsx +++ b/src/renderer/components/wallpapers-grid/content-components.tsx @@ -10,7 +10,8 @@ import { Settings, CheckCircle2, } from "lucide-react"; -import { BaseWallpaper } from "@electron/main/trpc/routes/theme.js"; +import { BaseWallpaper } from "@electron/main/trpc/routes/wallpaper/index.js"; +import { type SettingKey } from "@electron/main/trpc/routes/settings/index.js"; import { Dialog, DialogTrigger } from "@renderer/components/ui/dialog.js"; import { ScrollArea } from "@renderer/components/ui/scroll-area.js"; import { @@ -29,17 +30,13 @@ import { useCurrentTab } from "@renderer/providers/current-tab/hook.js"; import { cn } from "@renderer/lib/cn.js"; import { ConfigurationRequirement, OnWallpaperApply, OnWallpaperDownload } from "./types.js"; -export const ConfigurationScreen = ({ +export const ConfigurationScreen = ({ requirement, - configValue, isPending, - isError, refetch, }: { - requirement: ConfigurationRequirement; - configValue?: unknown; + requirement: ConfigurationRequirement; isPending: boolean; - isError: boolean; refetch: () => void; }) => { if (isPending) { @@ -61,108 +58,108 @@ export const ConfigurationScreen = ({ ); } - if (isError || !configValue) { - return ( - -
- - -
-
- {React.createElement(requirement.icon, { - className: "h-6 w-6 text-amber-600 dark:text-amber-400", - })} -
-
-
- {requirement.title} - - Setup Required - -
- {requirement.description} + return ( + +
+ + +
+
+ {React.createElement(requirement.icon, { + className: "h-6 w-6 text-amber-600 dark:text-amber-400", + })} +
+
+
+ {requirement.title} + + Setup Required +
+ {requirement.description}
- +
+
- -
-
- -

Setup Instructions

-
+ +
+
+ +

Setup Instructions

+
-
- {requirement.setupInstructions.map((instruction, index) => ( -
-
- {index + 1} -
-

{instruction}

+
+ {requirement.setupInstructions.map((instruction, index) => ( +
+
+ {index + 1}
- ))} -
+

{instruction}

+
+ ))}
+
- + -
-

- - Quick Actions -

+
+

+ + Quick Actions +

-
- {requirement?.actions?.map((action) => ( - - ))} -
+
+ {requirement?.actions?.map((action) => ( + + ))}
+
-
-

- Need help? {requirement.helperText} -

-
- - -
- - ); - } +
+

+ Need help? {requirement.helperText} +

+
+ + +
+ + ); return null; }; -export const Wallpaper = ({ +export const Wallpaper = ({ wallpaper, onWallpaperApply, onWallpaperDownload, scalingOptions, controlDefinitions, }: { - wallpaper: BaseWallpaper; - onWallpaperApply?: OnWallpaperApply; - onWallpaperDownload?: OnWallpaperDownload; + wallpaper: T; + onWallpaperApply?: OnWallpaperApply; + onWallpaperDownload?: OnWallpaperDownload; scalingOptions?: { key: string; text: string }[]; controlDefinitions?: DynamicControlDefinition[]; }) => { + const [isOpen, setIsOpen] = React.useState(false); + return (
- + {wallpaper.type !== "video" ? (
@@ -429,7 +427,7 @@ export const EmptyWallpapers = ({ ); }; -export const WallpaperGrid = ({ +export const WallpaperGrid = ({ isError, error, refetch, @@ -451,11 +449,11 @@ export const WallpaperGrid = ({ failureCount: number; isLoading: boolean; isFetching: boolean; - allWallpapers: BaseWallpaper[]; + allWallpapers: T[]; debouncedInputValue: string; clearSearch: () => void; - onWallpaperApply?: OnWallpaperApply; - onWallpaperDownload?: OnWallpaperDownload; + onWallpaperApply?: OnWallpaperApply; + onWallpaperDownload?: OnWallpaperDownload; scalingOptions?: { key: string; text: string }[]; infiniteScrollRef: (node?: Element | null | undefined) => void; controlDefinitions?: DynamicControlDefinition[]; diff --git a/src/renderer/components/wallpapers-grid/hooks.ts b/src/renderer/components/wallpapers-grid/hooks.ts index e7629b7..6554db3 100644 --- a/src/renderer/components/wallpapers-grid/hooks.ts +++ b/src/renderer/components/wallpapers-grid/hooks.ts @@ -2,8 +2,11 @@ import React from "react"; import { useInView } from "react-intersection-observer"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useDebouncedCallback } from "use-debounce"; +import { type BaseWallpaper } from "@electron/main/trpc/routes/wallpaper/index.js"; +import { type SettingKey, type SettingsSchema } from "@electron/main/trpc/routes/settings/index.js"; import { client } from "@renderer/lib/trpc.js"; import { AppliedFilters, ConfigurationRequirement, WallpapersGridProps } from "./types.js"; +import { DotNotationValueOf } from "node_modules/conf/dist/source/types.js"; export const useWallpaperSearch = () => { const [inputValue, setInputValue] = React.useState(""); @@ -27,8 +30,12 @@ export const useWallpaperSearch = () => { }; }; -export const useWallpaperFilters = (sortingOptions?: { key: string; text: string }[]) => { - const [sorting, setSorting] = React.useState(sortingOptions?.[0]?.key || ""); +export const useWallpaperFilters = ( + sortingOptions?: { key: TSorting; text: string }[] +) => { + const [sorting, setSorting] = React.useState( + sortingOptions?.[0]?.key ?? ("" as TSorting) + ); const [appliedFilters, setAppliedFilters] = React.useState({ arrays: {}, strings: {}, @@ -43,7 +50,9 @@ export const useWallpaperFilters = (sortingOptions?: { key: string; text: string }; }; -export const useConfiguration = (requiresConfiguration?: ConfigurationRequirement) => { +export const useConfiguration = ( + requiresConfiguration?: ConfigurationRequirement +) => { const { data: configValue, isPending: isConfigPending, @@ -71,7 +80,11 @@ export const useConfiguration = (requiresConfiguration?: ConfigurationRequiremen }; }; -export const useWallpaperData = ({ +export const useWallpaperData = < + TWallpaper extends BaseWallpaper, + TSorting extends string, + TConfigKey extends SettingKey, +>({ queryKeys, queryFn, queryEnabled, @@ -82,12 +95,12 @@ export const useWallpaperData = ({ isConfigurationValid, }: { queryKeys: string[]; - queryFn: WallpapersGridProps["queryFn"]; + queryFn: WallpapersGridProps["queryFn"]; queryEnabled: boolean; debouncedInputValue: string; - sorting: string; + sorting: TSorting; appliedFilters: AppliedFilters; - configValue: unknown; + configValue: DotNotationValueOf; isConfigurationValid: boolean; }) => { const { ref, inView } = useInView(); diff --git a/src/renderer/components/wallpapers-grid/index.tsx b/src/renderer/components/wallpapers-grid/index.tsx index 7466279..52bc113 100644 --- a/src/renderer/components/wallpapers-grid/index.tsx +++ b/src/renderer/components/wallpapers-grid/index.tsx @@ -1,4 +1,6 @@ import { RefreshCw } from "lucide-react"; +import { type BaseWallpaper } from "@electron/main/trpc/routes/wallpaper/index.js"; +import { type SettingKey } from "@electron/main/trpc/routes/settings/index.js"; import { Input } from "@renderer/components/ui/input.js"; import { Select, @@ -23,7 +25,7 @@ import { FilterDefinition, } from "./types.js"; -const WallpaperGridControls = ({ +const WallpaperGridControls = ({ inputValue, onInputChange, onSortingChange, @@ -35,8 +37,8 @@ const WallpaperGridControls = ({ }: { inputValue: string; onInputChange: (value: string) => void; - onSortingChange: (value: string) => void; - sortingOptions?: { key: string; text: string }[]; + onSortingChange: (value: TSorting) => void; + sortingOptions?: { key: TSorting; text: string }[]; filterDefinitions?: FilterDefinition[]; appliedFilters: AppliedFilters; setAppliedFilters: SetAppliedFilters; @@ -78,7 +80,11 @@ const WallpaperGridControls = ({ ); }; -const WallpapersGrid = ({ +const WallpapersGrid = < + T extends BaseWallpaper, + TSorting extends string, + TConfigKey extends SettingKey, +>({ queryKeys, queryFn, queryEnabled = true, @@ -89,8 +95,7 @@ const WallpapersGrid = ({ onWallpaperDownload, requiresConfiguration, controlDefinitions, -}: WallpapersGridProps) => { - // Custom hooks for state management +}: WallpapersGridProps) => { const { inputValue, setInputValue, debouncedInputValue, handleSearch, clearSearch } = useWallpaperSearch(); @@ -120,14 +125,17 @@ const WallpapersGrid = ({ isConfigurationValid, }); - // Show configuration requirement screen - if (requiresConfiguration && (isConfigPending || isConfigError || !configValue)) { + if ( + requiresConfiguration && + (isConfigPending || + isConfigError || + !configValue || + (Array.isArray(configValue) && configValue.length === 0)) + ) { return ( ); diff --git a/src/renderer/components/wallpapers-grid/types.ts b/src/renderer/components/wallpapers-grid/types.ts index eb1278d..beb240f 100644 --- a/src/renderer/components/wallpapers-grid/types.ts +++ b/src/renderer/components/wallpapers-grid/types.ts @@ -1,15 +1,22 @@ import { LucideIcon } from "lucide-react"; -import { RouterInputs } from "@electron/main/trpc/routes/base.js"; -import { BaseWallpaper, WallpaperData } from "@electron/main/trpc/routes/theme.js"; -import { DynamicControlValues } from "@renderer/components/wallpaper-dialog/types.js"; +import { type DotNotationValueOf } from "node_modules/conf/dist/source/types.js"; +import { + type BaseWallpaper, + type WallpaperData, +} from "@electron/main/trpc/routes/wallpaper/index.js"; +import { type SettingKey, type SettingsSchema } from "@electron/main/trpc/routes/settings/index.js"; +import { + DynamicControlDefinition, + DynamicControlValues, +} from "@renderer/components/wallpaper-dialog/types.js"; -export type OnWallpaperApply = ( - wallpaper: BaseWallpaper, +export type OnWallpaperApply = ( + wallpaper: T, monitorConfigs: { name: string; scalingMethod: string }[], controlValues?: DynamicControlValues ) => Promise; -export type OnWallpaperDownload = (wallpaper: BaseWallpaper) => Promise; +export type OnWallpaperDownload = (wallpaper: T) => Promise; export interface FilterDefinition { type: "single" | "multiple" | "boolean"; @@ -29,9 +36,9 @@ export interface AppliedFilters { export type SetAppliedFilters = React.Dispatch>; -export interface ConfigurationRequirement { +export interface ConfigurationRequirement { setting: { - key: RouterInputs["settings"]["get"]["key"]; + key: TConfigKey; decrypt?: boolean; }; title: string; @@ -48,18 +55,22 @@ export interface ConfigurationRequirement { }[]; } -export interface WallpapersGridProps { +export interface WallpapersGridProps< + T extends BaseWallpaper, + TSorting extends string, + TConfigKey extends SettingKey, +> { queryKeys: string[]; queryFn: (params: { pageParam: number; query: string; - sorting: string; + sorting: TSorting; appliedFilters?: AppliedFilters; - configValue?: unknown; - }) => Promise; + configValue?: DotNotationValueOf; + }) => Promise>; queryEnabled?: boolean; sortingOptions?: { - key: string; + key: TSorting; text: string; }[]; filterDefinitions?: FilterDefinition[]; @@ -67,8 +78,8 @@ export interface WallpapersGridProps { key: string; text: string; }[]; - onWallpaperApply?: OnWallpaperApply; - onWallpaperDownload?: OnWallpaperDownload; - requiresConfiguration?: ConfigurationRequirement; - controlDefinitions?: DynamicControlValue[]; + onWallpaperApply?: OnWallpaperApply; + onWallpaperDownload?: OnWallpaperDownload; + requiresConfiguration?: ConfigurationRequirement; + controlDefinitions?: DynamicControlDefinition[]; } diff --git a/src/renderer/lib/theme/base16.ts b/src/renderer/lib/theme/base16.ts deleted file mode 100644 index 194b757..0000000 --- a/src/renderer/lib/theme/base16.ts +++ /dev/null @@ -1,136 +0,0 @@ -import * as chroma from "chroma.ts"; -import quantize, { ColorMap, RgbPixel } from "quantize"; - -const base16ColorKeys = [ - "base00", - "base01", - "base02", - "base03", - "base04", - "base05", - "base06", - "base07", - "base08", - "base09", - "base0A", - "base0B", - "base0C", - "base0D", - "base0E", - "base0F", -]; - -const generateBase16Theme = (img: HTMLImageElement, backgroundColor: chroma.Color) => { - const colors = adjustColors(getImageColors(img), backgroundColor); - - const darkColors: Record = {}; - const lightColors: Record = {}; - - base16ColorKeys.forEach((key, index) => { - darkColors[key] = colors.dark[index].hex(); - lightColors[key] = colors.light[index].hex(); - }); - - return { - base16: { - dark: darkColors, - light: lightColors, - }, - }; -}; - -const getImageColors = (img: HTMLImageElement) => { - const canvas = document.createElement("canvas"); - const context = canvas.getContext("2d"); - canvas.width = img.naturalWidth; - canvas.height = img.naturalHeight; - context?.drawImage(img, 0, 0, canvas.width, canvas.height); - const imageData = context?.getImageData(0, 0, canvas.width, canvas.height); - if (!imageData) { - throw new Error("Failed to get image data"); - } - const pixelArray = createPixelArray(imageData.data, img.naturalWidth * img.naturalHeight, 10); - const colorMap = quantize(pixelArray, 100) as ColorMap; - return (colorMap.palette() as RgbPixel[]).map((color) => chroma.color(color)); -}; - -const createPixelArray = ( - imgData: Uint8ClampedArray, - pixelCount: number, - quality: number -): RgbPixel[] => { - const pixelArray: RgbPixel[] = []; - - for (let i = 0, offset, r, g, b, a; i < pixelCount; i = i + quality) { - offset = i * 4; - r = imgData[offset + 0]; - g = imgData[offset + 1]; - b = imgData[offset + 2]; - a = imgData[offset + 3]; - - // If pixel is mostly opaque and not white - if (typeof a === "undefined" || a >= 125) { - if (!(r > 250 && g > 250 && b > 250)) { - pixelArray.push([r, g, b]); - } - } - } - - return pixelArray; -}; - -const adjustColors = (colors: chroma.Color[], backgroundColor: chroma.Color) => { - const backgroundColors = adjustBackgroundColors(colors, backgroundColor); - const accentColors = adjustAccentColors(colors); - - return { - dark: [...backgroundColors.dark, ...accentColors], - light: [...backgroundColors.light, ...accentColors], - }; -}; - -const adjustBackgroundColors = (colors: chroma.Color[], backgroundColor: chroma.Color) => { - const darkBackgroundColors: chroma.Color[] = []; - const lightBackgroundColors: chroma.Color[] = []; - - const [bgH] = backgroundColor.hsl(); - darkBackgroundColors[0] = colors[0].set("hsl.h", bgH).set("hsl.s", 0.2).set("hsl.l", 0.05); - darkBackgroundColors[1] = colors[1].set("hsl.h", bgH).set("hsl.s", 0.2).set("hsl.l", 0.07); - darkBackgroundColors[2] = colors[2].set("hsl.h", bgH).set("hsl.s", 0.2).set("hsl.l", 0.15); - darkBackgroundColors[3] = colors[3].set("hsl.h", bgH).set("hsl.s", 0.2).set("hsl.l", 0.3); - darkBackgroundColors[4] = colors[4].set("hsl.h", bgH).set("hsl.s", 0.2).set("hsl.l", 0.6); - darkBackgroundColors[5] = colors[5].set("hsl.h", bgH).set("hsl.s", 0.2).set("hsl.l", 0.7); - darkBackgroundColors[6] = colors[6].set("hsl.h", bgH).set("hsl.s", 0.2).set("hsl.l", 0.8); - darkBackgroundColors[7] = colors[7].set("hsl.h", bgH).set("hsl.s", 0.1).set("hsl.l", 0.9); - lightBackgroundColors[0] = colors[0].set("hsl.h", bgH).set("hsl.s", 1).set("hsl.l", 0.9); - lightBackgroundColors[1] = colors[1].set("hsl.h", bgH).set("hsl.s", 1).set("hsl.l", 0.85); - lightBackgroundColors[2] = colors[2].set("hsl.h", bgH).set("hsl.s", 0.4).set("hsl.l", 0.7); - lightBackgroundColors[3] = colors[3].set("hsl.h", bgH).set("hsl.s", 0.2).set("hsl.l", 0.5); - lightBackgroundColors[4] = colors[4].set("hsl.h", bgH).set("hsl.s", 0.2).set("hsl.l", 0.4); - lightBackgroundColors[5] = colors[5].set("hsl.h", bgH).set("hsl.s", 0.2).set("hsl.l", 0.3); - lightBackgroundColors[6] = colors[6].set("hsl.h", bgH).set("hsl.s", 0.2).set("hsl.l", 0.2); - lightBackgroundColors[7] = colors[7].set("hsl.h", bgH).set("hsl.s", 0.1).set("hsl.l", 0.1); - - return { - dark: darkBackgroundColors, - light: lightBackgroundColors, - }; -}; - -const adjustAccentColors = (colors: chroma.Color[]) => { - const accentColors = colors.filter((color) => { - const [_, saturation, __] = color.hsl(); - const luminance = color.luminance(); - return saturation > 0.3 && luminance > 0.08 && luminance < 0.8; - }); - - return accentColors.length < 8 - ? colors - : accentColors - .map((color) => { - return color.saturate(0.5); - }) - .slice(0, 8); -}; - -export default generateBase16Theme; diff --git a/src/renderer/lib/theme/index.ts b/src/renderer/lib/theme/index.ts deleted file mode 100644 index cf85d6e..0000000 --- a/src/renderer/lib/theme/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as chroma from "chroma.ts"; -import generateBase16Theme from "./base16.js"; -import generateMaterialTheme from "./material.js"; - -export type ThemeTypes = "base16" | "material"; -export type ThemeVariants = "dark" | "light"; -export interface Theme { - base16: { - dark: Record; - light: Record; - }; - material: { - dark: Record; - light: Record; - }; -} - -const generateThemes = async (mediaElement: HTMLImageElement | HTMLVideoElement) => { - if (mediaElement instanceof HTMLVideoElement) { - const canvas = document.createElement("canvas"); - canvas.width = mediaElement.videoWidth; - canvas.height = mediaElement.videoHeight; - const ctx = canvas.getContext("2d"); - ctx?.drawImage(mediaElement, 0, 0, canvas.width, canvas.height); - - const dataUrl = canvas.toDataURL("image/png"); - mediaElement = new Image(); - mediaElement.src = dataUrl; - } - - // Material is good at getting the primary colors from an image, - // use it for the base16 background colors - as with quantize it - // can be hard to get a good background color. - const materialTheme = await generateMaterialTheme(mediaElement); - const base16Theme = generateBase16Theme( - mediaElement, - chroma.color(materialTheme.material.dark.primary) - ); - - return { - ...base16Theme, - ...materialTheme, - }; -}; - -export default generateThemes; diff --git a/src/renderer/lib/theme/material.ts b/src/renderer/lib/theme/material.ts deleted file mode 100644 index f5164e1..0000000 --- a/src/renderer/lib/theme/material.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { hexFromArgb, themeFromImage } from "@material/material-color-utilities"; - -const generateMaterialTheme = async (img: HTMLImageElement) => { - const theme = await themeFromImage(img); - return { - material: { - dark: { - primary: hexFromArgb(theme.schemes.dark.primary), - onPrimary: hexFromArgb(theme.schemes.dark.onPrimary), - primaryContainer: hexFromArgb(theme.schemes.dark.primaryContainer), - onPrimaryContainer: hexFromArgb(theme.schemes.dark.onPrimaryContainer), - secondary: hexFromArgb(theme.schemes.dark.secondary), - onSecondary: hexFromArgb(theme.schemes.dark.onSecondary), - secondaryContainer: hexFromArgb(theme.schemes.dark.secondaryContainer), - onSecondaryContainer: hexFromArgb(theme.schemes.dark.onSecondaryContainer), - tertiary: hexFromArgb(theme.schemes.dark.tertiary), - onTertiary: hexFromArgb(theme.schemes.dark.onTertiary), - tertiaryContainer: hexFromArgb(theme.schemes.dark.tertiaryContainer), - onTertiaryContainer: hexFromArgb(theme.schemes.dark.onTertiaryContainer), - error: hexFromArgb(theme.schemes.dark.error), - onError: hexFromArgb(theme.schemes.dark.onError), - errorContainer: hexFromArgb(theme.schemes.dark.errorContainer), - onErrorContainer: hexFromArgb(theme.schemes.dark.onErrorContainer), - background: hexFromArgb(theme.schemes.dark.background), - onBackground: hexFromArgb(theme.schemes.dark.onBackground), - surface: hexFromArgb(theme.schemes.dark.surface), - onSurface: hexFromArgb(theme.schemes.dark.onSurface), - surfaceVariant: hexFromArgb(theme.schemes.dark.surfaceVariant), - onSurfaceVariant: hexFromArgb(theme.schemes.dark.onSurfaceVariant), - outline: hexFromArgb(theme.schemes.dark.outline), - outlineVariant: hexFromArgb(theme.schemes.dark.outlineVariant), - shadow: hexFromArgb(theme.schemes.dark.shadow), - scrim: hexFromArgb(theme.schemes.dark.scrim), - inverseSurface: hexFromArgb(theme.schemes.dark.inverseSurface), - inverseOnSurface: hexFromArgb(theme.schemes.dark.inverseOnSurface), - inversePrimary: hexFromArgb(theme.schemes.dark.inversePrimary), - }, - light: { - primary: hexFromArgb(theme.schemes.light.primary), - onPrimary: hexFromArgb(theme.schemes.light.onPrimary), - primaryContainer: hexFromArgb(theme.schemes.light.primaryContainer), - onPrimaryContainer: hexFromArgb(theme.schemes.light.onPrimaryContainer), - secondary: hexFromArgb(theme.schemes.light.secondary), - onSecondary: hexFromArgb(theme.schemes.light.onSecondary), - secondaryContainer: hexFromArgb(theme.schemes.light.secondaryContainer), - onSecondaryContainer: hexFromArgb(theme.schemes.light.onSecondaryContainer), - tertiary: hexFromArgb(theme.schemes.light.tertiary), - onTertiary: hexFromArgb(theme.schemes.light.onTertiary), - tertiaryContainer: hexFromArgb(theme.schemes.light.tertiaryContainer), - onTertiaryContainer: hexFromArgb(theme.schemes.light.onTertiaryContainer), - error: hexFromArgb(theme.schemes.light.error), - onError: hexFromArgb(theme.schemes.light.onError), - errorContainer: hexFromArgb(theme.schemes.light.errorContainer), - onErrorContainer: hexFromArgb(theme.schemes.light.onErrorContainer), - background: hexFromArgb(theme.schemes.light.background), - onBackground: hexFromArgb(theme.schemes.light.onBackground), - surface: hexFromArgb(theme.schemes.light.surface), - onSurface: hexFromArgb(theme.schemes.light.onSurface), - surfaceVariant: hexFromArgb(theme.schemes.light.surfaceVariant), - onSurfaceVariant: hexFromArgb(theme.schemes.light.onSurfaceVariant), - outline: hexFromArgb(theme.schemes.light.outline), - outlineVariant: hexFromArgb(theme.schemes.light.outlineVariant), - shadow: hexFromArgb(theme.schemes.light.shadow), - scrim: hexFromArgb(theme.schemes.light.scrim), - inverseSurface: hexFromArgb(theme.schemes.light.inverseSurface), - inverseOnSurface: hexFromArgb(theme.schemes.light.inverseOnSurface), - inversePrimary: hexFromArgb(theme.schemes.light.inversePrimary), - }, - }, - }; -}; - -export default generateMaterialTheme; diff --git a/src/renderer/lib/trpc.ts b/src/renderer/lib/trpc.ts index 8ffe974..92bcd26 100644 --- a/src/renderer/lib/trpc.ts +++ b/src/renderer/lib/trpc.ts @@ -1,7 +1,9 @@ import { createTRPCProxyClient } from "@trpc/client"; import { ipcLink } from "electron-trpc-experimental/renderer"; -import { AppRouter } from "@electron/main/trpc/routes/base.js"; +import { AppRouter } from "@electron/main/trpc/routes/index.js"; -export const client = createTRPCProxyClient({ +const client = createTRPCProxyClient({ links: [ipcLink()], }); + +export { client }; diff --git a/src/renderer/tabs/explore/tabs/pexels-images.tsx b/src/renderer/tabs/explore/tabs/pexels-images.tsx index e3e79e5..1e79e05 100644 --- a/src/renderer/tabs/explore/tabs/pexels-images.tsx +++ b/src/renderer/tabs/explore/tabs/pexels-images.tsx @@ -10,7 +10,7 @@ const ExplorePexelsImagesTab = () => { { queryFn={async ({ pageParam, query, appliedFilters, configValue }) => await client.api.pexels.search.query({ type: "photos", - apiKey: configValue, + apiKey: configValue!, page: pageParam, query, ...appliedFilters?.strings, diff --git a/src/renderer/tabs/explore/tabs/pexels-videos.tsx b/src/renderer/tabs/explore/tabs/pexels-videos.tsx index 643b099..0a78e59 100644 --- a/src/renderer/tabs/explore/tabs/pexels-videos.tsx +++ b/src/renderer/tabs/explore/tabs/pexels-videos.tsx @@ -10,7 +10,7 @@ const ExplorePexelsVideosTab = () => { { queryFn={async ({ pageParam, query, appliedFilters, configValue }) => await client.api.pexels.search.query({ type: "videos", - apiKey: configValue, + apiKey: configValue!, page: pageParam, query, ...appliedFilters?.strings, diff --git a/src/renderer/tabs/explore/tabs/unsplash.tsx b/src/renderer/tabs/explore/tabs/unsplash.tsx index a3a858a..f4d63dd 100644 --- a/src/renderer/tabs/explore/tabs/unsplash.tsx +++ b/src/renderer/tabs/explore/tabs/unsplash.tsx @@ -10,7 +10,7 @@ const ExploreUnsplashTab = () => { { queryKeys={[`explore-unsplash`]} queryFn={async ({ pageParam, query, sorting, appliedFilters, configValue }) => await client.api.unsplash.search.query({ - apiKey: configValue, + apiKey: configValue!, page: pageParam, query, orderBy: sorting, diff --git a/src/renderer/tabs/explore/tabs/wallhaven.tsx b/src/renderer/tabs/explore/tabs/wallhaven.tsx index 5fd8b64..588122a 100644 --- a/src/renderer/tabs/explore/tabs/wallhaven.tsx +++ b/src/renderer/tabs/explore/tabs/wallhaven.tsx @@ -1,6 +1,6 @@ import WallpapersGrid from "@renderer/components/wallpapers-grid/index.js"; import { client } from "@renderer/lib/trpc.js"; -import { WallhavenSorting } from "@electron/main/trpc/routes/api/wallhaven.js"; +import { WallhavenSorting } from "@electron/main/trpc/routes/api/wallhaven/index.js"; const ExploreWallhavenTab = () => { return ( diff --git a/src/renderer/tabs/explore/tabs/wallpaper-engine.tsx b/src/renderer/tabs/explore/tabs/wallpaper-engine.tsx index 031c60f..a9e24b5 100644 --- a/src/renderer/tabs/explore/tabs/wallpaper-engine.tsx +++ b/src/renderer/tabs/explore/tabs/wallpaper-engine.tsx @@ -10,7 +10,7 @@ const ExploreWallpaperEngineTab = () => { { const tags = Object.entries(appliedFilters?.arrays || {}).flatMap(([_, values]) => values); return await client.api.wallpaperEngine.search.query({ - apiKey: configValue as string, + apiKey: configValue!, page: pageParam, query, sorting, @@ -210,9 +210,10 @@ const ExploreWallpaperEngineTab = () => { }, ]} onWallpaperDownload={async (wallpaper) => { - const apiKey = (await client.settings.get.query({ - key: "wallpaperEngine.apiKey", - })) as string; + const apiKey = await client.settings.get.query({ + key: "apiKeys.wallpaperEngine", + }); + await client.api.wallpaperEngine.subscribe.mutate({ apiKey, id: wallpaper.id, diff --git a/src/renderer/tabs/library/tabs/image.tsx b/src/renderer/tabs/library/tabs/image.tsx index 638f8d0..aa5b3fa 100644 --- a/src/renderer/tabs/library/tabs/image.tsx +++ b/src/renderer/tabs/library/tabs/image.tsx @@ -10,7 +10,7 @@ const LibraryImageTab = () => { { }} queryKeys={["library-image"]} queryFn={async ({ pageParam, query }) => - await client.theme.searchWallpapers.query({ + await client.wallpaper.search.query({ type: "image", page: pageParam, limit: 20, @@ -59,7 +59,7 @@ const LibraryImageTab = () => { { key: "tile", text: "Tile" }, ]} onWallpaperApply={async (wallpaper, monitors) => { - await client.theme.setWallpaper.mutate({ + await client.wallpaper.set.mutate({ type: "image", id: wallpaper.id, name: wallpaper.name, diff --git a/src/renderer/tabs/library/tabs/video.tsx b/src/renderer/tabs/library/tabs/video.tsx index e0e7e64..22096a9 100644 --- a/src/renderer/tabs/library/tabs/video.tsx +++ b/src/renderer/tabs/library/tabs/video.tsx @@ -10,7 +10,7 @@ const LibraryVideoTab = () => { { }} queryKeys={["library-video"]} queryFn={async ({ pageParam, query }) => - await client.theme.searchWallpapers.query({ + await client.wallpaper.search.query({ type: "video", page: pageParam, limit: 20, @@ -68,7 +68,7 @@ const LibraryVideoTab = () => { }, ]} onWallpaperApply={async (wallpaper, monitors, controlValues) => { - await client.theme.setWallpaper.mutate({ + await client.wallpaper.set.mutate({ type: "video", id: wallpaper.id, name: wallpaper.name, diff --git a/src/renderer/tabs/library/tabs/wallpaper-engine.tsx b/src/renderer/tabs/library/tabs/wallpaper-engine.tsx index 2bbb8c0..7b7414c 100644 --- a/src/renderer/tabs/library/tabs/wallpaper-engine.tsx +++ b/src/renderer/tabs/library/tabs/wallpaper-engine.tsx @@ -10,7 +10,7 @@ const LibraryWallpaperEngineTab = () => { { queryFn={async ({ pageParam, query, sorting, appliedFilters }) => { const tags = Object.entries(appliedFilters?.arrays || {}).flatMap(([_, values]) => values); - return await client.theme.searchWallpapers.query({ + return await client.wallpaper.search.query({ type: "wallpaper-engine", page: pageParam, limit: 50, @@ -283,7 +283,7 @@ const LibraryWallpaperEngineTab = () => { }, ]} onWallpaperApply={async (wallpaper, monitors, controlValues) => { - await client.theme.setWallpaper.mutate({ + await client.wallpaper.set.mutate({ type: "wallpaper-engine", id: wallpaper.id, name: wallpaper.name, diff --git a/src/renderer/tabs/settings/components.tsx b/src/renderer/tabs/settings/components.tsx index 3addd93..9c8356e 100644 --- a/src/renderer/tabs/settings/components.tsx +++ b/src/renderer/tabs/settings/components.tsx @@ -14,85 +14,88 @@ import { } from "lucide-react"; import { toast } from "sonner"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { v4 as uuidv4 } from "uuid"; +import { type SettingKey, type SettingsSchema } from "@electron/main/trpc/routes/settings/index.js"; import { useTheme } from "@renderer/providers/theme/hook.js"; import { Button } from "@renderer/components/ui/button.js"; import { Input } from "@renderer/components/ui/input.js"; import { Card, CardContent } from "@renderer/components/ui/card.js"; import { Switch } from "@renderer/components/ui/switch.js"; +import { Slider } from "@renderer/components/ui/slider.js"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@renderer/components/ui/select.js"; import { client } from "@renderer/lib/trpc.js"; -import { RouterInputs } from "@electron/main/trpc/routes/base.js"; - -type SettingKey = RouterInputs["settings"]["get"]["key"]; +import { useDebouncedCallback } from "use-debounce"; -const ThemeSetting = () => { - const { theme, setTheme } = useTheme(); - const isDarkMode = theme === "dark"; - - return ( -
- setTheme(checked ? "dark" : "light")} - /> -
- {isDarkMode ? : } - {isDarkMode ? "Dark" : "Light"} -
-
- ); -}; - -interface InputSettingProps { - settingKey: SettingKey; - nestedSettingPath?: (string | number)[]; - encrypt?: boolean; - filePicker?: "file" | "folder"; - placeholder?: string; - showOpenInExplorerButton?: boolean; -} - -const InputSetting = ({ +function useSettings({ settingKey, - nestedSettingPath, - placeholder = "", - encrypt = false, - filePicker, - showOpenInExplorerButton = false, -}: InputSettingProps) => { + encrypted = false, +}: { + settingKey: SettingKey | string; + encrypted?: boolean; +}) { const queryClient = useQueryClient(); - const queryKey = nestedSettingPath ? [settingKey, ...nestedSettingPath] : [settingKey]; - const [localValue, setLocalValue] = React.useState(""); - const [showPassword, setShowPassword] = React.useState(false); + const [localValue, setLocalValue] = React.useState(); const { data: value, isPending, isError, } = useQuery({ - queryKey: queryKey, - queryFn: async () => - await client.settings.get.query({ - key: settingKey, - path: nestedSettingPath, - decrypt: encrypt, - }), + queryKey: [settingKey], + queryFn: async () => await client.settings.get.query({ key: settingKey, decrypt: encrypted }), }); React.useEffect(() => { - setLocalValue(value ?? ""); + setLocalValue(value); }, [value]); - const onBlurMutation = useMutation({ - mutationFn: async (value: string) => { + const setValueMutation = useMutation({ + mutationFn: async (newValue: T) => { await client.settings.set.mutate({ key: settingKey, - path: nestedSettingPath, - value: value, - encrypt, + value: newValue, + encrypt: encrypted, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [settingKey] }); + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + const setValueMutationDebounced = useDebouncedCallback((value) => { + setValueMutation.mutate(value); + }, 200); + + const addTemplateMutation = useMutation({ + mutationFn: async (value: unknown) => { + await client.settings.add.mutate({ + key: settingKey, + value, }); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKey }); + queryClient.invalidateQueries({ queryKey: [settingKey] }); + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + const deleteTemplateMutation = useMutation({ + mutationFn: async (index: number) => { + await client.settings.delete.mutate({ key: settingKey, index }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [settingKey] }); }, onError: (error) => { toast.error(error.message); @@ -100,25 +103,97 @@ const InputSetting = ({ }); const onBrowseFolderMutation = useMutation({ - mutationFn: async () => { + mutationFn: async (type: "file" | "folder") => { await client.settings.set.mutate({ key: settingKey, - path: nestedSettingPath, - filePicker, + filePicker: type, }); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKey }); + queryClient.invalidateQueries({ queryKey: [settingKey] }); }, onError: (error) => { toast.error(error.message); }, }); + const onChange = (value: T) => { + setLocalValue(value); + setValueMutationDebounced(value); + }; + + return { + isPending, + isError, + value: localValue, + onChange, + onAdd: addTemplateMutation.mutate, + onDelete: deleteTemplateMutation.mutate, + onBrowseFolder: onBrowseFolderMutation.mutate, + }; +} + +const Error = ({ settingKey }: { settingKey: SettingKey | string }) => { + const queryClient = useQueryClient(); + + return ( +
+

Failed to load

+ +
+ ); +}; + +const ThemeSetting = () => { + const { theme, setTheme } = useTheme(); + const isDarkMode = theme === "dark"; + + return ( +
+ setTheme(checked ? "dark" : "light")} + /> +
+ {isDarkMode ? : } + {isDarkMode ? "Dark" : "Light"} +
+
+ ); +}; + +interface InputSettingProps { + settingKey: SettingKey | string; + encrypted?: boolean; + filePicker?: "file" | "folder"; + placeholder?: string; + showOpenInExplorerButton?: boolean; +} + +const InputSetting = ({ + settingKey, + placeholder = "", + encrypted = false, + filePicker, + showOpenInExplorerButton = false, +}: InputSettingProps) => { + const [showPassword, setShowPassword] = React.useState(false); + const { isPending, isError, value, onChange, onBrowseFolder } = useSettings({ + settingKey, + encrypted, + }); + const onOpenInExplorerMutation = useMutation({ mutationFn: async () => { await client.file.openInExplorer.mutate({ - path: localValue, + path: value as string, }); }, onError: (error) => { @@ -144,47 +219,24 @@ const InputSetting = ({ } if (isError) { - return ( -
-
-
- Failed to load setting -
- {filePicker && ( - - )} -
- -
- ); + return ; } return (
{ - setLocalValue(e.target.value); + onChange(e.target.value); }} - onBlur={(e) => { - onBlurMutation.mutate(e.target.value); - }} - disabled={onBlurMutation.isPending} /> - {encrypt && ( + {isPending && ( + + )} + {encrypted && ( -
- ); + return ; } const isEnabled = Boolean(value); @@ -287,93 +298,140 @@ const BooleanSetting = ({ settingKey }: { settingKey: SettingKey }) => {
setValue.mutate(checked)} - disabled={setValue.isPending} + onCheckedChange={(checked) => onChange(checked)} + disabled={isPending} /> - {setValue.isPending && } + {isPending && }
); }; -const FolderListSetting = ({ settingKey }: { settingKey: SettingKey }) => { - const queryClient = useQueryClient(); +interface DropdownSettingProps { + settingKey: SettingKey | string; + options: { value: string; label: string }[]; + placeholder?: string; +} - const { - data: paths, - isPending, - isError, - } = useQuery({ - queryKey: [settingKey], - queryFn: async () => { - return await client.settings.get.query({ - key: settingKey, - }); - }, - }); +const DropdownSetting = ({ + settingKey, + options, + placeholder = "Select an option", +}: DropdownSettingProps) => { + const { isPending, isError, value, onChange } = useSettings({ settingKey }); - const addPathMutation = useMutation({ - mutationFn: async () => { - await client.settings.add.mutate({ - filePicker: "folder", - key: settingKey, - }); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [settingKey] }); - }, - onError: (error) => { - toast.error(error.message); - }, - }); + if (isPending) { + return ( +
+
+ + Loading... +
+
+ ); + } - const deletePathMutation = useMutation({ - mutationFn: async (index: number) => { - await client.settings.delete.mutate({ - key: settingKey, - index, - }); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [settingKey] }); - }, - onError: (error) => { - toast.error(error.message); - }, - }); + if (isError) { + return ; + } + + return ( + + ); +}; + +interface SliderSettingProps { + settingKey: SettingKey | string; + min?: number; + max?: number; + step?: number; +} + +const SliderSetting = ({ settingKey, min = 0, max = 1, step = 0.01 }: SliderSettingProps) => { + const { isPending, isError, value, onChange } = useSettings({ settingKey }); if (isPending) { return ( -
+
); } if (isError) { + return ; + } + + return ( +
+ { + onChange(vals[0]); + }} + min={min} + max={max} + step={step} + disabled={isPending} + /> + { + const val = Number(e.target.value); + onChange(val); + }} + /> +
+ ); +}; + +const FolderListSetting = ({ settingKey }: { settingKey: SettingKey }) => { + const { isPending, isError, value, onAdd, onDelete } = useSettings({ settingKey }); + + const pathsWithId = React.useMemo( + () => value?.map((path) => ({ id: uuidv4(), value: path })), + [value] + ); + + if (isPending) { return ( -
-

Failed to load folders

- +
+
); } + if (isError) { + return ; + } + return ( - {paths?.map((path: string, index: number) => ( -
+ {pathsWithId?.map((path, index: number) => ( +
@@ -381,9 +439,9 @@ const FolderListSetting = ({ settingKey }: { settingKey: SettingKey }) => { size="sm" variant="ghost" className="text-destructive hover:text-destructive" - onClick={() => deletePathMutation.mutate(index)} + onClick={() => onDelete(index)} > - {deletePathMutation.isPending ? ( + {isPending ? ( ) : ( @@ -392,7 +450,7 @@ const FolderListSetting = ({ settingKey }: { settingKey: SettingKey }) => {
))} - {(!paths || paths?.length === 0) && ( + {(!pathsWithId || pathsWithId?.length === 0) && (

No folders added yet

@@ -400,12 +458,12 @@ const FolderListSetting = ({ settingKey }: { settingKey: SettingKey }) => { )} -
- ); + return ; } return ( - {templates?.length > 0 && ( + {templatesWithId && templatesWithId?.length > 0 && (
- {templates?.map((_, idx: number) => ( -
+ {templatesWithId?.map((tpl, index: number) => ( +
{/* Source file row */}
Source: { Dest: { Post Hook:
@@ -530,9 +544,9 @@ const TemplateListSetting = ({ settingKey }: { settingKey: SettingKey }) => {
)} - - {(!templates || templates?.length === 0) && ( + {(!templatesWithId || templatesWithId?.length === 0) && (

No templates added yet

)} -