feat(react/settings): port theme switch

This commit is contained in:
Elian Doran
2025-08-14 18:18:45 +03:00
parent 6685e583f2
commit ba6a1ec584
6 changed files with 103 additions and 67 deletions

View File

@@ -0,0 +1,14 @@
import type { ComponentChildren } from "preact";
interface ColumnProps {
md: number;
children: ComponentChildren;
}
export default function Column({ md, children }: ColumnProps) {
return (
<div className={`col-md-${md}`}>
{children}
</div>
)
}

View File

@@ -0,0 +1,25 @@
interface FormSelectProps {
currentValue?: string;
onChange(newValue: string): void;
values: { val: string, title: string }[];
}
export default function FormSelect({ currentValue, values, onChange }: FormSelectProps) {
return (
<select
class="form-select"
onChange={e => onChange((e.target as HTMLInputElement).value)}
>
{values.map(item => {
return (
<option
value={item.val}
selected={item.val === currentValue}
>
{item.title}
</option>
);
})}
</select>
)
}

View File

@@ -28,5 +28,5 @@ export function renderReactWidget(parentComponent: Component, el: JSX.Element) {
{el} {el}
</ParentComponent.Provider> </ParentComponent.Provider>
), renderContainer); ), renderContainer);
return $(renderContainer.firstChild as HTMLElement); return $(renderContainer.children) as JQuery<HTMLElement>;
} }

View File

