feat(react/settings): port note erasure timeout

This commit is contained in:
Elian Doran 2025-08-18 17:37:20 +03:00
parent 53d97047a3
commit 436fd16f3a
No known key found for this signature in database
7 changed files with 153 additions and 202 deletions

View File

@ -1,4 +1,5 @@
import type { ComponentChildren } from "preact";
import { CSSProperties } from "preact/compat";
type OnChangeListener = (newValue: string) => void;
@ -19,15 +20,16 @@ interface ValueConfig<T, Q> {
interface FormSelectProps<T, Q> extends ValueConfig<T, Q> {
onChange: OnChangeListener;
style?: CSSProperties;
}
/**
* Combobox component that takes in any object array as data. Each item of the array is rendered as an item, and the key and values are obtained by looking into the object by a specified key.
*/
export default function FormSelect<T>(props: FormSelectProps<T, T>) {
export default function FormSelect<T>({ onChange, style, ...restProps }: FormSelectProps<T, T>) {
return (
<FormSelectBody onChange={props.onChange}>
<FormSelectGroup {...props} />
<FormSelectBody onChange={onChange} style={style}>
<FormSelectGroup {...restProps} />
</FormSelectBody>
);
}
@ -49,11 +51,12 @@ export function FormSelectWithGroups<T>({ values, keyProperty, titleProperty, cu
)
}
function FormSelectBody({ children, onChange }: { children: ComponentChildren, onChange: OnChangeListener }) {
function FormSelectBody({ children, onChange, style }: { children: ComponentChildren, onChange: OnChangeListener, style?: CSSProperties }) {
return (
<select
class="form-select"
onChange={e => onChange((e.target as HTMLInputElement).value)}
style={style}
>
{children}
</select>

View File

@ -3,14 +3,13 @@ import type { InputHTMLAttributes, RefObject } from "preact/compat";
interface FormTextBoxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "onChange" | "value"> {
id?: string;
currentValue?: string;
onChange?(newValue: string): void;
onChange?(newValue: string, validity: ValidityState): void | false;
inputRef?: RefObject<HTMLInputElement>;
}
export default function FormTextBox({ inputRef, className, type, currentValue, onChange, ...rest}: FormTextBoxProps) {
if (type === "number" && currentValue) {
const { min, max } = rest;
console.log(currentValue , min, max);
const currentValueNum = parseInt(currentValue, 10);
if (min && currentValueNum < parseInt(String(min), 10)) {
currentValue = String(min);
@ -25,7 +24,10 @@ export default function FormTextBox({ inputRef, className, type, currentValue, o
className={`form-control ${className ?? ""}`}
type={type ?? "text"}
value={currentValue}
onInput={e => onChange?.(e.currentTarget.value)}
onInput={e => {
const target = e.currentTarget;
onChange?.(target.value, target.validity);
}}
{...rest}
/>
);

View File

@ -29,6 +29,7 @@ import PasswordSettings from "./options/password.jsx";
import ShortcutSettings from "./options/shortcuts.js";
import TextNoteSettings from "./options/text_notes.jsx";
import CodeNoteSettings from "./options/code_notes.jsx";
import OtherSettings from "./options/other.jsx";
const TPL = /*html*/`<div class="note-detail-content-widget note-detail-printable">
<style>
@ -68,17 +69,7 @@ const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", ((typeof NoteContextA
_optionsBackup: <BackupSettings />,
_optionsSync: <SyncOptions />,
_optionsAi: [AiSettingsOptions],
_optionsOther: [
SearchEngineOptions,
TrayOptions,
NoteErasureTimeoutOptions,
AttachmentErasureTimeoutOptions,
RevisionsSnapshotIntervalOptions,
RevisionSnapshotsLimitOptions,
HtmlImportTagsOptions,
ShareSettingsOptions,
NetworkConnectionsOptions
],
_optionsOther: <OtherSettings />,
_optionsLocalization: <InternationalizationOptions />,
_optionsAdvanced: <AdvancedSettings />,
_backendLog: [

View File

@ -0,0 +1,100 @@
import { OptionDefinitions } from "@triliumnext/commons";
import FormGroup from "../../../react/FormGroup";
import FormTextBox from "../../../react/FormTextBox";
import FormSelect from "../../../react/FormSelect";
import { useEffect, useMemo, useState } from "preact/hooks";
import { t } from "../../../../services/i18n";
import { useTriliumOption } from "../../../react/hooks";
import toast from "../../../../services/toast";
type TimeSelectorScale = "seconds" | "minutes" | "hours" | "days";
interface TimeSelectorProps {
name: string;
label: string;
optionValueId: keyof OptionDefinitions;
optionTimeScaleId: keyof OptionDefinitions;
includedTimeScales?: Set<TimeSelectorScale>;
minimumSeconds?: number;
}
interface TimeScaleInfo {
value: string;
unit: string;
}
export default function TimeSelector({ name, label, includedTimeScales, optionValueId, optionTimeScaleId, minimumSeconds }: TimeSelectorProps) {
const values = useMemo(() => {
const values: TimeScaleInfo[] = [];
const timeScalesWithDefault = includedTimeScales ?? new Set(["seconds", "minutes", "hours", "days"]);
if (timeScalesWithDefault.has("seconds")) {
values.push({ value: "1", unit: t("duration.seconds") });
values.push({ value: "60", unit: t("duration.minutes") });
values.push({ value: "3600", unit: t("duration.hours") });
values.push({ value: "86400", unit: t("duration.days") });
}
return values;
}, [ includedTimeScales ]);
const [ value, setValue ] = useTriliumOption(optionValueId);
const [ scale, setScale ] = useTriliumOption(optionTimeScaleId);
const [ displayedTime, setDisplayedTime ] = useState("");
// React to changes in scale and value.
useEffect(() => {
const newTime = convertTime(parseInt(value, 10), scale).toDisplay();
setDisplayedTime(String(newTime));
}, [ value, scale ]);
return (
<FormGroup label={label}>
<div class="d-flex gap-2">
<FormTextBox
name={name}
type="number" min={0} step={1} required
currentValue={displayedTime} onChange={(value, validity) => {
if (!validity.valid) {
toast.showError(t("time_selector.invalid_input"));
return false;
}
let time = parseInt(value, 10);
const minimumSecondsOrDefault = (minimumSeconds ?? 0);
if (Number.isNaN(time) || time < (minimumSecondsOrDefault)) {
toast.showError(t("time_selector.minimum_input", { minimumSeconds: minimumSecondsOrDefault }));
time = minimumSecondsOrDefault;
}
const newTime = convertTime(time, scale).toOption();
setValue(newTime);
}}
/>
<FormSelect
values={values}
keyProperty="value" titleProperty="unit"
style={{ width: "auto" }}
currentValue={scale} onChange={setScale}
/>
</div>
</FormGroup>
)
}
function convertTime(value: number, timeScale: string | number) {
if (Number.isNaN(value)) {
throw new Error(`Time needs to be a valid integer, but received: ${value}`);
}
const operand = typeof timeScale === "number" ? timeScale : parseInt(timeScale);
if (Number.isNaN(operand) || operand < 1) {
throw new Error(`TimeScale needs to be a valid integer >= 1, but received: ${timeScale}`);
}
return {
toOption: () => Math.ceil(value * operand),
toDisplay: () => Math.ceil(value / operand)
};
}

View File

@ -0,0 +1,39 @@
import { t } from "../../../services/i18n";
import server from "../../../services/server";
import toast from "../../../services/toast";
import Button from "../../react/Button";
import FormText from "../../react/FormText";
import OptionsSection from "./components/OptionsSection";
import TimeSelector from "./components/TimeSelector";
export default function OtherSettings() {
return (
<>
<NoteErasureTimeout />
</>
)
}
function NoteErasureTimeout() {
return (
<OptionsSection title={t("note_erasure_timeout.note_erasure_timeout_title")}>
<FormText>{t("note_erasure_timeout.note_erasure_description")}</FormText>
<TimeSelector
name="erase-entities-after"
label={t("note_erasure_timeout.erase_notes_after")}
optionValueId="eraseEntitiesAfterTimeInSeconds"
optionTimeScaleId="eraseEntitiesAfterTimeScale"
/>
<FormText>{t("note_erasure_timeout.manual_erasing_description")}</FormText>
<Button
text={t("note_erasure_timeout.erase_deleted_notes_now")}
onClick={() => {
server.post("notes/erase-deleted-notes-now").then(() => {
toast.showMessage(t("note_erasure_timeout.deleted_notes_erased"));
});
}}
/>
</OptionsSection>
)
}

View File

@ -1,44 +0,0 @@
import OptionsWidget from "../options_widget.js";
import server from "../../../../services/server.js";
import toastService from "../../../../services/toast.js";
import { t } from "../../../../services/i18n.js";
import type { OptionMap } from "@triliumnext/commons";
import TimeSelector from "../time_selector.js";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("note_erasure_timeout.note_erasure_timeout_title")}</h4>
<p class="form-text">${t("note_erasure_timeout.note_erasure_description")}</p>
<div id="time-selector-placeholder"></div>
<p class="form-text">${t("note_erasure_timeout.manual_erasing_description")}</p>
<button id="erase-deleted-notes-now-button" class="btn btn-secondary">${t("note_erasure_timeout.erase_deleted_notes_now")}</button>
</div>`;
export default class NoteErasureTimeoutOptions extends TimeSelector {
private $eraseDeletedNotesButton!: JQuery<HTMLButtonElement>;
constructor() {
super({
widgetId: "erase-entities-after",
widgetLabelId: "note_erasure_timeout.erase_notes_after",
optionValueId: "eraseEntitiesAfterTimeInSeconds",
optionTimeScaleId: "eraseEntitiesAfterTimeScale"
});
super.doRender();
}
doRender() {
const $timeSelector = this.$widget;
// inject TimeSelector widget template
this.$widget = $(TPL);
this.$widget.find("#time-selector-placeholder").replaceWith($timeSelector);
this.$eraseDeletedNotesButton = this.$widget.find("#erase-deleted-notes-now-button");
this.$eraseDeletedNotesButton.on("click", () => {
server.post("notes/erase-deleted-notes-now").then(() => {
toastService.showMessage(t("note_erasure_timeout.deleted_notes_erased"));
});
});
}
}

View File

@ -1,140 +0,0 @@
import OptionsWidget from "./options_widget.js";
import toastService from "../../../services/toast.js";
import { t } from "../../../services/i18n.js";
import type { OptionDefinitions, OptionMap } from "@triliumnext/commons";
import optionsService from "../../../services/options.js";
type TimeSelectorConstructor = {
widgetId: string;
widgetLabelId: string;
optionValueId: keyof OptionDefinitions;
optionTimeScaleId: keyof OptionDefinitions;
includedTimeScales?: Set<TimeSelectorScale>;
minimumSeconds?: number;
};
type TimeSelectorScale = "seconds" | "minutes" | "hours" | "days";
const TPL = (options: Omit<TimeSelectorConstructor, "optionValueId" | "optionTimeScaleId">) => `
<div class="form-group">
<label for="${options.widgetId}">${t(options.widgetLabelId)}</label>
<div class="d-flex gap-2">
<input id="${options.widgetId}" class="form-control options-number-input" type="number" min="0" steps="1" required>
<select id="${options.widgetId}-time-scale" class="form-select duration-selector" required>
${options.includedTimeScales?.has("seconds") ? `<option value="1">${t("duration.seconds")}</option>` : ""}
${options.includedTimeScales?.has("minutes") ? `<option value="60">${t("duration.minutes")}</option>` : ""}
${options.includedTimeScales?.has("hours") ? `<option value="3600">${t("duration.hours")}</option>` : ""}
${options.includedTimeScales?.has("days") ? `<option value="86400">${t("duration.days")}</option>` : ""}
</select>
</div>
</div>
</div>
<style>
.duration-selector {
width: auto;
}
</style>`;
export default class TimeSelector extends OptionsWidget {
private $timeValueInput!: JQuery<HTMLInputElement>;
private $timeScaleSelect!: JQuery<HTMLSelectElement>;
private internalTimeInSeconds!: string | number;
private widgetId: string;
private widgetLabelId: string;
private optionValueId: keyof OptionDefinitions;
private optionTimeScaleId: keyof OptionDefinitions;
private includedTimeScales: Set<TimeSelectorScale>;
private minimumSeconds: number;
constructor(options: TimeSelectorConstructor) {
super();
this.widgetId = options.widgetId;
this.widgetLabelId = options.widgetLabelId;
this.optionValueId = options.optionValueId;
this.optionTimeScaleId = options.optionTimeScaleId;
this.includedTimeScales = options.includedTimeScales || new Set(["seconds", "minutes", "hours", "days"]);
this.minimumSeconds = options.minimumSeconds || 0;
}
doRender() {
this.$widget = $(
TPL({
widgetId: this.widgetId,
widgetLabelId: this.widgetLabelId,
includedTimeScales: this.includedTimeScales
})
);
this.$timeValueInput = this.$widget.find(`#${this.widgetId}`);
this.$timeScaleSelect = this.$widget.find(`#${this.widgetId}-time-scale`);
this.$timeValueInput.on("change", () => {
const time = this.$timeValueInput.val();
const timeScale = this.$timeScaleSelect.val();
if (!this.handleTimeValidation() || typeof timeScale !== "string" || !time) return;
this.setInternalTimeInSeconds(this.convertTime(time, timeScale).toOption());
this.updateOption(this.optionValueId, this.internalTimeInSeconds);
});
this.$timeScaleSelect.on("change", () => {
const timeScale = this.$timeScaleSelect.val();
if (!this.handleTimeValidation() || typeof timeScale !== "string") return;
//calculate the new displayed value
const displayedTime = this.convertTime(this.internalTimeInSeconds, timeScale).toDisplay();
this.updateOption(this.optionTimeScaleId, timeScale);
this.$timeValueInput.val(displayedTime).trigger("change");
});
}
async optionsLoaded(options: OptionMap) {
const optionValue = optionsService.getInt(this.optionValueId) || 0;
const optionTimeScale = optionsService.getInt(this.optionTimeScaleId) || 1;
this.setInternalTimeInSeconds(optionValue);
const displayedTime = this.convertTime(optionValue, optionTimeScale).toDisplay();
this.$timeValueInput.val(displayedTime);
this.$timeScaleSelect.val(optionTimeScale);
}
private convertTime(time: string | number, timeScale: string | number) {
const value = typeof time === "number" ? time : parseInt(time);
if (Number.isNaN(value)) {
throw new Error(`Time needs to be a valid integer, but received: ${time}`);
}
const operand = typeof timeScale === "number" ? timeScale : parseInt(timeScale);
if (Number.isNaN(operand) || operand < 1) {
throw new Error(`TimeScale needs to be a valid integer >= 1, but received: ${timeScale}`);
}
return {
toOption: () => Math.ceil(value * operand),
toDisplay: () => Math.ceil(value / operand)
};
}
private handleTimeValidation() {
if (this.$timeValueInput.is(":invalid")) {
toastService.showError(t("time_selector.invalid_input"));
return false;
}
return true;
}
private setInternalTimeInSeconds(time: number) {
if (time < this.minimumSeconds) {
toastService.showError(t("time_selector.minimum_input", { minimumSeconds: this.minimumSeconds }));
return (this.internalTimeInSeconds = this.minimumSeconds);
}
return (this.internalTimeInSeconds = time);
}
}