= (props) => {
export default PopupWindow;
function copyStyles(sourceDoc: Document, targetDoc: Document) {
+ // First clear any existing styles
+ ['style', 'link'].forEach((t) =>
+ Array.from(targetDoc.getElementsByTagName(t)).forEach((i) => i.parentElement?.removeChild(i)),
+ );
+
for (let i = 0; i < sourceDoc.styleSheets.length; i++) {
const styleSheet = sourceDoc.styleSheets[i];
// production mode bundles CSS in one file
diff --git a/src/frontend/containers/Settings/index.tsx b/src/frontend/containers/Settings/index.tsx
index 27b40f927..42cf19eab 100644
--- a/src/frontend/containers/Settings/index.tsx
+++ b/src/frontend/containers/Settings/index.tsx
@@ -1,13 +1,16 @@
+import { chromeExtensionUrl } from 'common/config';
+import { getFilenameFriendlyFormattedDateTime } from 'common/fmt';
+import { getThumbnailPath, isDirEmpty } from 'common/fs';
+import { WINDOW_STORAGE_KEY } from 'common/window';
import { shell } from 'electron';
import { runInAction } from 'mobx';
import { observer } from 'mobx-react-lite';
import SysPath from 'path';
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
-import { chromeExtensionUrl } from 'common/config';
import { IMG_EXTENSIONS, IMG_EXTENSIONS_TYPE } from 'src/entities/File';
import { AppToaster } from 'src/frontend/components/Toaster';
+import useCustomTheme from 'src/frontend/hooks/useCustomTheme';
import { RendererMessenger } from 'src/Messaging';
-import { WINDOW_STORAGE_KEY } from 'common/window';
import {
Button,
ButtonGroup,
@@ -23,8 +26,6 @@ import { Alert, DialogButton } from 'widgets/popovers';
import PopupWindow from '../../components/PopupWindow';
import { useStore } from '../../contexts/StoreContext';
import { moveThumbnailDir } from '../../image/ThumbnailGeneration';
-import { getFilenameFriendlyFormattedDateTime } from 'common/fmt';
-import { getThumbnailPath, isDirEmpty } from 'common/fs';
import { ClearDbButton } from '../ErrorBoundary';
import HotkeyMapper from './HotkeyMapper';
import Tabs, { TabItem } from './Tabs';
@@ -73,14 +74,18 @@ const Appearance = observer(() => {
+
+
+
+
+
+
-
-
Thumbnail
@@ -154,6 +159,41 @@ const Zoom = () => {
);
};
+const CustomThemePicker = () => {
+ const { theme, setTheme, refresh, options, themeDir } = useCustomTheme();
+
+ useEffect(() => {
+ refresh();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+ );
+};
+
const ImportExport = observer(() => {
const rootStore = useStore();
const { fileStore, tagStore, exifTool } = rootStore;
diff --git a/src/frontend/hooks/useCustomTheme.tsx b/src/frontend/hooks/useCustomTheme.tsx
new file mode 100644
index 000000000..bf8a5c2dc
--- /dev/null
+++ b/src/frontend/hooks/useCustomTheme.tsx
@@ -0,0 +1,127 @@
+import React, { useCallback, useContext, useEffect, useState } from 'react';
+import fse from 'fs-extra';
+import path from 'path';
+
+import useLocalStorage from './useLocalStorage';
+import { RendererMessenger } from 'src/Messaging';
+import { AppToaster } from '../components/Toaster';
+import { getExtraResourcePath } from '../../../common/fs';
+
+type CustomThemeContextType = {
+ theme: string;
+ setTheme: (filename: string) => void;
+ themeDir: string;
+ options: string[];
+ refresh: () => void;
+};
+
+const CustomThemeContext = React.createContext({
+ theme: '',
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ setTheme: () => {},
+ themeDir: '',
+ options: [],
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ refresh: () => {},
+});
+
+/** Copies preset themes from /resources/themes into the user's custom theme directory */
+async function copyPresets(themeDir: string) {
+ try {
+ const presetDir = getExtraResourcePath('themes');
+ const files = await fse.readdir(presetDir);
+ for (const file of files) {
+ await fse.copy(`${presetDir}/${file}`, `${themeDir}/${file}`);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+}
+
+async function loadThemeFiles(themeDir: string): Promise {
+ try {
+ await fse.ensureDir(themeDir);
+
+ // Place default custom themes from resources/themes in the theme directory
+ await copyPresets(themeDir);
+
+ const files = await fse.readdir(themeDir);
+ return files.filter((f) => f.endsWith('.css'));
+ } catch (e) {
+ console.error(e);
+ }
+ return [];
+}
+
+function applyTheme(themeDir: string, filename: string) {
+ // First clear the previously applied custom theme
+ const customThemeLinkId = 'custom-theme-link';
+ document.getElementById(customThemeLinkId)?.remove();
+
+ // Then apply the new one
+ if (filename) {
+ const link = document.createElement('link');
+ link.id = customThemeLinkId;
+ link.rel = 'stylesheet';
+ link.type = 'text/css';
+ link.href = `file:///${path.join(themeDir, filename)}`;
+
+ link.onerror = () =>
+ AppToaster.show({
+ message: 'Could not load theme',
+ timeout: 5000,
+ clickAction: { onClick: RendererMessenger.toggleDevTools, label: 'Toggle DevTools' },
+ });
+
+ // The style of the settings panel doesn't automatically update, since it's a separate window
+ // This function is exposed in the PopupWindow component as a workaround
+ link.onload = () => (window as any).reapplyPopupStyles?.();
+
+ document.head.appendChild(link);
+ } else {
+ (window as any).reapplyPopupStyles?.();
+ }
+}
+
+export const CustomThemeProvider = ({ children }: { children: React.ReactNode }) => {
+ const [themeDir, setThemeDir] = useState('');
+ const [theme, setTheme] = useLocalStorage('custom-theme', '');
+ const [options, setOptions] = useState([]);
+
+ useEffect(() => {
+ // Load options and previously selected theme on startup
+ RendererMessenger.getThemesDirectory().then((dir) => {
+ setThemeDir(dir);
+ loadThemeFiles(dir).then(setOptions);
+ if (theme) {
+ applyTheme(dir, theme);
+ }
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const refresh = useCallback(() => {
+ loadThemeFiles(themeDir).then(setOptions);
+ applyTheme(themeDir, theme);
+ }, [theme, themeDir]);
+
+ const setThemeAndApply = useCallback(
+ (filename: string) => {
+ setTheme(filename);
+ applyTheme(themeDir, filename);
+ },
+ [setTheme, themeDir],
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default function useCustomTheme() {
+ return useContext(CustomThemeContext);
+}
diff --git a/widgets/Tree/tree.scss b/widgets/Tree/tree.scss
index 46f8eb0f3..488fa0460 100644
--- a/widgets/Tree/tree.scss
+++ b/widgets/Tree/tree.scss
@@ -71,7 +71,7 @@
// If focused, show an outline
// If selected as well: do nothing special: keep the selected background color
// hacky solution so outline shows both horizontal and vertical, and layout doesn't shift. I don't know what I"m doing
- border: 2px solid var(--accent-color-blue);
+ border: 2px solid var(--accent-color);
// TODO: proper background color change for hover
}
diff --git a/widgets/menus/menu.scss b/widgets/menus/menu.scss
index 373b62fb8..e29432b1c 100644
--- a/widgets/menus/menu.scss
+++ b/widgets/menus/menu.scss
@@ -119,7 +119,7 @@ a[role='menuitem'] {
}
&::-webkit-slider-thumb {
- background: var(--accent-color-blue);
+ background: var(--accent-color);
cursor: pointer;
}
}