@@ -4,7 +4,7 @@ import { ParentComponent } from "./ReactBasicWidget";
import SpacedUpdate from "../../services/spaced_update"; import SpacedUpdate from "../../services/spaced_update";
import { OptionNames } from "@triliumnext/commons"; import { OptionNames } from "@triliumnext/commons";
import options from "../../services/options"; import options from "../../services/options";
import utils from "../../services/utils"; import utils, { reloadFrontendApp } from "../../services/utils";
/** /**
* Allows a React component to react to Trilium events (e.g. `entitiesReloaded`). When the desired event is triggered, the handler is invoked with the event parameters. * Allows a React component to react to Trilium events (e.g. `entitiesReloaded`). When the desired event is triggered, the handler is invoked with the event parameters.
@@ -68,12 +68,16 @@ export function useSpacedUpdate(callback: () => Promise<void>, interval = 1000)
return spacedUpdateRef.current; return spacedUpdateRef.current;
} }
export function useTriliumOption(name: OptionNames): [string, (newValue: string) => Promise<void>] { export function useTriliumOption(name: OptionNames, needsRefresh?: boolean): [string, (newValue: string) => Promise<void>] {
const initialValue = options.get(name); const initialValue = options.get(name);
const [ value, setValue ] = useState(initialValue); const [ value, setValue ] = useState(initialValue);
async function wrappedSetValue(newValue: string) { async function wrappedSetValue(newValue: string) {
await options.save(name, newValue); await options.save(name, newValue);
if (needsRefresh) {
reloadFrontendApp(`option change: ${name}`);
}
}; };
useTriliumEvent("entitiesReloaded", ({ loadResults }) => { useTriliumEvent("entitiesReloaded", ({ loadResults }) => {

View File

@@ -1,31 +1,68 @@
import { useEffect, useState } from "preact/hooks";
import { t } from "../../../services/i18n"; import { t } from "../../../services/i18n";
import { isMobile, reloadFrontendApp } from "../../../services/utils"; import { isMobile, reloadFrontendApp } from "../../../services/utils";
import Column from "../../react/Column";
import FormRadioGroup from "../../react/FormRadioGroup"; import FormRadioGroup from "../../react/FormRadioGroup";
import FormSelect from "../../react/FormSelect";
import { useTriliumOption } from "../../react/hooks"; import { useTriliumOption } from "../../react/hooks";
import OptionsSection from "./components/OptionsSection"; import OptionsSection from "./components/OptionsSection";
import server from "../../../services/server";
interface Theme {
val: string;
title: string;
noteId?: string;
}
const BUILTIN_THEMES: Theme[] = [
{ val: "next", title: t("theme.triliumnext") },
{ val: "next-light", title: t("theme.triliumnext-light") },
{ val: "next-dark", title: t("theme.triliumnext-dark") },
{ val: "auto", title: t("theme.auto_theme") },
{ val: "light", title: t("theme.light_theme") },
{ val: "dark", title: t("theme.dark_theme") }
]
export default function AppearanceSettings() { export default function AppearanceSettings() {
const [ layoutOrientation, setLayoutOrientation ] = useTriliumOption("layoutOrientation"); const [ layoutOrientation, setLayoutOrientation ] = useTriliumOption("layoutOrientation", true);
const [ theme, setTheme ] = useTriliumOption("theme", true);
const [ themes, setThemes ] = useState<Theme[]>([]);
useEffect(() => {
server.get<Theme[]>("options/user-themes").then((userThemes) => {
setThemes([
...BUILTIN_THEMES,
...userThemes
])
});
}, []);
return ( return (
<OptionsSection title={t("theme.layout")}> <>
{!isMobile() && <FormRadioGroup <OptionsSection title={t("theme.layout")}>
name="layout-orientation" {!isMobile() && <FormRadioGroup
values={[ name="layout-orientation"
{ values={[
label: <><strong>{t("theme.layout-vertical-title")}</strong> - {t("theme.layout-vertical-description")}</>, {
value: "vertical" label: <><strong>{t("theme.layout-vertical-title")}</strong> - {t("theme.layout-vertical-description")}</>,
}, value: "vertical"
{ },
label: <><strong>{t("theme.layout-horizontal-title")}</strong> - {t("theme.layout-horizontal-description")}</>, {
value: "horizontal" label: <><strong>{t("theme.layout-horizontal-title")}</strong> - {t("theme.layout-horizontal-description")}</>,
} value: "horizontal"
]} }
currentValue={layoutOrientation} onChange={async (newValue) => { ]}
await setLayoutOrientation(newValue); currentValue={layoutOrientation} onChange={setLayoutOrientation}
reloadFrontendApp("layout orientation change"); />}
}} </OptionsSection>
/>}
</OptionsSection> <OptionsSection title={t("theme.title")}>
<Column md={6}>
<label>{t("theme.theme_label")}</label>
<FormSelect values={themes} currentValue={theme} onChange={setTheme} />
</Column>
</OptionsSection>
</>
) )
} }

View File

@@ -6,14 +6,7 @@ import type { OptionMap } from "@triliumnext/commons";
const TPL = /*html*/` const TPL = /*html*/`
<div class="options-section"> <div class="options-section">
<h4>${t("theme.title")}</h4>
<div class="form-group row"> <div class="form-group row">
<div class="col-md-6">
<label for="theme-select">${t("theme.theme_label")}</label>
<select id="theme-select" class="theme-select form-select"></select>
</div>
<div class="col-md-6 side-checkbox"> <div class="col-md-6 side-checkbox">
<label class="form-check tn-checkbox"> <label class="form-check tn-checkbox">
<input type="checkbox" class="override-theme-fonts form-check-input"> <input type="checkbox" class="override-theme-fonts form-check-input">
@@ -24,57 +17,20 @@ const TPL = /*html*/`
</div> </div>
`; `;
interface Theme {
val: string;
title: string;
noteId?: string;
}
export default class ThemeOptions extends OptionsWidget { export default class ThemeOptions extends OptionsWidget {
private $themeSelect!: JQuery<HTMLElement>;
private $overrideThemeFonts!: JQuery<HTMLElement>; private $overrideThemeFonts!: JQuery<HTMLElement>;
private $layoutOrientation!: JQuery<HTMLElement>;
doRender() { doRender() {
this.$widget = $(TPL); this.$widget = $(TPL);
this.$themeSelect = this.$widget.find(".theme-select"); this.$themeSelect = this.$widget.find(".theme-select");
this.$overrideThemeFonts = this.$widget.find(".override-theme-fonts"); this.$overrideThemeFonts = this.$widget.find(".override-theme-fonts");
this.$themeSelect.on("change", async () => {
const newTheme = this.$themeSelect.val();
await server.put(`options/theme/${newTheme}`);
utils.reloadFrontendApp("theme change");
});
this.$overrideThemeFonts.on("change", () => this.updateCheckboxOption("overrideThemeFonts", this.$overrideThemeFonts)); this.$overrideThemeFonts.on("change", () => this.updateCheckboxOption("overrideThemeFonts", this.$overrideThemeFonts));
} }
async optionsLoaded(options: OptionMap) { async optionsLoaded(options: OptionMap) {
const themes: Theme[] = [
{ val: "next", title: t("theme.triliumnext") },
{ val: "next-light", title: t("theme.triliumnext-light") },
{ val: "next-dark", title: t("theme.triliumnext-dark") },
{ val: "auto", title: t("theme.auto_theme") },
{ val: "light", title: t("theme.light_theme") },
{ val: "dark", title: t("theme.dark_theme") }
].concat(await server.get<Theme[]>("options/user-themes"));
this.$themeSelect.empty();
for (const theme of themes) {
this.$themeSelect.append(
$("<option>")
.attr("value", theme.val)
.attr("data-note-id", theme.noteId || "")
.text(theme.title)
);
}
this.$themeSelect.val(options.theme);
this.setCheckboxState(this.$overrideThemeFonts, options.overrideThemeFonts); this.setCheckboxState(this.$overrideThemeFonts, options.overrideThemeFonts);
this.$widget.find(`input[name="layout-orientation"][value="${options.layoutOrientation}"]`).prop("checked", "true"); this.$widget.find(`input[name="layout-orientation"][value="${options.layoutOrientation}"]`).prop("checked", "true");