Settings improvements (#9412)

This commit is contained in:
Elian Doran
2026-04-13 19:26:53 +03:00
committed by GitHub
29 changed files with 1737 additions and 916 deletions

View File

@@ -1680,7 +1680,7 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
display: inline !important;
}
body.mobile .options-section table {
body.mobile .options-section-card table {
word-break: break-all;
}
@@ -1860,12 +1860,12 @@ button.close:hover {
margin-bottom: 15px;
}
.options-section h5 {
.options-section-card h5 {
margin-top: 10px;
margin-bottom: 10px;
}
.options-section input[type="number"] {
.options-section-card input[type="number"] {
/* overriding settings from .form-control */
width: 10em !important;
flex-grow: 0 !important;

View File

@@ -186,12 +186,7 @@ body.experimental-feature-new-layout .note-detail-content-widget-content.options
}
.options-section:not(.tn-no-card) {
margin-bottom: calc(var(--options-title-offset) + 26px) !important;
box-shadow: var(--card-box-shadow);
border: 1px solid var(--card-border-color) !important;
border-radius: 8px;
background: var(--card-background-color);
padding: var(--options-card-padding);
margin-bottom: 26px !important;
}
body.desktop .options-section:not(.tn-no-card) {
@@ -199,40 +194,70 @@ body.desktop .options-section:not(.tn-no-card) {
max-width: var(--options-card-max-width);
}
.options-section-card {
box-shadow: var(--card-box-shadow);
border: 1px solid var(--card-border-color);
border-radius: 8px;
background: var(--card-background-color);
padding: var(--options-card-padding);
}
.note-detail-content-widget-content.options {
--default-padding: 15px;
padding-top: calc(var(--default-padding) + var(--options-title-offset) + var(--options-title-font-size));
padding-top: var(--default-padding);
padding-bottom: var(--default-padding);
}
.options-section:not(.tn-no-card) h4,
.options-section:not(.tn-no-card) h5 {
.options-section:not(.tn-no-card) h4 {
text-transform: uppercase;
letter-spacing: .4pt;
}
.options-section:not(.tn-no-card) h4 {
font-size: var(--options-title-font-size);
font-weight: 600;
color: var(--launcher-pane-text-color);
margin-top: calc(-1 * var(--options-card-padding) - var(--options-title-font-size) - var(--options-title-offset)) !important;
margin-bottom: calc(var(--options-title-offset) + var(--options-card-padding)) !important;
margin-inline-start: calc(-1 * var(--options-card-padding));
margin-top: 0;
margin-bottom: var(--options-title-offset);
}
.options-section:not(.tn-no-card) h5 {
.options-section:not(.tn-no-card) .options-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.options-section:not(.tn-no-card) .options-section-header h4 {
margin: 0;
margin-bottom: 0;
}
.options-section:not(.tn-no-card) .options-section-header .icon-action {
margin-inline-start: auto;
}
.options-section-description {
color: var(--muted-text-color);
font-size: 13px;
margin-top: 0;
margin-bottom: 16px;
}
.options-section-card h5 {
text-transform: uppercase;
letter-spacing: .4pt;
font-size: var(--options-title-font-size);
font-weight: bold;
margin-top: 1em !important;
margin-bottom: unset !important;
}
.options-section:not(.tn-no-card) h5:first-of-type {
.options-section-card h5:first-of-type {
margin-top: unset !important;
}
.options-section hr {
.options-section-card hr {
--bs-border-width: 2px;
margin-inline-start: calc(var(--options-card-padding) * -1);
@@ -241,27 +266,26 @@ body.desktop .options-section:not(.tn-no-card) {
color: var(--root-background);
}
.options-section p:last-of-type:not(:first-of-type),
.options-section h4 + p:last-child,
.options-section .existing-anonymized-databases {
.options-section-card p:last-of-type:not(:first-of-type),
.options-section-card .existing-anonymized-databases {
margin-bottom: 0;
}
.options-section .form-group {
.options-section-card .form-group {
margin-bottom: 1em;
}
.options-section .form-group:last-child {
.options-section-card .form-group:last-child {
margin-bottom: 0;
}
.options-section ul {
.options-section-card ul {
margin: 0;
padding: 0;
margin-bottom: 1em;
}
.options-section label:not(.tn-checkbox):not(.tn-radio) {
.options-section-card label:not(.tn-checkbox):not(.tn-radio) {
margin-bottom: 6px;
}
@@ -275,7 +299,7 @@ nav.options-section-tabs .nav-tabs {
border-bottom: 0;
}
nav.options-section-tabs + .options-section {
nav.options-section-tabs + .options-section .options-section-card {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
@@ -329,3 +353,9 @@ nav.options-section-tabs + .options-section {
.etapi-options-section div {
height: auto !important;
}
/* BACKUP */
.options-section-card table a {
color: inherit;
}

View File

@@ -1121,17 +1121,20 @@
},
"consistency_checks": {
"title": "Consistency Checks",
"find_and_fix_label": "Find and fix consistency issues",
"find_and_fix_description": "Scan for and automatically repair any data consistency issues in the database.",
"find_and_fix_button": "Find and fix consistency issues",
"finding_and_fixing_message": "Finding and fixing consistency issues...",
"issues_fixed_message": "Any consistency issue which may have been found is now fixed."
},
"database_anonymization": {
"title": "Database Anonymization",
"full_anonymization": "Full Anonymization",
"full_anonymization_description": "This action will create a new copy of the database and anonymize it (remove all note content and leave only structure and some non-sensitive metadata) for sharing online for debugging purposes without fear of leaking your personal data.",
"description": "Create an anonymized copy of your database for sharing with developers when debugging issues, without exposing personal data.",
"full_anonymization": "Full anonymization",
"full_anonymization_description": "Creates a copy of the database with all note content removed, leaving only structure and non-sensitive metadata. Safe for sharing online when debugging issues.",
"save_fully_anonymized_database": "Save fully anonymized database",
"light_anonymization": "Light Anonymization",
"light_anonymization_description": "This action will create a new copy of the database and do a light anonymization on it — specifically only content of all notes will be removed, but titles and attributes will remain. Additionally, custom JS frontend/backend script notes and custom widgets will remain. This provides more context to debug the issues.",
"light_anonymization": "Light anonymization",
"light_anonymization_description": "Creates a copy with note content removed, but titles, attributes, and custom scripts/widgets remain. Provides more context for debugging.",
"choose_anonymization": "You can decide yourself if you want to provide a fully or lightly anonymized database. Even fully anonymized DB is very useful, however in some cases lightly anonymized database can speed up the process of bug identification and fixing.",
"save_lightly_anonymized_database": "Save lightly anonymized database",
"existing_anonymized_databases": "Existing anonymized databases",
@@ -1144,7 +1147,8 @@
},
"database_integrity_check": {
"title": "Database Integrity Check",
"description": "This will check that the database is not corrupted on the SQLite level. It might take some time, depending on the DB size.",
"check_integrity_label": "Check database integrity",
"check_integrity_description": "Verify that the database is not corrupted on the SQLite level.",
"check_button": "Check database integrity",
"checking_integrity": "Checking database integrity...",
"integrity_check_succeeded": "Integrity check succeeded - no problems found.",
@@ -1152,7 +1156,11 @@
},
"sync": {
"title": "Sync",
"force_full_sync_label": "Force full sync",
"force_full_sync_description": "Trigger a complete synchronization with the sync server, re-uploading all changes.",
"force_full_sync_button": "Force full sync",
"fill_entity_changes_label": "Fill entity changes",
"fill_entity_changes_description": "Rebuild entity change records. Use this if sync is missing some changes.",
"fill_entity_changes_button": "Fill entity changes records",
"full_sync_triggered": "Full sync triggered",
"filling_entity_changes": "Filling entity changes rows...",
@@ -1160,8 +1168,13 @@
"finished-successfully": "Sync finished successfully.",
"failed": "Sync failed: {{message}}"
},
"database": {
"title": "Database"
},
"vacuum_database": {
"title": "Vacuum Database",
"vacuum_label": "Vacuum database",
"vacuum_description": "Rebuild the database to reduce file size. No data will be changed.",
"description": "This will rebuild the database which will typically result in a smaller database file. No data will be actually changed.",
"button_text": "Vacuum database",
"vacuuming_database": "Vacuuming database...",
@@ -1178,16 +1191,18 @@
"fonts": {
"theme_defined": "Theme defined",
"fonts": "Fonts",
"main_font": "Main Font",
"custom_fonts": "Use custom fonts",
"main_font": "Interface text",
"font_family": "Font family",
"size": "Size",
"note_tree_font": "Note Tree Font",
"note_detail_font": "Note Detail Font",
"monospace_font": "Monospace (code) Font",
"note_tree_and_detail_font_sizing": "Note that tree and detail font sizing is relative to the main font size setting.",
"not_all_fonts_available": "Not all listed fonts may be available on your system.",
"apply_font_changes": "To apply font changes, click on",
"reload_frontend": "reload frontend",
"preview": "Preview",
"note_tree_font": "Note tree text",
"note_detail_font": "Document text",
"monospace_font": "Monospace text",
"monospace_font_description": "Used for code notes and code blocks",
"size_relative_to_general": "Size is relative to the general font size",
"not_all_fonts_available": "Not all listed fonts may be available on your system",
"apply_changes": "Reload to apply changes",
"generic-fonts": "Generic fonts",
"sans-serif-system-fonts": "Sans-serif system fonts",
"serif-system-fonts": "Serif system fonts",
@@ -1216,15 +1231,18 @@
"edited_notes_message": "Edited Notes ribbon tab will automatically open on day notes"
},
"theme": {
"title": "Application Theme",
"theme_label": "Theme",
"title": "User Interface",
"theme_label": "Application theme",
"override_theme_fonts_label": "Override theme fonts",
"auto_theme": "Legacy (Follow system color scheme)",
"light_theme": "Legacy (Light)",
"dark_theme": "Legacy (Dark)",
"triliumnext": "Trilium (Follow system color scheme)",
"triliumnext-light": "Trilium (Light)",
"triliumnext-dark": "Trilium (Dark)",
"auto_theme": "Follow system color scheme",
"light_theme": "Light",
"dark_theme": "Dark",
"triliumnext": "Follow system color scheme",
"triliumnext-light": "Light",
"triliumnext-dark": "Dark",
"modern_themes": "Modern",
"legacy_themes": "Legacy",
"custom_themes": "Custom",
"layout": "Layout",
"layout-vertical-title": "Vertical",
"layout-horizontal-title": "Horizontal",
@@ -1233,11 +1251,11 @@
},
"ui-performance": {
"title": "Performance",
"enable-motion": "Enable transitions and animations",
"enable-shadows": "Enable shadows",
"enable-backdrop-effects": "Enable background effects for menus, popups and panels",
"enable-smooth-scroll": "Enable smooth scrolling",
"app-restart-required": "(a restart of the application is required for the change to take effect)"
"enable-motion": "Transitions and animations",
"enable-shadows": "Shadows",
"enable-backdrop-effects": "Background effects for menus, popups and panels",
"enable-smooth-scroll": "Smooth scrolling",
"app-restart-required": "Requires app restart"
},
"zoom_factor": {
"title": "Zoom Factor (desktop build only)",
@@ -1246,7 +1264,7 @@
"code_auto_read_only_size": {
"title": "Automatic Read-Only Size",
"description": "Automatic read-only note size is the size after which notes will be displayed in a read-only mode (for performance reasons).",
"label": "Automatic read-only size (code notes)",
"label": "Automatic read-only size",
"unit": "characters"
},
"code-editor-options": {
@@ -1292,36 +1310,45 @@
"batch_ocr_error": "Error during batch processing: {{error}}"
},
"attachment_erasure_timeout": {
"attachment_erasure_timeout": "Attachment Erasure Timeout",
"attachment_auto_deletion_description": "Attachments get automatically deleted (and erased) if they are not referenced by their note anymore after a defined time out.",
"erase_attachments_after": "Erase unused attachments after:",
"manual_erasing_description": "You can also trigger erasing manually (without considering the timeout defined above):",
"erase_unused_attachments_now": "Erase unused attachment notes now",
"attachment_erasure_timeout": "Unused Attachments",
"description": "Attachments that are no longer referenced by any note are considered unused and can be automatically erased after a period of time.",
"erase_attachments_after": "Erase unused attachments after",
"erase_attachments_after_description": "Time before unused attachments are permanently erased.",
"manual_erasing_description": "Trigger erasing manually, ignoring the timeout above.",
"erase_unused_attachments_now": "Erase unused attachments now",
"unused_attachments_erased": "Unused attachments have been erased."
},
"network_connections": {
"network_connections_title": "Network Connections",
"check_for_updates": "Check for updates automatically"
"network_connections_title": "Network",
"check_for_updates": "Check for updates automatically",
"check_for_updates_description": "Checks for new versions on GitHub and shows a notification in the global menu when available."
},
"note_erasure_timeout": {
"note_erasure_timeout_title": "Note Erasure Timeout",
"note_erasure_description": "Deleted notes (and attributes, revisions...) are at first only marked as deleted and it is possible to recover them from Recent Notes dialog. After a period of time, deleted notes are \"erased\" which means their content is not recoverable anymore. This setting allows you to configure the length of the period between deleting and erasing the note.",
"erase_notes_after": "Erase notes after:",
"manual_erasing_description": "You can also trigger erasing manually (without considering the timeout defined above):",
"note_erasure_timeout_title": "Deleted Notes",
"description": "Deleted notes are first only marked as deleted and can be recovered from Recent Notes. After a period of time, they are permanently erased.",
"erase_notes_after": "Erase notes after",
"erase_notes_after_description": "Time before deleted notes are permanently erased.",
"manual_erasing_description": "Trigger erasing manually, ignoring the timeout above.",
"erase_deleted_notes_now": "Erase deleted notes now",
"deleted_notes_erased": "Deleted notes have been erased."
},
"revisions": {
"title": "Note Revisions"
},
"revisions_snapshot_interval": {
"note_revisions_snapshot_interval_title": "Note Revision Snapshot Interval",
"note_revisions_snapshot_description": "The Note revision snapshot interval is the time after which a new note revision will be created for the note. See <doc>wiki</doc> for more info.",
"snapshot_time_interval_label": "Note revision snapshot time interval:"
"note_revisions_snapshot_description_short": "Time after which a new note revision will be created.",
"snapshot_time_interval_label": "Snapshot interval"
},
"revisions_snapshot_limit": {
"note_revisions_snapshot_limit_title": "Note Revision Snapshot Limit",
"note_revisions_snapshot_limit_description": "The note revision snapshot number limit refers to the maximum number of revisions that can be saved for each note. Where -1 means no limit, 0 means delete all revisions. You can set the maximum revisions for a single note through the #versioningLimit label.",
"snapshot_number_limit_label": "Note revision snapshot number limit:",
"note_revisions_snapshot_limit_description_short": "Max revisions per note. Use -1 for unlimited, 0 to disable.",
"snapshot_number_limit_label": "Maximum revisions",
"snapshot_number_limit_unit": "snapshots",
"erase_excess_revision_snapshots": "Erase excess revision snapshots now",
"erase_excess_revision_snapshots_description": "Delete revisions exceeding the limit for all notes.",
"erase_excess_revision_snapshots_prompt": "Excess revision snapshots have been erased."
},
"search": {
@@ -1333,24 +1360,30 @@
},
"search_engine": {
"title": "Search Engine",
"custom_search_engine_info": "Custom search engine requires both a name and a URL to be set. If either of these is not set, DuckDuckGo will be used as the default search engine.",
"predefined_templates_label": "Predefined search engine templates",
"custom_search_engine_info": "Used when searching the web for selected text. If not configured, DuckDuckGo will be used.",
"predefined_templates_label": "Presets",
"bing": "Bing",
"baidu": "Baidu",
"duckduckgo": "DuckDuckGo",
"google": "Google",
"custom_name_label": "Custom search engine name",
"custom_name_placeholder": "Customize search engine name",
"custom_url_label": "Custom search engine URL should include {keyword} as a placeholder for the search term.",
"custom_url_placeholder": "Customize search engine URL",
"custom_name_label": "Name",
"custom_name_placeholder": "Search engine name",
"custom_url_label": "URL",
"custom_url_description": "Use {keyword} as a placeholder for the search term.",
"custom_url_placeholder": "Search engine URL",
"save_button": "Save"
},
"tray": {
"title": "System Tray",
"enable_tray": "Enable tray (Trilium needs to be restarted for this change to take effect)"
"enable_tray": "Tray icon",
"enable_tray_description": "Trilium needs to be restarted for this change to take effect."
},
"text_editor": {
"title": "Editor"
},
"heading_style": {
"title": "Heading Style",
"title": "Heading style",
"description": "Visual style for headings in text notes.",
"plain": "Plain",
"underline": "Underline",
"markdown": "Markdown-style"
@@ -1377,14 +1410,16 @@
"text_auto_read_only_size": {
"title": "Automatic Read-Only Size",
"description": "Automatic read-only note size is the size after which notes will be displayed in a read-only mode (for performance reasons).",
"label": "Automatic read-only size (text notes)",
"label": "Automatic read-only size",
"unit": "characters"
},
"custom_date_time_format": {
"title": "Custom Date/Time Format",
"title": "Date/time format",
"description": "Customize the format of the date and time inserted via <shortcut /> or the toolbar. See <doc>Day.js docs</doc> for available format tokens.",
"format_string": "Format string:",
"formatted_time": "Formatted date/time:"
"description_short": "Customize the format of the date and time inserted via the toolbar.",
"preview": "Preview: {{preview}}",
"format_string": "Format string",
"formatted_time": "Formatted date/time"
},
"i18n": {
"title": "Localization",
@@ -1399,20 +1434,20 @@
"sunday": "Sunday",
"first-week-of-the-year": "First week of the year",
"first-week-contains-first-day": "First week contains first day of the year",
"first-week-contains-first-thursday": "First week contains first Thursday of the year",
"first-week-contains-first-thursday": "First week contains first Thursday (ISO 8601)",
"first-week-has-minimum-days": "First week has minimum days",
"min-days-in-first-week": "Minimum days in first week",
"first-week-info": "First week contains first Thursday of the year is based on <a href=\"https://en.wikipedia.org/wiki/ISO_week_date#First_week\">ISO 8601</a> standard.",
"first-week-warning": "Changing first week options may cause duplicate with existing Week Notes and the existing Week Notes will not be updated accordingly.",
"first-week-warning": "Changing this may cause duplicates with existing Week Notes.",
"formatting-locale": "Date & number format",
"formatting-locale-auto": "Based on the application's language"
},
"backup": {
"title": "Backup",
"automatic_backup": "Automatic backup",
"automatic_backup_description": "Trilium can back up the database automatically:",
"enable_daily_backup": "Enable daily backup",
"enable_weekly_backup": "Enable weekly backup",
"enable_monthly_backup": "Enable monthly backup",
"enable_daily_backup": "Backup daily",
"enable_weekly_backup": "Backup weekly",
"enable_monthly_backup": "Backup monthly",
"backup_recommendation": "It's recommended to keep the backup turned on, but this can make application startup slow with large databases and/or slow storage devices.",
"backup_now": "Backup now",
"backup_database_now": "Backup database now",
@@ -1456,11 +1491,15 @@
"new_password": "New password",
"new_password_confirmation": "New password confirmation",
"change_password": "Change password",
"protected_session_timeout": "Protected Session Timeout",
"protected_session_timeout_description": "Protected session timeout is a time period after which the protected session is wiped from the browser's memory. This is measured from the last interaction with protected notes. See",
"change_password_description": "Update your current password",
"reset_password": "Reset password",
"reset_password_description": "Permanently lose access to protected notes",
"cancel": "Cancel",
"protected_session_timeout": "Protected Session",
"protected_session_timeout_description": "Time of inactivity before the session is cleared from browser memory. See",
"wiki": "wiki",
"for_more_info": "for more info.",
"protected_session_timeout_label": "Protected session timeout:",
"protected_session_timeout_label": "Auto-close session after",
"reset_confirmation": "By resetting the password you will forever lose access to all your existing protected notes. Do you really want to reset the password?",
"reset_success_message": "Password has been reset. Please set new password",
"change_password_heading": "Change Password",
@@ -1530,18 +1569,17 @@
"related_description": "Configure spell check languages and custom dictionary."
},
"sync_2": {
"config_title": "Sync Configuration",
"server_address": "Server instance address",
"timeout": "Sync timeout",
"timeout_description": "How long to wait before giving up on a slow sync connection. Increase if you have an unstable network.",
"proxy_label": "Sync proxy server (optional)",
"note": "Note",
"note_description": "If you leave the proxy setting blank, the system proxy will be used (applies to desktop/electron build only).",
"special_value_description": "Another special value is <code>noproxy</code> which forces ignoring even the system proxy and respects <code>NODE_TLS_REJECT_UNAUTHORIZED</code>.",
"config_title": "Sync Server",
"server_address": "Server address",
"server_address_description": "URL of the Trilium server to sync with.",
"timeout": "Connection timeout",
"timeout_description": "Time to wait before giving up on a slow connection.",
"proxy_label": "Proxy server",
"proxy_description": "Leave blank to use system proxy (desktop only). Use \"noproxy\" to bypass all proxies.",
"save": "Save",
"help": "Help",
"test_title": "Sync Test",
"test_description": "This will test the connection and handshake to the sync server. If the sync server isn't initialized, this will set it up to sync with the local document.",
"test_title": "Test Connection",
"test_description": "Test the connection to the sync server. If not initialized, this will set up syncing.",
"test_button": "Test sync",
"handshake_failed": "Sync server handshake failed, error: {{message}}"
},
@@ -1903,6 +1941,7 @@
"editing": {
"editor_type": {
"label": "Formatting toolbar",
"toolbar_style": "Toolbar style",
"floating": {
"title": "Floating",
"description": "editing tools appear near the cursor;"
@@ -1911,7 +1950,7 @@
"title": "Fixed",
"description": "editing tools appear in the \"Formatting\" ribbon tab."
},
"multiline-toolbar": "Display the toolbar on multiple lines if it doesn't fit."
"multiline-toolbar": "Display the toolbar on multiple lines if it doesn't fit"
}
},
"electron_context_menu": {
@@ -1978,7 +2017,7 @@
"days": "Days"
},
"share": {
"title": "Share Settings",
"title": "Share",
"redirect_bare_domain": "Redirect bare domain to Share page",
"redirect_bare_domain_description": "Redirect anonymous users to the Share page instead of showing Login",
"show_login_link": "Show Login link in Share theme",
@@ -2042,12 +2081,12 @@
},
"editorfeatures": {
"title": "Features",
"emoji_completion_enabled": "Enable Emoji auto-completion",
"emoji_completion_description": "If enabled, emojis can be easily inserted into text by typing `:`, followed by the name of an emoji.",
"note_completion_enabled": "Enable note auto-completion",
"note_completion_description": "If enabled, links to notes can be created by typing `@` followed by the title of a note.",
"slash_commands_enabled": "Enable slash commands",
"slash_commands_description": "If enabled, editing commands such as inserting line breaks or headings can be toggled by typing `/`."
"emoji_completion_enabled": "Emoji auto-completion",
"emoji_completion_description": "Emojis can be easily inserted into text by typing `:`, followed by the name of an emoji.",
"note_completion_enabled": "Note auto-completion",
"note_completion_description": "Links to notes can be created by typing `@` followed by the title of a note.",
"slash_commands_enabled": "Slash commands",
"slash_commands_description": "Editing commands such as inserting line breaks or headings can be toggled by typing `/`."
},
"table_view": {
"new-row": "New row",
@@ -2166,6 +2205,8 @@
"related_code_blocks": "Color scheme for code blocks in text notes",
"related_code_notes": "Color scheme for code notes",
"ui": "User interface",
"ui_layout_style": "Layout style",
"ui_layout_orientation": "Launcher bar orientation",
"ui_old_layout": "Old layout",
"ui_new_layout": "New layout"
},

View File

@@ -24,14 +24,15 @@ interface FormSelectProps<T, Q> extends ValueConfig<T, Q> {
onChange: OnChangeListener;
style?: CSSProperties;
className?: string;
disabled?: boolean;
}
/**
* 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>({ name, id, onChange, style, className, ...restProps }: FormSelectProps<T, T>) {
export default function FormSelect<T>({ name, id, onChange, style, className, disabled, ...restProps }: FormSelectProps<T, T>) {
return (
<FormSelectBody name={name} id={id} onChange={onChange} style={style} className={className}>
<FormSelectBody name={name} id={id} onChange={onChange} style={style} className={className} disabled={disabled}>
<FormSelectGroup {...restProps} />
</FormSelectBody>
);
@@ -40,9 +41,9 @@ export default function FormSelect<T>({ name, id, onChange, style, className, ..
/**
* Similar to {@link FormSelect}, but the top-level elements are actually groups.
*/
export function FormSelectWithGroups<T>({ name, id, values, keyProperty, titleProperty, currentValue, onChange, ...restProps }: FormSelectProps<T, FormSelectGroup<T> | T>) {
export function FormSelectWithGroups<T>({ name, id, values, keyProperty, titleProperty, currentValue, onChange, disabled, ...restProps }: FormSelectProps<T, FormSelectGroup<T> | T>) {
return (
<FormSelectBody name={name} id={id} onChange={onChange} {...restProps}>
<FormSelectBody name={name} id={id} onChange={onChange} disabled={disabled} {...restProps}>
{values.map((item) => {
if (!item) return <></>;
if (typeof item === "object" && "items" in item) {
@@ -61,7 +62,7 @@ export function FormSelectWithGroups<T>({ name, id, values, keyProperty, titlePr
)
}
function FormSelectBody({ id, name, children, onChange, style, className }: { id?: string, name?: string, children: ComponentChildren, onChange: OnChangeListener, style?: CSSProperties, className?: string }) {
function FormSelectBody({ id, name, children, onChange, style, className, disabled }: { id?: string, name?: string, children: ComponentChildren, onChange: OnChangeListener, style?: CSSProperties, className?: string, disabled?: boolean }) {
return (
<select
id={id}
@@ -69,6 +70,7 @@ function FormSelectBody({ id, name, children, onChange, style, className }: { id
onChange={e => onChange((e.target as HTMLInputElement).value)}
style={style}
className={`form-select ${className ?? ""}`}
disabled={disabled}
>
{children}
</select>

View File

@@ -1,41 +1,39 @@
import { AnonymizedDbResponse, DatabaseAnonymizeResponse, DatabaseCheckIntegrityResponse } from "@triliumnext/commons";
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
import { experimentalFeatures, type ExperimentalFeatureId } from "../../../services/experimental_features";
import { type ExperimentalFeatureId,experimentalFeatures } from "../../../services/experimental_features";
import { t } from "../../../services/i18n";
import server from "../../../services/server";
import toast from "../../../services/toast";
import Button from "../../react/Button";
import Column from "../../react/Column";
import FormText from "../../react/FormText";
import FormToggle from "../../react/FormToggle";
import { useTriliumOptionJson } from "../../react/hooks";
import OptionsRow from "./components/OptionsRow";
import { OptionsRowWithButton, OptionsRowWithToggle } from "./components/OptionsRow";
import OptionsSection from "./components/OptionsSection";
export default function AdvancedSettings() {
return <>
<AdvancedSyncOptions />
<DatabaseIntegrityOptions />
<DatabaseOptions />
<DatabaseAnonymizationOptions />
<VacuumDatabaseOptions />
<ExperimentalOptions />
<AdvancedSyncOptions />
</>;
}
function AdvancedSyncOptions() {
return (
<OptionsSection title={t("sync.title")}>
<Button
text={t("sync.force_full_sync_button")}
<OptionsRowWithButton
label={t("sync.force_full_sync_label")}
description={t("sync.force_full_sync_description")}
onClick={async () => {
await server.post("sync/force-full-sync");
toast.showMessage(t("sync.full_sync_triggered"));
}}
/>
<Button
text={t("sync.fill_entity_changes_button")}
<OptionsRowWithButton
label={t("sync.fill_entity_changes_label")}
description={t("sync.fill_entity_changes_description")}
onClick={async () => {
toast.showMessage(t("sync.filling_entity_changes"));
await server.post("sync/fill-entity-changes");
@@ -46,13 +44,12 @@ function AdvancedSyncOptions() {
);
}
function DatabaseIntegrityOptions() {
function DatabaseOptions() {
return (
<OptionsSection title={t("database_integrity_check.title")}>
<FormText>{t("database_integrity_check.description")}</FormText>
<Button
text={t("database_integrity_check.check_button")}
<OptionsSection title={t("database.title")}>
<OptionsRowWithButton
label={t("database_integrity_check.check_integrity_label")}
description={t("database_integrity_check.check_integrity_description")}
onClick={async () => {
toast.showMessage(t("database_integrity_check.checking_integrity"));
@@ -66,20 +63,31 @@ function DatabaseIntegrityOptions() {
}}
/>
<Button
text={t("consistency_checks.find_and_fix_button")}
<OptionsRowWithButton
label={t("consistency_checks.find_and_fix_label")}
description={t("consistency_checks.find_and_fix_description")}
onClick={async () => {
toast.showMessage(t("consistency_checks.finding_and_fixing_message"));
await server.post("database/find-and-fix-consistency-issues");
toast.showMessage(t("consistency_checks.issues_fixed_message"));
}}
/>
<OptionsRowWithButton
label={t("vacuum_database.vacuum_label")}
description={t("vacuum_database.vacuum_description")}
onClick={async () => {
toast.showMessage(t("vacuum_database.vacuuming_database"));
await server.post("database/vacuum-database");
toast.showMessage(t("vacuum_database.database_vacuumed"));
}}
/>
</OptionsSection>
);
}
function DatabaseAnonymizationOptions() {
const [ existingAnonymizedDatabases, setExistingAnonymizedDatabases ] = useState<AnonymizedDbResponse[]>([]);
const [existingAnonymizedDatabases, setExistingAnonymizedDatabases] = useState<AnonymizedDbResponse[]>([]);
function refreshAnonymizedDatabase() {
server.get<AnonymizedDbResponse[]>("database/anonymized-databases").then(setExistingAnonymizedDatabases);
@@ -89,59 +97,47 @@ function DatabaseAnonymizationOptions() {
return (
<OptionsSection title={t("database_anonymization.title")}>
<FormText>{t("database_anonymization.choose_anonymization")}</FormText>
<FormText>{t("database_anonymization.description")}</FormText>
<div className="row">
<DatabaseAnonymizationOption
title={t("database_anonymization.full_anonymization")}
description={t("database_anonymization.full_anonymization_description")}
buttonText={t("database_anonymization.save_fully_anonymized_database")}
buttonClick={async () => {
toast.showMessage(t("database_anonymization.creating_fully_anonymized_database"));
const resp = await server.post<DatabaseAnonymizeResponse>("database/anonymize/full");
<OptionsRowWithButton
label={t("database_anonymization.full_anonymization")}
description={t("database_anonymization.full_anonymization_description")}
onClick={async () => {
toast.showMessage(t("database_anonymization.creating_fully_anonymized_database"));
const resp = await server.post<DatabaseAnonymizeResponse>("database/anonymize/full");
if (!resp.success) {
toast.showError(t("database_anonymization.error_creating_anonymized_database"));
} else {
toast.showMessage(t("database_anonymization.successfully_created_fully_anonymized_database", { anonymizedFilePath: resp.anonymizedFilePath }), 10000);
refreshAnonymizedDatabase();
}
}}
/>
<DatabaseAnonymizationOption
title={t("database_anonymization.light_anonymization")}
description={t("database_anonymization.light_anonymization_description")}
buttonText={t("database_anonymization.save_lightly_anonymized_database")}
buttonClick={async () => {
toast.showMessage(t("database_anonymization.creating_lightly_anonymized_database"));
const resp = await server.post<DatabaseAnonymizeResponse>("database/anonymize/light");
if (!resp.success) {
toast.showError(t("database_anonymization.error_creating_anonymized_database"));
} else {
toast.showMessage(t("database_anonymization.successfully_created_fully_anonymized_database", { anonymizedFilePath: resp.anonymizedFilePath }), 10000);
refreshAnonymizedDatabase();
}
}}
/>
if (!resp.success) {
toast.showError(t("database_anonymization.error_creating_anonymized_database"));
} else {
toast.showMessage(t("database_anonymization.successfully_created_lightly_anonymized_database", { anonymizedFilePath: resp.anonymizedFilePath }), 10000);
refreshAnonymizedDatabase();
}
}}
/>
</div>
<OptionsRowWithButton
label={t("database_anonymization.light_anonymization")}
description={t("database_anonymization.light_anonymization_description")}
onClick={async () => {
toast.showMessage(t("database_anonymization.creating_lightly_anonymized_database"));
const resp = await server.post<DatabaseAnonymizeResponse>("database/anonymize/light");
if (!resp.success) {
toast.showError(t("database_anonymization.error_creating_anonymized_database"));
} else {
toast.showMessage(t("database_anonymization.successfully_created_lightly_anonymized_database", { anonymizedFilePath: resp.anonymizedFilePath }), 10000);
refreshAnonymizedDatabase();
}
}}
/>
<hr />
<ExistingAnonymizedDatabases databases={existingAnonymizedDatabases} />
</OptionsSection>
);
}
function DatabaseAnonymizationOption({ title, description, buttonText, buttonClick }: { title: string, description: string, buttonText: string, buttonClick: () => void }) {
return (
<Column md={6} style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", marginTop: "1em" }}>
<h5>{title}</h5>
<FormText>{description}</FormText>
<Button text={buttonText} onClick={buttonClick} />
</Column>
);
}
function ExistingAnonymizedDatabases({ databases }: { databases: AnonymizedDbResponse[] }) {
if (!databases.length) {
return <FormText>{t("database_anonymization.no_anonymized_database_yet")}</FormText>;
@@ -163,22 +159,6 @@ function ExistingAnonymizedDatabases({ databases }: { databases: AnonymizedDbRes
);
}
function VacuumDatabaseOptions() {
return (
<OptionsSection title={t("vacuum_database.title")}>
<FormText>{t("vacuum_database.description")}</FormText>
<Button
text={t("vacuum_database.button_text")}
onClick={async () => {
toast.showMessage(t("vacuum_database.vacuuming_database"));
await server.post("database/vacuum-database");
toast.showMessage(t("vacuum_database.database_vacuumed"));
}}
/>
</OptionsSection>
);
}
function ExperimentalOptions() {
const [enabledFeatures, setEnabledFeatures] = useTriliumOptionJson<ExperimentalFeatureId[]>("experimentalFeatures", true);
@@ -201,18 +181,14 @@ function ExperimentalOptions() {
<FormText>{t("experimental_features.disclaimer")}</FormText>
{filteredFeatures.map((feature) => (
<OptionsRow
<OptionsRowWithToggle
key={feature.id}
name={`experimental-${feature.id}`}
label={feature.name}
description={feature.description}
>
<FormToggle
switchOnName="" switchOffName=""
currentValue={enabledFeatures.includes(feature.id)}
onChange={(enabled) => toggleFeature(feature.id, enabled)}
/>
</OptionsRow>
currentValue={enabledFeatures.includes(feature.id)}
onChange={(enabled) => toggleFeature(feature.id, enabled)}
/>
))}
</OptionsSection>
);

View File

@@ -1,3 +1,234 @@
/* Font Option Row */
.font-option-row {
&:disabled {
opacity: 0.5;
pointer-events: none;
}
}
.font-option-preview {
display: flex;
align-items: center;
gap: 8px;
color: var(--muted-text-color);
}
/* Font Picker Modal */
.font-picker-modal .modal-body {
padding: 0;
}
.font-picker-content {
display: flex;
height: 400px;
}
.font-picker-list {
width: 280px;
border-right: 1px solid var(--main-border-color);
overflow: hidden;
.dropdownWrapper {
height: 100%;
}
.dropdown-menu {
border: none;
border-radius: 0;
}
.dropdown-item {
font-size: 14px;
}
}
.font-picker-settings {
flex: 1;
display: flex;
flex-direction: column;
padding: 16px;
gap: 16px;
}
.font-size-control {
display: flex;
flex-direction: column;
gap: 8px;
label {
font-weight: 500;
color: var(--muted-text-color);
font-size: 12px;
text-transform: uppercase;
}
.font-size-description {
color: var(--muted-text-color);
font-size: 12px;
}
}
.font-size-slider {
display: flex;
align-items: center;
gap: 12px;
.slider {
flex: 1;
}
.font-size-value {
min-width: 50px;
text-align: right;
font-variant-numeric: tabular-nums;
}
}
.font-preview {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
label {
font-weight: 500;
color: var(--muted-text-color);
font-size: 12px;
text-transform: uppercase;
}
}
.font-preview-text {
flex: 1;
padding: 16px;
border: 1px solid var(--main-border-color);
border-radius: 6px;
background: var(--main-background-color);
overflow: auto;
line-height: 1.6;
}
.orientation-illustration {
width: 170px;
height: 130px;
border: 1px solid var(--main-border-color);
border-radius: 6px;
display: flex;
flex-direction: column;
background: var(--root-background);
overflow: hidden;
> .tab-bar.full-width {
height: 12px;
flex-shrink: 0;
display: flex;
align-items: flex-end;
gap: 2px;
padding: 0 4px;
background: var(--launcher-pane-background-color);
.tab {
width: 24px;
height: 8px;
background: var(--main-background-color);
border-radius: 4px 4px 0 0;
opacity: 0.4;
&.active {
height: 10px;
opacity: 1;
}
}
}
.launcher-bar {
background: var(--launcher-pane-vert-background-color);
display: flex;
align-items: center;
flex-shrink: 0;
.tn-icon {
font-size: 12px;
opacity: 0.5;
}
&.horizontal {
flex-direction: row;
height: 14px;
padding: 0 4px;
gap: 6px;
}
&.vertical {
flex-direction: column;
width: 24px;
padding: 4px 0;
gap: 4px;
}
}
.main-area {
display: flex;
flex-grow: 1;
min-height: 0;
}
.tree-pane {
width: 40px;
flex-shrink: 0;
padding: 8px 4px;
.tree-content {
height: 100%;
background: repeating-linear-gradient(
180deg,
var(--main-text-color) 0px,
var(--main-text-color) 4px,
transparent 4px,
transparent 8px
);
opacity: 0.15;
border-radius: 2px;
}
}
.content-pane {
flex-grow: 1;
display: flex;
flex-direction: column;
.tab-bar {
height: 12px;
flex-shrink: 0;
display: flex;
align-items: flex-end;
gap: 2px;
padding: 0 4px;
.tab {
width: 24px;
height: 8px;
background: var(--main-background-color);
border-radius: 4px 4px 0 0;
opacity: 0.4;
&.active {
height: 10px;
opacity: 1;
}
}
}
.note-content {
flex-grow: 1;
background: var(--main-background-color);
border-top-left-radius: 6px;
margin: 0;
}
}
}
.old-layout-illustration {
width: 170px;
height: 130px;

View File

@@ -1,6 +1,8 @@
import "./appearance.css";
import { FontFamily, OptionNames } from "@triliumnext/commons";
import { FontFamily, OptionNames, SYSTEM_MONOSPACE_FONT_STACK, SYSTEM_SANS_SERIF_FONT_STACK } from "@triliumnext/commons";
import { Fragment } from "preact";
import { createPortal } from "preact/compat";
import { useEffect, useState } from "preact/hooks";
import zoomService from "../../../components/zoom";
@@ -8,17 +10,14 @@ import { t } from "../../../services/i18n";
import server from "../../../services/server";
import { isElectron, isMobile, reloadFrontendApp, restartDesktopApp } from "../../../services/utils";
import { VerticalLayoutIcon } from "../../buttons/global_menu";
import Button from "../../react/Button";
import Column from "../../react/Column";
import FormCheckbox from "../../react/FormCheckbox";
import FormGroup from "../../react/FormGroup";
import FormRadioGroup from "../../react/FormRadioGroup";
import FormSelect, { FormSelectWithGroups } from "../../react/FormSelect";
import FormText from "../../react/FormText";
import Dropdown from "../../react/Dropdown";
import FormList, { FormListHeader, FormListItem } from "../../react/FormList";
import { FormTextBoxWithUnit } from "../../react/FormTextBox";
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import Icon from "../../react/Icon";
import OptionsRow from "./components/OptionsRow";
import Modal from "../../react/Modal";
import Slider from "../../react/Slider";
import OptionsRow, { OptionsRowWithButton, OptionsRowWithToggle } from "./components/OptionsRow";
import OptionsSection from "./components/OptionsSection";
import PlatformIndicator from "./components/PlatformIndicator";
import RadioWithIllustration from "./components/RadioWithIllustration";
@@ -29,16 +28,20 @@ const MIN_CONTENT_WIDTH = 640;
interface Theme {
val: string;
title: string;
icon?: 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") }
const MODERN_THEMES: Theme[] = [
{ val: "next", title: t("theme.triliumnext"), icon: "bx bx-sun bx-flip-horizontal" },
{ val: "next-light", title: t("theme.triliumnext-light"), icon: "bx bx-sun" },
{ val: "next-dark", title: t("theme.triliumnext-dark"), icon: "bx bx-moon" }
];
const LEGACY_THEMES: Theme[] = [
{ val: "auto", title: t("theme.auto_theme"), icon: "bx bx-sun bx-flip-horizontal" },
{ val: "light", title: t("theme.light_theme"), icon: "bx bx-sun" },
{ val: "dark", title: t("theme.dark_theme"), icon: "bx bx-moon" }
];
interface FontFamilyEntry {
@@ -89,14 +92,10 @@ const FONT_FAMILIES: FontGroup[] = [
];
export default function AppearanceSettings() {
const [ overrideThemeFonts ] = useTriliumOption("overrideThemeFonts");
return (
<div>
{!isMobile() && <LayoutSwitcher />}
{!isMobile() && <LayoutOrientation />}
<ApplicationTheme />
{overrideThemeFonts === "true" && <Fonts />}
<UserInterface />
<Fonts />
{isElectron() && <ElectronIntegration /> }
<Performance />
<MaxContentWidth />
@@ -115,22 +114,97 @@ export default function AppearanceSettings() {
);
}
function LayoutSwitcher() {
function UserInterface() {
const [ theme, setTheme ] = useTriliumOption("theme", true);
const [ customThemes, setCustomThemes ] = useState<Theme[]>([]);
const [ newLayout, setNewLayout ] = useTriliumOptionBool("newLayout");
const [ layoutOrientation, setLayoutOrientation ] = useTriliumOption("layoutOrientation", true);
useEffect(() => {
server.get<Theme[]>("options/user-themes").then((userThemes) => {
setCustomThemes(userThemes);
});
}, []);
// Find current theme for display
const allThemes = [...MODERN_THEMES, ...LEGACY_THEMES, ...customThemes];
const currentTheme = allThemes.find(t => t.val === theme);
const currentThemeIcon = currentTheme?.icon ?? "bx bx-palette";
const currentThemeLabel = currentTheme?.title ?? theme ?? "";
return (
<OptionsSection title={t("settings_appearance.ui")}>
<RadioWithIllustration
currentValue={newLayout ? "new-layout" : "old-layout"}
onChange={async newValue => {
await setNewLayout(newValue === "new-layout");
reloadFrontendApp();
}}
values={[
{ key: "old-layout", text: t("settings_appearance.ui_old_layout"), illustration: <LayoutIllustration /> },
{ key: "new-layout", text: t("settings_appearance.ui_new_layout"), illustration: <LayoutIllustration isNewLayout /> }
]}
/>
<OptionsSection title={t("theme.title")}>
<OptionsRow name="theme" label={t("theme.theme_label")}>
<Dropdown
text={<>
<span className={currentThemeIcon} style={{ marginRight: "8px" }} />
{currentThemeLabel}
</>}
>
<FormListHeader text={t("theme.modern_themes")} />
{MODERN_THEMES.map(th => (
<FormListItem
key={th.val}
icon={th.icon}
selected={theme === th.val}
onClick={() => setTheme(th.val)}
>
{th.title}
</FormListItem>
))}
<FormListHeader text={t("theme.legacy_themes")} />
{LEGACY_THEMES.map(th => (
<FormListItem
key={th.val}
icon={th.icon}
selected={theme === th.val}
onClick={() => setTheme(th.val)}
>
{th.title}
</FormListItem>
))}
{customThemes.length > 0 && (
<>
<FormListHeader text={t("theme.custom_themes")} />
{customThemes.map(ct => (
<FormListItem
key={ct.val}
icon={ct.icon}
selected={theme === ct.val}
onClick={() => setTheme(ct.val)}
>
{ct.title}
</FormListItem>
))}
</>
)}
</Dropdown>
</OptionsRow>
{!isMobile() && <>
<OptionsRow name="layout-style" label={t("settings_appearance.ui_layout_style")}>
<RadioWithIllustration
currentValue={newLayout ? "new-layout" : "old-layout"}
onChange={async newValue => {
await setNewLayout(newValue === "new-layout");
reloadFrontendApp();
}}
values={[
{ key: "old-layout", text: t("settings_appearance.ui_old_layout"), illustration: <LayoutIllustration /> },
{ key: "new-layout", text: t("settings_appearance.ui_new_layout"), illustration: <LayoutIllustration isNewLayout /> }
]}
/>
</OptionsRow>
<OptionsRow name="layout-orientation" label={t("settings_appearance.ui_layout_orientation")}>
<RadioWithIllustration
currentValue={layoutOrientation ?? "vertical"}
onChange={setLayoutOrientation}
values={[
{ key: "vertical", text: t("theme.layout-vertical-title"), illustration: <OrientationIllustration orientation="vertical" /> },
{ key: "horizontal", text: t("theme.layout-horizontal-title"), illustration: <OrientationIllustration orientation="horizontal" /> }
]}
/>
</OptionsRow>
</>}
</OptionsSection>
);
}
@@ -227,113 +301,237 @@ function LayoutIllustration({ isNewLayout }: { isNewLayout?: boolean }) {
);
}
function LayoutOrientation() {
const [ layoutOrientation, setLayoutOrientation ] = useTriliumOption("layoutOrientation", true);
function OrientationIllustration({ orientation }: { orientation: "vertical" | "horizontal" }) {
const isHorizontal = orientation === "horizontal";
return (
<OptionsSection title={t("theme.layout")}>
<FormRadioGroup
name="layout-orientation"
values={[
{
label: t("theme.layout-vertical-title"),
inlineDescription: t("theme.layout-vertical-description"),
value: "vertical"
},
{
label: t("theme.layout-horizontal-title"),
inlineDescription: t("theme.layout-horizontal-description"),
value: "horizontal"
}
]}
currentValue={layoutOrientation} onChange={setLayoutOrientation}
<div className={`orientation-illustration ${orientation}`}>
{isHorizontal && (
<div className="tab-bar full-width">
<div className="tab active" />
<div className="tab" />
<div className="tab" />
</div>
)}
{isHorizontal && (
<div className="launcher-bar horizontal">
<Icon icon="bx bx-menu" />
<Icon icon="bx bx-send" />
<Icon icon="bx bx-file-blank" />
<Icon icon="bx bx-search" />
</div>
)}
<div className="main-area">
{!isHorizontal && (
<div className="launcher-bar vertical">
<Icon icon="bx bx-menu" />
<Icon icon="bx bx-send" />
<Icon icon="bx bx-file-blank" />
<Icon icon="bx bx-search" />
</div>
)}
<div className="tree-pane">
<div className="tree-content" />
</div>
<div className="content-pane">
{!isHorizontal && (
<div className="tab-bar">
<div className="tab active" />
<div className="tab" />
<div className="tab" />
</div>
)}
<div className="note-content" />
</div>
</div>
</div>
);
}
function Fonts() {
const [ overrideThemeFonts, setOverrideThemeFonts ] = useTriliumOptionBool("overrideThemeFonts");
const isEnabled = overrideThemeFonts === true;
return (
<OptionsSection title={t("fonts.fonts")}>
<OptionsRowWithToggle
name="override-theme-fonts"
label={t("fonts.custom_fonts")}
description={t("fonts.not_all_fonts_available")}
currentValue={overrideThemeFonts}
onChange={setOverrideThemeFonts}
/>
<Font label={t("fonts.main_font")} fontFamilyOption="mainFontFamily" fontSizeOption="mainFontSize" disabled={!isEnabled} />
<Font label={t("fonts.note_tree_font")} sizeDescription={t("fonts.size_relative_to_general")} fontFamilyOption="treeFontFamily" fontSizeOption="treeFontSize" disabled={!isEnabled} />
<Font label={t("fonts.note_detail_font")} sizeDescription={t("fonts.size_relative_to_general")} fontFamilyOption="detailFontFamily" fontSizeOption="detailFontSize" disabled={!isEnabled} />
<Font label={t("fonts.monospace_font")} description={t("fonts.monospace_font_description")} fontFamilyOption="monospaceFontFamily" fontSizeOption="monospaceFontSize" disabled={!isEnabled} isMonospace />
<OptionsRowWithButton
label={t("fonts.apply_changes")}
icon="bx bx-refresh"
onClick={reloadFrontendApp}
/>
</OptionsSection>
);
}
function ApplicationTheme() {
const [ theme, setTheme ] = useTriliumOption("theme", true);
const [ overrideThemeFonts, setOverrideThemeFonts ] = useTriliumOptionBool("overrideThemeFonts");
const [ themes, setThemes ] = useState<Theme[]>([]);
useEffect(() => {
server.get<Theme[]>("options/user-themes").then((userThemes) => {
setThemes([
...BUILTIN_THEMES,
...userThemes
]);
});
}, []);
return (
<OptionsSection title={t("theme.title")}>
<div className="row">
<FormGroup name="theme" label={t("theme.theme_label")} className="col-md-6" style={{ marginBottom: 0 }}>
<FormSelect
values={themes} currentValue={theme} onChange={setTheme}
keyProperty="val" titleProperty="title"
/>
</FormGroup>
<FormGroup className="side-checkbox col-md-6" name="override-theme-fonts">
<FormCheckbox
label={t("theme.override_theme_fonts_label")}
currentValue={overrideThemeFonts} onChange={setOverrideThemeFonts} />
</FormGroup>
</div>
</OptionsSection>
);
interface FontProps {
label: string;
description?: string;
sizeDescription?: string;
fontFamilyOption: OptionNames;
fontSizeOption: OptionNames;
disabled?: boolean;
isMonospace?: boolean;
}
function Fonts() {
return (
<OptionsSection title={t("fonts.fonts")}>
<Font title={t("fonts.main_font")} fontFamilyOption="mainFontFamily" fontSizeOption="mainFontSize" />
<Font title={t("fonts.note_tree_font")} fontFamilyOption="treeFontFamily" fontSizeOption="treeFontSize" />
<Font title={t("fonts.note_detail_font")} fontFamilyOption="detailFontFamily" fontSizeOption="detailFontSize" />
<Font title={t("fonts.monospace_font")} fontFamilyOption="monospaceFontFamily" fontSizeOption="monospaceFontSize" />
<FormText>{t("fonts.note_tree_and_detail_font_sizing")}</FormText>
<FormText>{t("fonts.not_all_fonts_available")}</FormText>
<p>
{t("fonts.apply_font_changes")} <Button text={t("fonts.reload_frontend")} size="micro" onClick={reloadFrontendApp} />
</p>
</OptionsSection>
);
}
function Font({ title, fontFamilyOption, fontSizeOption }: { title: string, fontFamilyOption: OptionNames, fontSizeOption: OptionNames }) {
function Font({ label, description, sizeDescription, fontFamilyOption, fontSizeOption, disabled, isMonospace }: FontProps) {
const [ fontFamily, setFontFamily ] = useTriliumOption(fontFamilyOption);
const [ fontSize, setFontSize ] = useTriliumOption(fontSizeOption);
const [ showModal, setShowModal ] = useState(false);
// Find the current font entry to display
const currentFont = FONT_FAMILIES
.flatMap(group => group.items)
.find(item => item.value === fontFamily);
const displayLabel = currentFont?.label ?? currentFont?.value ?? fontFamily ?? "";
// Map option name to CSS variable
const themeCssVariable = {
mainFontFamily: "var(--main-font-family)",
treeFontFamily: "var(--tree-font-family)",
detailFontFamily: "var(--detail-font-family)",
monospaceFontFamily: "var(--monospace-font-family)"
}[fontFamilyOption] ?? "inherit";
// Get the CSS font-family value for preview
const getFontFamily = (value: string) => {
if (value === "theme") {
// Use the theme's CSS variable for this font option
return themeCssVariable;
}
if (value === "system") {
// Use the appropriate system font stack
return isMonospace ? SYSTEM_MONOSPACE_FONT_STACK : SYSTEM_SANS_SERIF_FONT_STACK;
}
return value;
};
return (
<>
<h5>{title}</h5>
<div className="row">
<FormGroup name="font-family" className="col-md-4" label={t("fonts.font_family")}>
<FormSelectWithGroups
values={FONT_FAMILIES}
currentValue={fontFamily} onChange={setFontFamily}
keyProperty="value" titleProperty="label"
/>
</FormGroup>
<button
type="button"
className="option-row option-row-link font-option-row"
onClick={() => setShowModal(true)}
disabled={disabled}
>
<div className="option-row-label">
<label style={{ cursor: "pointer" }}>{label}</label>
{description && <small>{description}</small>}
</div>
<div className="option-row-input font-option-preview">
<span style={{ fontFamily: getFontFamily(fontFamily ?? ""), fontSize: `${fontSize}%` }}>{displayLabel}</span>
<span className="bx bx-chevron-right" />
</div>
</button>
<FormGroup name="font-size" className="col-md-6" label={t("fonts.size")}>
<FormTextBoxWithUnit
name="tree-font-size"
type="number" min={50} max={200} step={10}
currentValue={fontSize} onBlur={setFontSize}
unit={t("units.percentage")}
/>
</FormGroup>
</div>
<FontPickerModal
show={showModal}
onHidden={() => setShowModal(false)}
title={label}
fontFamily={fontFamily ?? ""}
fontSize={parseInt(fontSize ?? "100", 10)}
onFontFamilyChange={setFontFamily}
onFontSizeChange={(size) => setFontSize(String(size))}
getFontFamily={getFontFamily}
sizeDescription={sizeDescription}
/>
</>
);
}
const PREVIEW_TEXT = "The quick brown fox jumps over the lazy dog. 0123456789";
interface FontPickerModalProps {
show: boolean;
onHidden: () => void;
title: string;
fontFamily: string;
fontSize: number;
onFontFamilyChange: (value: string) => void;
onFontSizeChange: (value: number) => void;
getFontFamily: (value: string) => string | undefined;
sizeDescription?: string;
}
function FontPickerModal({ show, onHidden, title, fontFamily, fontSize, onFontFamilyChange, onFontSizeChange, getFontFamily, sizeDescription }: FontPickerModalProps) {
return createPortal(
<Modal
className="font-picker-modal"
title={title}
size="lg"
show={show}
onHidden={onHidden}
>
<div className="font-picker-content">
<div className="font-picker-list">
<FormList fullHeight>
{FONT_FAMILIES.map(group => (
<Fragment key={group.title}>
<FormListHeader text={group.title} />
{group.items.map(item => (
<FormListItem
key={item.value}
onClick={() => onFontFamilyChange(item.value)}
checked={fontFamily === item.value}
selected={fontFamily === item.value}
>
<span style={{ fontFamily: getFontFamily(item.value) }}>
{item.label ?? item.value}
</span>
</FormListItem>
))}
</Fragment>
))}
</FormList>
</div>
<div className="font-picker-settings">
<div className="font-size-control">
<label>{t("fonts.size")}</label>
<div className="font-size-slider">
<Slider
value={fontSize}
onChange={onFontSizeChange}
min={50}
max={200}
step={5}
/>
<span className="font-size-value">{fontSize}%</span>
</div>
{sizeDescription && <small className="font-size-description">{sizeDescription}</small>}
</div>
<div className="font-preview">
<label>{t("fonts.preview")}</label>
<div
className="font-preview-text"
style={{
fontFamily: getFontFamily(fontFamily),
fontSize: `${fontSize}%`
}}
>
{PREVIEW_TEXT}
</div>
</div>
</div>
</div>
</Modal>,
document.body
);
}
function ElectronIntegration() {
const [ zoomFactor ] = useTriliumOption("zoomFactor");
const [ nativeTitleBarVisible, setNativeTitleBarVisible ] = useTriliumOptionBool("nativeTitleBarVisible");
@@ -353,26 +551,28 @@ function ElectronIntegration() {
/>
</OptionsRow>
<FormGroup name="native-title-bar" description={t("electron_integration.native-title-bar-description")}>
<FormCheckbox
label={t("electron_integration.native-title-bar")}
currentValue={nativeTitleBarVisible} onChange={setNativeTitleBarVisible}
/>
</FormGroup>
<OptionsRowWithToggle
name="native-title-bar"
label={t("electron_integration.native-title-bar")}
description={t("electron_integration.native-title-bar-description")}
currentValue={nativeTitleBarVisible}
onChange={setNativeTitleBarVisible}
/>
<FormGroup name="background-effects" description={t("electron_integration.background-effects-description")}>
<FormCheckbox
label={<>
{t("electron_integration.background-effects")}
{" "}
<PlatformIndicator windows="11" mac />
</>}
currentValue={backgroundEffects} onChange={setBackgroundEffects}
disabled={nativeTitleBarVisible}
/>
</FormGroup>
<OptionsRowWithToggle
name="background-effects"
label={<>{t("electron_integration.background-effects")} <PlatformIndicator windows="11" mac /></>}
description={t("electron_integration.background-effects-description")}
currentValue={backgroundEffects}
onChange={setBackgroundEffects}
disabled={nativeTitleBarVisible}
/>
<Button text={t("electron_integration.restart-app-button")} onClick={restartDesktopApp} />
<OptionsRowWithButton
label={t("electron_integration.restart-app-button")}
icon="bx bx-refresh"
onClick={restartDesktopApp}
/>
</OptionsSection>
);
}
@@ -383,19 +583,25 @@ function Performance() {
const [ backdropEffectsEnabled, setBackdropEffectsEnabled ] = useTriliumOptionBool("backdropEffectsEnabled");
return <OptionsSection title={t("ui-performance.title")}>
<FormCheckbox
<OptionsRowWithToggle
name="motion-enabled"
label={t("ui-performance.enable-motion")}
currentValue={motionEnabled} onChange={setMotionEnabled}
currentValue={motionEnabled}
onChange={setMotionEnabled}
/>
<FormCheckbox
<OptionsRowWithToggle
name="shadows-enabled"
label={t("ui-performance.enable-shadows")}
currentValue={shadowsEnabled} onChange={setShadowsEnabled}
currentValue={shadowsEnabled}
onChange={setShadowsEnabled}
/>
{!isMobile() && <FormCheckbox
{!isMobile() && <OptionsRowWithToggle
name="backdrop-effects-enabled"
label={t("ui-performance.enable-backdrop-effects")}
currentValue={backdropEffectsEnabled} onChange={setBackdropEffectsEnabled}
currentValue={backdropEffectsEnabled}
onChange={setBackdropEffectsEnabled}
/>}
{isElectron() && <SmoothScrollEnabledOption />}
@@ -406,9 +612,12 @@ function Performance() {
function SmoothScrollEnabledOption() {
const [ smoothScrollEnabled, setSmoothScrollEnabled ] = useTriliumOptionBool("smoothScrollEnabled");
return <FormCheckbox
label={`${t("ui-performance.enable-smooth-scroll")} ${t("ui-performance.app-restart-required")}`}
currentValue={smoothScrollEnabled} onChange={setSmoothScrollEnabled}
return <OptionsRowWithToggle
name="smooth-scroll-enabled"
label={t("ui-performance.enable-smooth-scroll")}
description={t("ui-performance.app-restart-required")}
currentValue={smoothScrollEnabled}
onChange={setSmoothScrollEnabled}
/>;
}
@@ -417,22 +626,21 @@ function MaxContentWidth() {
const [centerContent, setCenterContent] = useTriliumOptionBool("centerContent");
return (
<OptionsSection title={t("max_content_width.title")}>
<FormText>{t("max_content_width.default_description")}</FormText>
<OptionsSection title={t("max_content_width.title")} description={t("max_content_width.default_description")}>
<OptionsRow name="max-content-width" label={t("max_content_width.max_width_label")}>
<FormTextBoxWithUnit
type="number" min={MIN_CONTENT_WIDTH} step="10"
currentValue={maxContentWidth} onBlur={setMaxContentWidth}
unit={t("max_content_width.max_width_unit")}
/>
</OptionsRow>
<Column md={6}>
<FormGroup name="max-content-width" label={t("max_content_width.max_width_label")}>
<FormTextBoxWithUnit
type="number" min={MIN_CONTENT_WIDTH} step="10"
currentValue={maxContentWidth} onBlur={setMaxContentWidth}
unit={t("max_content_width.max_width_unit")}
/>
</FormGroup>
</Column>
<FormCheckbox label={t("max_content_width.centerContent")}
<OptionsRowWithToggle
name="center-content"
label={t("max_content_width.centerContent")}
currentValue={centerContent}
onChange={setCenterContent} />
onChange={setCenterContent}
/>
</OptionsSection>
);
}
@@ -442,9 +650,11 @@ function RibbonOptions() {
return (
<OptionsSection title={t('ribbon.widgets')}>
<FormCheckbox
<OptionsRowWithToggle
name="edited-notes-open-in-ribbon"
label={t('ribbon.edited_notes_message')}
currentValue={editedNotesOpenInRibbon} onChange={setEditedNotesOpenInRibbon}
currentValue={editedNotesOpenInRibbon}
onChange={setEditedNotesOpenInRibbon}
/>
</OptionsSection>
);

View File

@@ -5,15 +5,14 @@ import { t } from "../../../services/i18n";
import server from "../../../services/server";
import toast from "../../../services/toast";
import { formatDateTime } from "../../../utils/formatters";
import Button from "../../react/Button";
import FormCheckbox from "../../react/FormCheckbox";
import { FormMultiGroup } from "../../react/FormGroup";
import ActionButton from "../../react/ActionButton";
import FormText from "../../react/FormText";
import { useTriliumOptionBool } from "../../react/hooks";
import { OptionsRowWithButton, OptionsRowWithToggle } from "./components/OptionsRow";
import OptionsSection from "./components/OptionsSection";
export default function BackupSettings() {
const [ backups, setBackups ] = useState<DatabaseBackup[]>([]);
const [backups, setBackups] = useState<DatabaseBackup[]>([]);
const refreshBackups = useCallback(() => {
server.get<DatabaseBackup[]>("database/backups").then((backupFiles) => {
@@ -26,56 +25,54 @@ export default function BackupSettings() {
setBackups(backupFiles);
});
}, [ setBackups ]);
}, [setBackups]);
useEffect(refreshBackups, []);
return (
<>
<AutomaticBackup />
<BackupNow refreshCallback={refreshBackups} />
<BackupConfiguration refreshCallback={refreshBackups} />
<BackupList backups={backups} />
</>
);
}
export function AutomaticBackup() {
const [ dailyBackupEnabled, setDailyBackupEnabled ] = useTriliumOptionBool("dailyBackupEnabled");
const [ weeklyBackupEnabled, setWeeklyBackupEnabled ] = useTriliumOptionBool("weeklyBackupEnabled");
const [ monthlyBackupEnabled, setMonthlyBackupEnabled ] = useTriliumOptionBool("monthlyBackupEnabled");
export function BackupConfiguration({ refreshCallback }: { refreshCallback: () => void }) {
const [dailyBackupEnabled, setDailyBackupEnabled] = useTriliumOptionBool("dailyBackupEnabled");
const [weeklyBackupEnabled, setWeeklyBackupEnabled] = useTriliumOptionBool("weeklyBackupEnabled");
const [monthlyBackupEnabled, setMonthlyBackupEnabled] = useTriliumOptionBool("monthlyBackupEnabled");
return (
<OptionsSection title={t("backup.automatic_backup")}>
<FormMultiGroup label={t("backup.automatic_backup_description")}>
<FormCheckbox
name="daily-backup-enabled"
label={t("backup.enable_daily_backup")}
currentValue={dailyBackupEnabled} onChange={setDailyBackupEnabled}
/>
<OptionsSection title={t("backup.title")}>
<FormText>{t("backup.automatic_backup_description")}</FormText>
<FormCheckbox
name="weekly-backup-enabled"
label={t("backup.enable_weekly_backup")}
currentValue={weeklyBackupEnabled} onChange={setWeeklyBackupEnabled}
/>
<OptionsRowWithToggle
name="daily-backup-enabled"
label={t("backup.enable_daily_backup")}
currentValue={dailyBackupEnabled}
onChange={setDailyBackupEnabled}
/>
<FormCheckbox
name="monthly-backup-enabled"
label={t("backup.enable_monthly_backup")}
currentValue={monthlyBackupEnabled} onChange={setMonthlyBackupEnabled}
/>
</FormMultiGroup>
<OptionsRowWithToggle
name="weekly-backup-enabled"
label={t("backup.enable_weekly_backup")}
currentValue={weeklyBackupEnabled}
onChange={setWeeklyBackupEnabled}
/>
<OptionsRowWithToggle
name="monthly-backup-enabled"
label={t("backup.enable_monthly_backup")}
currentValue={monthlyBackupEnabled}
onChange={setMonthlyBackupEnabled}
/>
<FormText>{t("backup.backup_recommendation")}</FormText>
</OptionsSection>
);
}
export function BackupNow({ refreshCallback }: { refreshCallback: () => void }) {
return (
<OptionsSection title={t("backup.backup_now")}>
<Button
text={t("backup.backup_database_now")}
<hr />
<OptionsRowWithButton
label={t("backup.backup_database_now")}
onClick={async () => {
const { backupFile } = await server.post<BackupDatabaseNowResponse>("database/backup-database");
toast.showMessage(t("backup.database_backed_up_to", { backupFilePath: backupFile }), 10000);
@@ -110,7 +107,7 @@ export function BackupList({ backups }: { backups: DatabaseBackup[] }) {
<td className="selectable-text">{filePath}</td>
<td>
<a href={`api/database/backup/download?filePath=${encodeURIComponent(filePath)}`} download>
<Button text={t("backup.download")} />
<ActionButton icon="bx bx-download" text={t("backup.download")} />
</a>
</td>
</tr>

View File

@@ -8,48 +8,73 @@ import { useEffect, useMemo, useRef } from "preact/hooks";
import { t } from "../../../services/i18n";
import mime_types from "../../../services/mime_types";
import Column from "../../react/Column";
import FormCheckbox from "../../react/FormCheckbox";
import FormGroup from "../../react/FormGroup";
import FormSelect from "../../react/FormSelect";
import { FormTextBoxWithUnit } from "../../react/FormTextBox";
import { useStaticTooltip, useTriliumOption, useTriliumOptionBool, useTriliumOptionJson } from "../../react/hooks";
import { CODE_THEME_DEFAULT_PREFIX as DEFAULT_PREFIX } from "../constants";
import AutoReadOnlySize from "./components/AutoReadOnlySize";
import CheckboxList from "./components/CheckboxList";
import OptionsRow, { OptionsRowWithToggle } from "./components/OptionsRow";
import OptionsSection from "./components/OptionsSection";
import codeNoteSample from "./samples/code_note.txt?raw";
const SAMPLE_MIME = "application/typescript";
export default function CodeNoteSettings() {
const [codeLineWrapEnabled, setCodeLineWrapEnabled] = useTriliumOptionBool("codeLineWrapEnabled");
return (
<>
<Editor />
<Appearance />
<Editor wordWrapping={codeLineWrapEnabled} setWordWrapping={setCodeLineWrapEnabled} />
<Appearance wordWrapping={codeLineWrapEnabled} />
<CodeMimeTypes />
<AutoReadOnlySize option="autoReadonlySizeCode" label={t("code_auto_read_only_size.label")} />
</>
);
}
function Editor() {
const [ vimKeymapEnabled, setVimKeymapEnabled ] = useTriliumOptionBool("vimKeymapEnabled");
interface EditorProps {
wordWrapping: boolean;
setWordWrapping: (newValue: boolean) => void;
}
function Editor({ wordWrapping, setWordWrapping }: EditorProps) {
const [vimKeymapEnabled, setVimKeymapEnabled] = useTriliumOptionBool("vimKeymapEnabled");
const [autoReadonlySize, setAutoReadonlySize] = useTriliumOption("autoReadonlySizeCode");
return (
<OptionsSection title={t("code-editor-options.title")}>
<FormGroup name="vim-keymap-enabled" description={t("vim_key_bindings.enable_vim_keybindings")}>
<FormCheckbox
label={t("vim_key_bindings.use_vim_keybindings_in_code_notes")}
currentValue={vimKeymapEnabled} onChange={setVimKeymapEnabled}
<OptionsRowWithToggle
name="word-wrap"
label={t("code_theme.word_wrapping")}
currentValue={wordWrapping}
onChange={setWordWrapping}
/>
<OptionsRow name="source-readonly-threshold" label={t("code_auto_read_only_size.label")} description={t("text_auto_read_only_size.description")}>
<FormTextBoxWithUnit
type="number" min={0}
unit={t("text_auto_read_only_size.unit")}
currentValue={autoReadonlySize}
onBlur={setAutoReadonlySize}
/>
</FormGroup>
</OptionsRow>
<OptionsRowWithToggle
name="vim-keymap-enabled"
label={t("vim_key_bindings.use_vim_keybindings_in_code_notes")}
description={t("vim_key_bindings.enable_vim_keybindings")}
currentValue={vimKeymapEnabled}
onChange={setVimKeymapEnabled}
/>
</OptionsSection>
);
}
function Appearance() {
const [ codeNoteTheme, setCodeNoteTheme ] = useTriliumOption("codeNoteTheme");
const [ codeLineWrapEnabled, setCodeLineWrapEnabled ] = useTriliumOptionBool("codeLineWrapEnabled");
interface AppearanceProps {
wordWrapping: boolean;
}
function Appearance({ wordWrapping }: AppearanceProps) {
const [codeNoteTheme, setCodeNoteTheme] = useTriliumOption("codeNoteTheme");
const themes = useMemo(() => {
return ColorThemes.map(({ id, name }) => ({
@@ -60,25 +85,15 @@ function Appearance() {
return (
<OptionsSection title={t("code_theme.title")}>
<div className="row" style={{ marginBottom: "15px" }}>
<FormGroup name="color-scheme" label={t("code_theme.color-scheme")} className="col-md-6" style={{ marginBottom: 0 }}>
<FormSelect
values={themes}
keyProperty="id" titleProperty="name"
currentValue={codeNoteTheme} onChange={setCodeNoteTheme}
/>
</FormGroup>
<OptionsRow name="color-scheme" label={t("code_theme.color-scheme")}>
<FormSelect
values={themes}
keyProperty="id" titleProperty="name"
currentValue={codeNoteTheme} onChange={setCodeNoteTheme}
/>
</OptionsRow>
<Column className="side-checkbox">
<FormCheckbox
name="word-wrap"
label={t("code_theme.word_wrapping")}
currentValue={codeLineWrapEnabled} onChange={setCodeLineWrapEnabled}
/>
</Column>
</div>
<CodeNotePreview wordWrapping={codeLineWrapEnabled} themeName={codeNoteTheme} />
<CodeNotePreview wordWrapping={wordWrapping} themeName={codeNoteTheme} />
</OptionsSection>
);
}

View File

@@ -38,7 +38,7 @@
color: var(--muted-text-color);
}
.option-row:last-of-type {
.option-row:last-child {
border-bottom: unset;
}
@@ -56,7 +56,7 @@
width: 100%;
}
.option-row-link.use-tn-links {
.option-row-link {
text-decoration: none;
color: inherit;
margin-inline: calc(-1 * var(--options-card-padding, 15px));
@@ -67,3 +67,37 @@
.option-row-link:hover {
background: var(--hover-item-background-color);
}
button.option-row-link {
background: none;
border: none;
border-bottom: 1px solid var(--main-border-color);
width: calc(100% + 2 * var(--options-card-padding, 15px));
text-align: left;
font: inherit;
cursor: pointer;
}
button.option-row-link:last-child {
border-bottom: none;
}
.search-engine-templates {
--badge-radius: 12px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.search-engine-templates .ext-badge {
--color: var(--input-background-color);
color: var(--main-text-color);
font-size: 0.85em;
}
.search-engine-templates .ext-badge.selected {
--color: var(--accented-background-color);
outline: 2px solid var(--accent-color);
outline-offset: -2px;
}

View File

@@ -1,13 +1,14 @@
import "./OptionsRow.css";
import { cloneElement, VNode } from "preact";
import { cloneElement, ComponentChildren, VNode } from "preact";
import FormToggle from "../../../react/FormToggle";
import { useUniqueName } from "../../../react/hooks";
interface OptionsRowProps {
name: string;
label?: string;
description?: string;
label?: ComponentChildren;
description?: ComponentChildren;
children: VNode;
centered?: boolean;
/** When true, stacks label above input with full-width input */
@@ -16,7 +17,7 @@ interface OptionsRowProps {
export default function OptionsRow({ name, label, description, children, centered, stacked }: OptionsRowProps) {
const id = useUniqueName(name);
const childWithId = cloneElement(children, { id });
const childWithId = cloneElement(children, { id, name: (children.props as { name?: string }).name ?? name });
const className = `option-row ${centered ? "centered" : ""} ${stacked ? "stacked" : ""}`;
@@ -41,7 +42,7 @@ interface OptionsRowLinkProps {
export function OptionsRowLink({ label, description, href }: OptionsRowLinkProps) {
return (
<a href={href} className="option-row option-row-link use-tn-links no-tooltip-preview">
<a href={href} className="option-row option-row-link no-tooltip-preview">
<div className="option-row-label">
<label style={{ cursor: "pointer" }}>{label}</label>
{description && <small className="option-row-description">{description}</small>}
@@ -52,3 +53,56 @@ export function OptionsRowLink({ label, description, href }: OptionsRowLinkProps
</a>
);
}
interface OptionsRowWithToggleProps {
name: string;
label: ComponentChildren;
description?: ComponentChildren;
currentValue: boolean | null;
onChange: (newValue: boolean) => void;
disabled?: boolean;
helpPage?: string;
}
export function OptionsRowWithToggle({ name, label, description, currentValue, onChange, disabled, helpPage }: OptionsRowWithToggleProps) {
return (
<OptionsRow name={name} label={label} description={description}>
<FormToggle
switchOnName=""
switchOffName=""
currentValue={currentValue}
onChange={onChange}
disabled={disabled}
helpPage={helpPage}
/>
</OptionsRow>
);
}
interface OptionsRowWithButtonProps {
label: string;
description?: string;
icon?: string;
onClick: () => void;
}
export function OptionsRowWithButton({ label, description, icon, onClick }: OptionsRowWithButtonProps) {
return (
<button
type="button"
className="option-row option-row-link"
onClick={onClick}
aria-label={label}
>
<div className="option-row-label">
<span style={{ cursor: "pointer" }}>{label}</span>
{description && <small className="option-row-description">{description}</small>}
</div>
{icon && (
<div className="option-row-input">
<span className={icon} />
</div>
)}
</button>
);
}

View File

@@ -1,19 +1,47 @@
import type { ComponentChildren } from "preact";
import { CSSProperties } from "preact/compat";
import HelpButton from "../../../react/HelpButton";
interface OptionsSectionProps {
title?: ComponentChildren;
description?: ComponentChildren;
children: ComponentChildren;
noCard?: boolean;
style?: CSSProperties;
className?: string;
helpUrl?: string;
}
export default function OptionsSection({ title, children, noCard, className, ...rest }: OptionsSectionProps) {
return (
<div className={`options-section ${noCard ? "tn-no-card" : ""} ${className ?? ""}`} {...rest}>
export default function OptionsSection({ title, description, children, noCard, className, helpUrl, ...rest }: OptionsSectionProps) {
const header = (title || helpUrl) && (
<div className="options-section-header">
{title && <h4>{title}</h4>}
{helpUrl && <HelpButton helpPage={helpUrl} />}
</div>
);
const content = (
<>
{description && <p className="options-section-description">{description}</p>}
{children}
</>
);
if (noCard) {
return (
<div className={`options-section tn-no-card ${className ?? ""}`} {...rest}>
{header}
{content}
</div>
);
}
return (
<div className={`options-section ${className ?? ""}`} {...rest}>
{header}
<div className="options-section-card">
{content}
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
.options-section .radio-with-illustration {
.options-section-card .radio-with-illustration {
list-style-type: none;
margin-bottom: 0;
padding: 0;

View File

@@ -6,10 +6,7 @@ import OptionsSection from "./components/OptionsSection";
import { useTriliumOption, useTriliumOptionJson } from "../../react/hooks";
import type { Locale } from "@triliumnext/commons";
import { isElectron, restartDesktopApp } from "../../../services/utils";
import FormRadioGroup from "../../react/FormRadioGroup";
import FormText from "../../react/FormText";
import RawHtml from "../../react/RawHtml";
import Admonition from "../../react/Admonition";
import Button from "../../react/Button";
import CheckboxList from "./components/CheckboxList";
import RelatedSettings from "./components/RelatedSettings";
@@ -96,10 +93,13 @@ function DateSettings() {
/>
</OptionsRow>
<OptionsRow name="first-week-of-year" label={t("i18n.first-week-of-the-year")}>
<FormRadioGroup
<OptionsRow name="first-week-of-year" label={t("i18n.first-week-of-the-year")} description={t("i18n.first-week-warning")}>
<FormSelect
name="first-week-of-year"
currentValue={firstWeekOfYear} onChange={setFirstWeekOfYear}
currentValue={firstWeekOfYear}
onChange={setFirstWeekOfYear}
keyProperty="value"
titleProperty="label"
values={[
{ value: "0", label: t("i18n.first-week-contains-first-day") },
{ value: "1", label: t("i18n.first-week-contains-first-thursday") },
@@ -117,14 +117,6 @@ function DateSettings() {
(_, i) => ({ days: String(i + 1) }))} />
</OptionsRow>}
<FormText>
<RawHtml html={t("i18n.first-week-info")} />
</FormText>
<Admonition type="warning">
{t("i18n.first-week-warning")}
</Admonition>
<OptionsRow name="restart" centered>
<Button
name="restart-app-button"

View File

@@ -5,9 +5,8 @@ import { isExperimentalFeatureEnabled } from "../../../services/experimental_fea
import { t } from "../../../services/i18n";
import ActionButton from "../../react/ActionButton";
import Button from "../../react/Button";
import FormToggle from "../../react/FormToggle";
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import OptionsRow from "./components/OptionsRow";
import OptionsRow, { OptionsRowWithToggle } from "./components/OptionsRow";
import OptionsSection from "./components/OptionsSection";
import AddProviderModal, { type LlmProviderConfig, PROVIDER_TYPES } from "./llm/AddProviderModal";
@@ -92,13 +91,13 @@ function McpSettings() {
return (
<OptionsSection title={t("llm.mcp_title")}>
<OptionsRow name="mcp-enabled" label={t("llm.mcp_enabled")} description={t("llm.mcp_enabled_description")}>
<FormToggle
switchOnName="" switchOffName=""
currentValue={mcpEnabled}
onChange={setMcpEnabled}
/>
</OptionsRow>
<OptionsRowWithToggle
name="mcp-enabled"
label={t("llm.mcp_enabled")}
description={t("llm.mcp_enabled_description")}
currentValue={mcpEnabled}
onChange={setMcpEnabled}
/>
{mcpEnabled && (
<OptionsRow name="mcp-endpoint" label={t("llm.mcp_endpoint_title")} description={t("llm.mcp_endpoint_description")}>

View File

@@ -5,10 +5,9 @@ import server from "../../../services/server";
import toast from "../../../services/toast";
import { isElectron } from "../../../services/utils";
import { FormTextBoxWithUnit } from "../../react/FormTextBox";
import FormToggle from "../../react/FormToggle";
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import Slider from "../../react/Slider";
import OptionsRow from "./components/OptionsRow";
import OptionsRow, { OptionsRowWithToggle } from "./components/OptionsRow";
import OptionsSection from "./components/OptionsSection";
import RelatedSettings from "./components/RelatedSettings";
@@ -29,21 +28,21 @@ function ImageSettings() {
return (
<OptionsSection title={t("images.images_section_title")}>
<OptionsRow name="download-images-automatically" label={t("images.download_images_automatically")} description={t("images.download_images_description")}>
<FormToggle
switchOnName="" switchOffName=""
currentValue={downloadImagesAutomatically}
onChange={setDownloadImagesAutomatically}
/>
</OptionsRow>
<OptionsRowWithToggle
name="download-images-automatically"
label={t("images.download_images_automatically")}
description={t("images.download_images_description")}
currentValue={downloadImagesAutomatically}
onChange={setDownloadImagesAutomatically}
/>
<OptionsRow name="image-compression-enabled" label={t("images.enable_image_compression")} description={t("images.enable_image_compression_description")}>
<FormToggle
switchOnName="" switchOffName=""
currentValue={compressImages}
onChange={setCompressImages}
/>
</OptionsRow>
<OptionsRowWithToggle
name="image-compression-enabled"
label={t("images.enable_image_compression")}
description={t("images.enable_image_compression_description")}
currentValue={compressImages}
onChange={setCompressImages}
/>
<OptionsRow name="image-max-width-height" label={t("images.max_image_dimensions")} description={t("images.max_image_dimensions_description")}>
<FormTextBoxWithUnit
@@ -72,13 +71,13 @@ function OcrSettings() {
return (
<>
<OptionsSection title={t("images.ocr_section_title")}>
<OptionsRow name="ocr-auto-process" label={t("images.ocr_auto_process")} description={t("images.ocr_auto_process_description")}>
<FormToggle
switchOnName="" switchOffName=""
currentValue={ocrAutoProcess}
onChange={setOcrAutoProcess}
/>
</OptionsRow>
<OptionsRowWithToggle
name="ocr-auto-process"
label={t("images.ocr_auto_process")}
description={t("images.ocr_auto_process_description")}
currentValue={ocrAutoProcess}
onChange={setOcrAutoProcess}
/>
<OptionsRow name="ocr-min-confidence" label={`${t("images.ocr_min_confidence")} (${Math.round(parseFloat(ocrMinConfidence ?? "0.75") * 100)}%)`} description={t("images.ocr_confidence_description")}>
<Slider

View File

@@ -1,22 +1,17 @@
import { SANITIZER_DEFAULT_ALLOWED_TAGS } from "@triliumnext/commons";
import { useMemo } from "preact/hooks";
import type React from "react";
import { Trans } from "react-i18next";
import { t } from "../../../services/i18n";
import search from "../../../services/search";
import server from "../../../services/server";
import toast from "../../../services/toast";
import { isElectron } from "../../../services/utils";
import { Badge } from "../../react/Badge";
import Button from "../../react/Button";
import FormCheckbox from "../../react/FormCheckbox";
import FormGroup from "../../react/FormGroup";
import FormSelect from "../../react/FormSelect";
import FormText from "../../react/FormText";
import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox";
import FormToggle from "../../react/FormToggle";
import { useTriliumOption, useTriliumOptionBool, useTriliumOptionJson } from "../../react/hooks";
import OptionsRow from "./components/OptionsRow";
import OptionsRow, { OptionsRowWithButton, OptionsRowWithToggle } from "./components/OptionsRow";
import OptionsSection from "./components/OptionsSection";
import TimeSelector from "./components/TimeSelector";
@@ -30,8 +25,7 @@ export default function OtherSettings() {
</>}
<NoteErasureTimeout />
<AttachmentErasureTimeout />
<RevisionSnapshotInterval />
<RevisionSnapshotLimit />
<RevisionSettings />
<HtmlImportTags />
<ShareSettings />
<NetworkSettings />
@@ -45,29 +39,21 @@ function SearchSettings() {
return (
<OptionsSection title={t("search.title")}>
<OptionsRow
<OptionsRowWithToggle
name="search-fuzzy-matching"
label={t("search.fuzzy_matching_label")}
description={t("search.fuzzy_matching_description")}
>
<FormToggle
switchOnName="" switchOffName=""
currentValue={fuzzyEnabled}
onChange={setFuzzyEnabled}
/>
</OptionsRow>
currentValue={fuzzyEnabled}
onChange={setFuzzyEnabled}
/>
<OptionsRow
<OptionsRowWithToggle
name="search-autocomplete-fuzzy"
label={t("search.autocomplete_fuzzy_label")}
description={t("search.autocomplete_fuzzy_description")}
>
<FormToggle
switchOnName="" switchOffName=""
currentValue={autocompleteFuzzy}
onChange={setAutocompleteFuzzy}
/>
</OptionsRow>
currentValue={autocompleteFuzzy}
onChange={setAutocompleteFuzzy}
/>
</OptionsSection>
);
}
@@ -78,47 +64,45 @@ function SearchEngineSettings() {
const searchEngines = useMemo(() => {
return [
{ url: "https://www.bing.com/search?q={keyword}", name: t("search_engine.bing") },
{ url: "https://www.baidu.com/s?wd={keyword}", name: t("search_engine.baidu") },
{ url: "https://duckduckgo.com/?q={keyword}", name: t("search_engine.duckduckgo") },
{ url: "https://www.google.com/search?q={keyword}", name: t("search_engine.google") }
{ url: "https://www.bing.com/search?q={keyword}", name: t("search_engine.bing"), icon: "bx bxl-bing" },
{ url: "https://www.baidu.com/s?wd={keyword}", name: t("search_engine.baidu"), icon: "bx bxl-baidu" },
{ url: "https://www.google.com/search?q={keyword}", name: t("search_engine.google"), icon: "bx bxl-google" }
];
}, []);
return (
<OptionsSection title={t("search_engine.title")}>
<FormText>{t("search_engine.custom_search_engine_info")}</FormText>
<OptionsSection title={t("search_engine.title")} description={t("search_engine.custom_search_engine_info")}>
<OptionsRow name="predefined-templates" label={t("search_engine.predefined_templates_label")}>
<div className="search-engine-templates">
{searchEngines.map(engine => (
<Badge
key={engine.url}
icon={engine.icon}
text={engine.name}
className={customSearchEngineUrl === engine.url ? "selected" : ""}
onClick={() => {
setCustomSearchEngineName(engine.name);
setCustomSearchEngineUrl(engine.url);
}}
/>
))}
</div>
</OptionsRow>
<FormGroup name="predefined-search-engine" label={t("search_engine.predefined_templates_label")}>
<FormSelect
values={searchEngines}
currentValue={customSearchEngineUrl}
keyProperty="url" titleProperty="name"
onChange={newValue => {
const searchEngine = searchEngines.find(e => e.url === newValue);
if (!searchEngine) {
return;
}
setCustomSearchEngineName(searchEngine.name);
setCustomSearchEngineUrl(searchEngine.url);
}}
/>
</FormGroup>
<FormGroup name="custom-name" label={t("search_engine.custom_name_label")}>
<OptionsRow name="custom-name" label={t("search_engine.custom_name_label")}>
<FormTextBox
currentValue={customSearchEngineName} onChange={setCustomSearchEngineName}
currentValue={customSearchEngineName} onBlur={setCustomSearchEngineName}
placeholder={t("search_engine.custom_name_placeholder")}
/>
</FormGroup>
</OptionsRow>
<FormGroup name="custom-url" label={t("search_engine.custom_url_label")}>
<OptionsRow name="custom-url" label={t("search_engine.custom_url_label")} description={t("search_engine.custom_url_description")} stacked>
<FormTextBox
currentValue={customSearchEngineUrl} onChange={setCustomSearchEngineUrl}
currentValue={customSearchEngineUrl} onBlur={setCustomSearchEngineUrl}
placeholder={t("search_engine.custom_url_placeholder")}
/>
</FormGroup>
</OptionsRow>
</OptionsSection>
);
}
@@ -128,9 +112,10 @@ function TrayOptionsSettings() {
return (
<OptionsSection title={t("tray.title")}>
<FormCheckbox
<OptionsRowWithToggle
name="tray-enabled"
label={t("tray.enable_tray")}
description={t("tray.enable_tray_description")}
currentValue={!disableTray}
onChange={trayEnabled => setDisableTray(!trayEnabled)}
/>
@@ -141,17 +126,18 @@ function TrayOptionsSettings() {
function NoteErasureTimeout() {
return (
<OptionsSection title={t("note_erasure_timeout.note_erasure_timeout_title")}>
<FormText>{t("note_erasure_timeout.note_erasure_description")}</FormText>
<FormGroup name="erase-entities-after" label={t("note_erasure_timeout.erase_notes_after")}>
<FormText>{t("note_erasure_timeout.description")}</FormText>
<OptionsRow name="erase-entities-after" label={t("note_erasure_timeout.erase_notes_after")} description={t("note_erasure_timeout.erase_notes_after_description")}>
<TimeSelector
name="erase-entities-after"
optionValueId="eraseEntitiesAfterTimeInSeconds" optionTimeScaleId="eraseEntitiesAfterTimeScale"
/>
</FormGroup>
<FormText>{t("note_erasure_timeout.manual_erasing_description")}</FormText>
</OptionsRow>
<Button
text={t("note_erasure_timeout.erase_deleted_notes_now")}
<OptionsRowWithButton
label={t("note_erasure_timeout.erase_deleted_notes_now")}
description={t("note_erasure_timeout.manual_erasing_description")}
onClick={() => {
server.post("notes/erase-deleted-notes-now").then(() => {
toast.showMessage(t("note_erasure_timeout.deleted_notes_erased"));
@@ -165,17 +151,18 @@ function NoteErasureTimeout() {
function AttachmentErasureTimeout() {
return (
<OptionsSection title={t("attachment_erasure_timeout.attachment_erasure_timeout")}>
<FormText>{t("attachment_erasure_timeout.attachment_auto_deletion_description")}</FormText>
<FormGroup name="erase-unused-attachments-after" label={t("attachment_erasure_timeout.erase_attachments_after")}>
<FormText>{t("attachment_erasure_timeout.description")}</FormText>
<OptionsRow name="erase-unused-attachments-after" label={t("attachment_erasure_timeout.erase_attachments_after")} description={t("attachment_erasure_timeout.erase_attachments_after_description")}>
<TimeSelector
name="erase-unused-attachments-after"
optionValueId="eraseUnusedAttachmentsAfterSeconds" optionTimeScaleId="eraseUnusedAttachmentsAfterTimeScale"
/>
</FormGroup>
<FormText>{t("attachment_erasure_timeout.manual_erasing_description")}</FormText>
</OptionsRow>
<Button
text={t("attachment_erasure_timeout.erase_unused_attachments_now")}
<OptionsRowWithButton
label={t("attachment_erasure_timeout.erase_unused_attachments_now")}
description={t("attachment_erasure_timeout.manual_erasing_description")}
onClick={() => {
server.post("notes/erase-unused-attachments-now").then(() => {
toast.showMessage(t("attachment_erasure_timeout.unused_attachments_erased"));
@@ -186,49 +173,36 @@ function AttachmentErasureTimeout() {
);
}
function RevisionSnapshotInterval() {
function RevisionSettings() {
const [ revisionSnapshotNumberLimit, setRevisionSnapshotNumberLimit ] = useTriliumOption("revisionSnapshotNumberLimit");
return (
<OptionsSection title={t("revisions_snapshot_interval.note_revisions_snapshot_interval_title")}>
<FormText>
<Trans
i18nKey="revisions_snapshot_interval.note_revisions_snapshot_description"
components={{ doc: <a href="https://triliumnext.github.io/Docs/Wiki/note-revisions.html" class="external" /> as React.ReactElement }}
/>
</FormText>
<FormGroup name="revision-snapshot-time-interval" label={t("revisions_snapshot_interval.snapshot_time_interval_label")}>
<OptionsSection title={t("revisions.title")}>
<OptionsRow name="revision-snapshot-time-interval" label={t("revisions_snapshot_interval.snapshot_time_interval_label")} description={t("revisions_snapshot_interval.note_revisions_snapshot_description_short")}>
<TimeSelector
name="revision-snapshot-time-interval"
optionValueId="revisionSnapshotTimeInterval" optionTimeScaleId="revisionSnapshotTimeIntervalTimeScale"
minimumSeconds={10}
/>
</FormGroup>
</OptionsSection>
);
}
</OptionsRow>
function RevisionSnapshotLimit() {
const [ revisionSnapshotNumberLimit, setRevisionSnapshotNumberLimit ] = useTriliumOption("revisionSnapshotNumberLimit");
return (
<OptionsSection title={t("revisions_snapshot_limit.note_revisions_snapshot_limit_title")}>
<FormText>{t("revisions_snapshot_limit.note_revisions_snapshot_limit_description")}</FormText>
<FormGroup name="revision-snapshot-number-limit">
<OptionsRow name="revision-snapshot-number-limit" label={t("revisions_snapshot_limit.snapshot_number_limit_label")} description={t("revisions_snapshot_limit.note_revisions_snapshot_limit_description_short")}>
<FormTextBoxWithUnit
type="number" min={-1}
currentValue={revisionSnapshotNumberLimit}
unit={t("revisions_snapshot_limit.snapshot_number_limit_unit")}
onChange={value => {
onBlur={value => {
const newValue = parseInt(value, 10);
if (!isNaN(newValue) && newValue >= -1) {
setRevisionSnapshotNumberLimit(newValue);
}
}}
/>
</FormGroup>
</OptionsRow>
<Button
text={t("revisions_snapshot_limit.erase_excess_revision_snapshots")}
<OptionsRowWithButton
label={t("revisions_snapshot_limit.erase_excess_revision_snapshots")}
description={t("revisions_snapshot_limit.erase_excess_revision_snapshots_description")}
onClick={async () => {
await server.post("revisions/erase-all-excess-revisions");
toast.showMessage(t("revisions_snapshot_limit.erase_excess_revision_snapshots_prompt"));
@@ -283,34 +257,35 @@ function ShareSettings() {
return (
<OptionsSection title={t("share.title")}>
<FormGroup name="redirectBareDomain" description={t("share.redirect_bare_domain_description")}>
<FormCheckbox
label={t(t("share.redirect_bare_domain"))}
currentValue={redirectBareDomain}
onChange={async value => {
if (value) {
const shareRootNotes = await search.searchForNotes("#shareRoot");
const sharedShareRootNote = shareRootNotes.find((note) => note.isShared());
<OptionsRowWithToggle
name="redirect-bare-domain"
label={t("share.redirect_bare_domain")}
description={t("share.redirect_bare_domain_description")}
currentValue={redirectBareDomain}
onChange={async value => {
if (value) {
const shareRootNotes = await search.searchForNotes("#shareRoot");
const sharedShareRootNote = shareRootNotes.find((note) => note.isShared());
if (sharedShareRootNote) {
toast.showMessage(t("share.share_root_found", { noteTitle: sharedShareRootNote.title }));
} else if (shareRootNotes.length > 0) {
toast.showError(t("share.share_root_not_shared", { noteTitle: shareRootNotes[0].title }));
} else {
toast.showError(t("share.share_root_not_found"));
}
if (sharedShareRootNote) {
toast.showMessage(t("share.share_root_found", { noteTitle: sharedShareRootNote.title }));
} else if (shareRootNotes.length > 0) {
toast.showError(t("share.share_root_not_shared", { noteTitle: shareRootNotes[0].title }));
} else {
toast.showError(t("share.share_root_not_found"));
}
setRedirectBareDomain(value);
}}
/>
</FormGroup>
}
setRedirectBareDomain(value);
}}
/>
<FormGroup name="showLoginInShareTheme" description={t("share.show_login_link_description")}>
<FormCheckbox
label={t("share.show_login_link")}
currentValue={showLogInShareTheme} onChange={setShowLogInShareTheme}
/>
</FormGroup>
<OptionsRowWithToggle
name="show-login-in-share-theme"
label={t("share.show_login_link")}
description={t("share.show_login_link_description")}
currentValue={showLogInShareTheme}
onChange={setShowLogInShareTheme}
/>
</OptionsSection>
);
}
@@ -320,10 +295,12 @@ function NetworkSettings() {
return (
<OptionsSection title={t("network_connections.network_connections_title")}>
<FormCheckbox
<OptionsRowWithToggle
name="check-for-updates"
label={t("network_connections.check_for_updates")}
currentValue={checkForUpdates} onChange={setCheckForUpdates}
description={t("network_connections.check_for_updates_description")}
currentValue={checkForUpdates}
onChange={setCheckForUpdates}
/>
</OptionsSection>
);

View File

@@ -1,18 +1,18 @@
import { useState } from "preact/hooks"
import { t } from "../../../services/i18n"
import server from "../../../services/server"
import toast from "../../../services/toast"
import Alert from "../../react/Alert"
import Button from "../../react/Button"
import FormGroup from "../../react/FormGroup"
import FormTextBox from "../../react/FormTextBox"
import LinkButton from "../../react/LinkButton"
import OptionsSection from "./components/OptionsSection"
import protected_session_holder from "../../../services/protected_session_holder"
import { ChangePasswordResponse } from "@triliumnext/commons"
import dialog from "../../../services/dialog"
import TimeSelector from "./components/TimeSelector"
import FormText from "../../react/FormText"
import { useState } from "preact/hooks";
import { createPortal } from "preact/compat";
import { t } from "../../../services/i18n";
import server from "../../../services/server";
import toast from "../../../services/toast";
import Button from "../../react/Button";
import FormGroup from "../../react/FormGroup";
import FormTextBox from "../../react/FormTextBox";
import OptionsSection from "./components/OptionsSection";
import OptionsRow, { OptionsRowWithButton } from "./components/OptionsRow";
import protected_session_holder from "../../../services/protected_session_holder";
import { ChangePasswordResponse } from "@triliumnext/commons";
import dialog from "../../../services/dialog";
import TimeSelector from "./components/TimeSelector";
import Modal from "../../react/Modal";
export default function PasswordSettings() {
return (
@@ -20,104 +20,142 @@ export default function PasswordSettings() {
<ChangePassword />
<ProtectedSessionTimeout />
</>
)
);
}
function ChangePassword() {
const [ oldPassword, setOldPassword ] = useState("");
const [ newPassword1, setNewPassword1 ] = useState("");
const [ newPassword2, setNewPassword2 ] = useState("");
const [showModal, setShowModal] = useState(false);
return (
<OptionsSection title={t("password.heading")}>
<Alert type="warning">
{t("password.alert_message")}
&nbsp;
<LinkButton
text={t("password.reset_link")}
onClick={async () => {
if (!confirm(t("password.reset_confirmation"))) {
return;
}
<OptionsRowWithButton
label={t("password.change_password")}
description={t("password.change_password_description")}
icon="bx bx-chevron-right"
onClick={() => setShowModal(true)}
/>
await server.post("password/reset?really=yesIReallyWantToResetPasswordAndLoseAccessToMyProtectedNotes");
toast.showError(t("password.reset_success_message"));
}}
/>
</Alert>
<OptionsRowWithButton
label={t("password.reset_password")}
description={t("password.reset_password_description")}
icon="bx bx-chevron-right"
onClick={async () => {
if (!await dialog.confirm(t("password.reset_confirmation"))) {
return;
}
<form onSubmit={async (e) => {
e.preventDefault();
await server.post("password/reset?really=yesIReallyWantToResetPasswordAndLoseAccessToMyProtectedNotes");
toast.showError(t("password.reset_success_message"));
}}
/>
setOldPassword("");
setNewPassword1("");
setNewPassword2("");
if (newPassword1 !== newPassword2) {
toast.showError(t("password.password_mismatch"));
return;
}
const result = await server
.post<ChangePasswordResponse>("password/change", {
current_password: oldPassword,
new_password: newPassword1
})
if (result.success) {
await dialog.info(t("password.password_changed_success"));
// password changed so current protected session is invalid and needs to be cleared
protected_session_holder.resetProtectedSession();
} else if (result.message) {
toast.showError(result.message);
}
}}>
<FormGroup name="old-password" label={t("password.old_password")}>
<FormTextBox
type="password"
currentValue={oldPassword} onChange={setOldPassword}
/>
</FormGroup>
<FormGroup name="new-password1" label={t("password.new_password")}>
<FormTextBox
type="password"
currentValue={newPassword1} onChange={setNewPassword1}
/>
</FormGroup>
<FormGroup name="new-password2" label={t("password.new_password_confirmation")}>
<FormTextBox
type="password"
currentValue={newPassword2} onChange={setNewPassword2}
/>
</FormGroup>
<Button
text={t("password.change_password")}
kind="primary"
/>
</form>
{createPortal(
<ChangePasswordModal show={showModal} onHidden={() => setShowModal(false)} />,
document.body
)}
</OptionsSection>
)
);
}
interface ChangePasswordModalProps {
show: boolean;
onHidden: () => void;
}
function ChangePasswordModal({ show, onHidden }: ChangePasswordModalProps) {
const [oldPassword, setOldPassword] = useState("");
const [newPassword1, setNewPassword1] = useState("");
const [newPassword2, setNewPassword2] = useState("");
const handleSubmit = async () => {
if (newPassword1 !== newPassword2) {
toast.showError(t("password.password_mismatch"));
return;
}
const result = await server.post<ChangePasswordResponse>("password/change", {
current_password: oldPassword,
new_password: newPassword1
});
if (result.success) {
onHidden();
setOldPassword("");
setNewPassword1("");
setNewPassword2("");
await dialog.info(t("password.password_changed_success"));
// password changed so current protected session is invalid and needs to be cleared
protected_session_holder.resetProtectedSession();
} else if (result.message) {
toast.showError(result.message);
}
};
const handleHidden = () => {
setOldPassword("");
setNewPassword1("");
setNewPassword2("");
onHidden();
};
return (
<Modal
show={show}
onHidden={handleHidden}
onSubmit={handleSubmit}
title={t("password.change_password_heading")}
className="change-password-modal"
size="md"
footer={
<>
<Button text={t("password.cancel")} onClick={handleHidden} />
<Button text={t("password.change_password")} kind="primary" />
</>
}
>
<FormGroup name="old-password" label={t("password.old_password")}>
<FormTextBox
type="password"
currentValue={oldPassword}
onChange={setOldPassword}
/>
</FormGroup>
<FormGroup name="new-password1" label={t("password.new_password")}>
<FormTextBox
type="password"
currentValue={newPassword1}
onChange={setNewPassword1}
/>
</FormGroup>
<FormGroup name="new-password2" label={t("password.new_password_confirmation")}>
<FormTextBox
type="password"
currentValue={newPassword2}
onChange={setNewPassword2}
/>
</FormGroup>
</Modal>
);
}
function ProtectedSessionTimeout() {
return (
<OptionsSection title={t("password.protected_session_timeout")}>
<FormText>
{t("password.protected_session_timeout_description")}
&nbsp;
<a class="tn-link" href="https://triliumnext.github.io/Docs/Wiki/protected-notes.html" className="external">{t("password.wiki")}</a> {t("password.for_more_info")}
</FormText>
<FormGroup name="protected-session-timeout" label={t("password.protected_session_timeout_label")}>
<TimeSelector
<OptionsRow
name="protected-session-timeout"
label={t("password.protected_session_timeout_label")}
description={<>{t("password.protected_session_timeout_description")} <a class="tn-link" href="https://triliumnext.github.io/Docs/Wiki/protected-notes.html">{t("password.wiki")}</a> {t("password.for_more_info")}</>}
>
<TimeSelector
name="protected-session-timeout"
optionValueId="protectedSessionTimeout" optionTimeScaleId="protectedSessionTimeoutTimeScale"
optionValueId="protectedSessionTimeout"
optionTimeScaleId="protectedSessionTimeoutTimeScale"
minimumSeconds={60}
/>
</FormGroup>
</OptionsRow>
</OptionsSection>
)
}
);
}

View File

@@ -5,11 +5,10 @@ import { t } from "../../../services/i18n";
import { dynamicRequire, isElectron, restartDesktopApp } from "../../../services/utils";
import Button from "../../react/Button";
import FormText from "../../react/FormText";
import FormToggle from "../../react/FormToggle";
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import NoItems from "../../react/NoItems";
import CheckboxList from "./components/CheckboxList";
import OptionsRow from "./components/OptionsRow";
import OptionsRow, { OptionsRowWithToggle } from "./components/OptionsRow";
import OptionsSection from "./components/OptionsSection";
export default function SpellcheckSettings() {
@@ -32,13 +31,12 @@ function ElectronSpellcheckSettings() {
<OptionsSection title={t("spellcheck.title")}>
<FormText>{t("spellcheck.restart-required")}</FormText>
<OptionsRow name="spell-check-enabled" label={t("spellcheck.enable")}>
<FormToggle
switchOnName="" switchOffName=""
currentValue={spellCheckEnabled}
onChange={setSpellCheckEnabled}
/>
</OptionsRow>
<OptionsRowWithToggle
name="spell-check-enabled"
label={t("spellcheck.enable")}
currentValue={spellCheckEnabled}
onChange={setSpellCheckEnabled}
/>
<OptionsRow name="restart" centered>
<Button

View File

@@ -1,69 +1,47 @@
import { SyncTestResponse } from "@triliumnext/commons";
import { useRef } from "preact/hooks";
import { useEffect, useState } from "preact/hooks";
import { t } from "../../../services/i18n";
import server from "../../../services/server";
import toast from "../../../services/toast";
import { openInAppHelpFromUrl } from "../../../services/utils";
import Button from "../../react/Button";
import FormGroup from "../../react/FormGroup";
import FormText from "../../react/FormText";
import FormTextBox from "../../react/FormTextBox";
import { useTriliumOptions } from "../../react/hooks";
import RawHtml from "../../react/RawHtml";
import OptionsRow from "./components/OptionsRow";
import { useTriliumOption } from "../../react/hooks";
import OptionsRow, { OptionsRowWithButton } from "./components/OptionsRow";
import OptionsSection from "./components/OptionsSection";
import TimeSelector from "./components/TimeSelector";
export default function SyncOptions() {
return (
<>
<SyncConfiguration />
<SyncTest />
</>
);
return <SyncConfiguration />;
}
export function SyncConfiguration() {
const [ options, setOptions ] = useTriliumOptions("syncServerHost", "syncProxy");
const syncServerHost = useRef(options.syncServerHost);
const syncProxy = useRef(options.syncProxy);
const [syncServerHost, setSyncServerHost] = useTriliumOption("syncServerHost");
const [syncProxy, setSyncProxy] = useTriliumOption("syncProxy");
const [localHost, setLocalHost] = useState(syncServerHost);
const [localProxy, setLocalProxy] = useState(syncProxy);
useEffect(() => setLocalHost(syncServerHost), [syncServerHost]);
useEffect(() => setLocalProxy(syncProxy), [syncProxy]);
return (
<OptionsSection title={t("sync_2.config_title")}>
<form onSubmit={(e) => {
setOptions({
syncServerHost: syncServerHost.current,
syncProxy: syncProxy.current
});
e.preventDefault();
}}>
<FormGroup name="sync-server-host" label={t("sync_2.server_address")}>
<FormTextBox
placeholder="https://<host>:<port>"
currentValue={syncServerHost.current} onChange={(newValue) => syncServerHost.current = newValue}
/>
</FormGroup>
<OptionsSection helpUrl="cbkrhQjrkKrh">
<OptionsRow name="sync-server-host" label={t("sync_2.server_address")} description={t("sync_2.server_address_description")} stacked>
<FormTextBox
placeholder="https://<host>:<port>"
currentValue={localHost}
onChange={setLocalHost}
onBlur={setSyncServerHost}
/>
</OptionsRow>
<FormGroup name="sync-proxy" label={t("sync_2.proxy_label")}
description={<>
<strong>{t("sync_2.note")}:</strong> {t("sync_2.note_description")}<br/>
<RawHtml html={t("sync_2.special_value_description")} />
</>}
>
<FormTextBox
placeholder="https://<host>:<port>"
currentValue={syncProxy.current} onChange={(newValue) => syncProxy.current = newValue}
/>
</FormGroup>
<div style={{ display: "flex", justifyContent: "spaceBetween"}}>
<Button text={t("sync_2.save")} kind="primary" />
<Button text={t("sync_2.help")} onClick={() => openInAppHelpFromUrl("cbkrhQjrkKrh")} />
</div>
</form>
<hr/>
<OptionsRow name="sync-proxy" label={t("sync_2.proxy_label")} description={t("sync_2.proxy_description")} stacked>
<FormTextBox
placeholder="https://<host>:<port>"
currentValue={localProxy}
onChange={setLocalProxy}
onBlur={setSyncProxy}
/>
</OptionsRow>
<OptionsRow name="sync-server-timeout" label={t("sync_2.timeout")} description={t("sync_2.timeout_description")}>
<TimeSelector
@@ -73,17 +51,15 @@ export function SyncConfiguration() {
minimumSeconds={1}
/>
</OptionsRow>
</OptionsSection>
);
}
export function SyncTest() {
return (
<OptionsSection title={t("sync_2.test_title")}>
<FormText>{t("sync_2.test_description")}</FormText>
<Button
text={t("sync_2.test_button")}
<OptionsRowWithButton
label={t("sync_2.test_button")}
description={t("sync_2.test_description")}
onClick={async () => {
await Promise.all([
setSyncServerHost(localHost),
setSyncProxy(localProxy)
]);
const result = await server.post<SyncTestResponse>("sync/test");
if (result.success && result.message) {

View File

@@ -0,0 +1,125 @@
/* HeadingStyleSelector */
.heading-style-preview {
display: flex;
align-items: center;
gap: 12px;
}
.heading-preview {
display: inline-flex;
align-items: center;
font-weight: bold;
font-size: 1.1em;
min-width: 40px;
position: relative;
}
.heading-preview-markdown .heading-prefix {
color: var(--muted-text-color);
font-weight: normal;
font-family: var(--font-family-monospace);
}
.heading-preview-underline {
flex-direction: column;
align-items: flex-start;
}
.heading-preview-underline .heading-underline {
width: 100%;
height: 2px;
background-color: currentColor;
margin-top: 2px;
}
.heading-style-label {
flex: 1;
}
/* Toolbar type illustration */
.toolbar-illustration {
width: 140px;
height: 100px;
border: 1px solid var(--main-border-color);
border-radius: 6px;
background: var(--main-background-color);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
.toolbar-bar {
display: flex;
gap: 4px;
padding: 6px 8px;
background: var(--left-pane-background-color);
border-bottom: 1px solid var(--main-border-color);
flex-shrink: 0;
}
.toolbar-icon {
width: 10px;
height: 10px;
background: var(--muted-text-color);
border-radius: 2px;
opacity: 0.5;
&.wide {
width: 20px;
}
}
.document-area {
flex: 1;
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.text-line {
height: 6px;
background: var(--muted-text-color);
border-radius: 2px;
opacity: 0.25;
}
.text-line-with-selection {
display: flex;
gap: 2px;
height: 6px;
.text-segment {
background: var(--muted-text-color);
border-radius: 2px;
opacity: 0.25;
}
.text-selection {
width: 30%;
background: var(--accented-background-color);
border-radius: 2px;
}
}
.floating-toolbar {
position: absolute;
top: 32px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 3px;
padding: 4px 6px;
background: var(--tooltip-background-color);
border: 1px solid var(--main-border-color);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
.toolbar-icon {
width: 8px;
height: 8px;
background: var(--main-background-color);
opacity: 0.8;
}
}
}

View File

@@ -1,27 +1,26 @@
import { normalizeMimeTypeForCKEditor, type OptionNames } from "@triliumnext/commons";
import "./text_notes.css";
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
import { Themes } from "@triliumnext/highlightjs";
import type { CSSProperties } from "preact/compat";
import { useEffect, useMemo, useState } from "preact/hooks";
import type React from "react";
import { Trans } from "react-i18next";
import { isExperimentalFeatureEnabled } from "../../../services/experimental_features";
import { t } from "../../../services/i18n";
import { ensureMimeTypesForHighlighting, loadHighlightingTheme } from "../../../services/syntax_highlight";
import { formatDateTime, toggleBodyClass } from "../../../services/utils";
import Column from "../../react/Column";
import FormCheckbox from "../../react/FormCheckbox";
import FormGroup from "../../react/FormGroup";
import FormRadioGroup from "../../react/FormRadioGroup";
import Dropdown from "../../react/Dropdown";
import { FormListItem } from "../../react/FormList";
import { FormSelectGroup, FormSelectWithGroups } from "../../react/FormSelect";
import FormText from "../../react/FormText";
import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox";
import { useTriliumOption, useTriliumOptionBool, useTriliumOptionJson } from "../../react/hooks";
import KeyboardShortcut from "../../react/KeyboardShortcut";
import { getHtml } from "../../react/RawHtml";
import AutoReadOnlySize from "./components/AutoReadOnlySize";
import CheckboxList from "./components/CheckboxList";
import OptionsRow, { OptionsRowWithToggle } from "./components/OptionsRow";
import OptionsSection from "./components/OptionsSection";
import RadioWithIllustration from "./components/RadioWithIllustration";
const isNewLayout = isExperimentalFeatureEnabled("new-layout");
@@ -30,12 +29,10 @@ export default function TextNoteSettings() {
<>
<FormattingToolbar />
<EditorFeatures />
<HeadingStyle />
<Editor />
<CodeBlockStyle />
<TableOfContent />
<HighlightsList />
<AutoReadOnlySize option="autoReadonlySizeText" label={t("text_auto_read_only_size.label")} />
<DateTimeFormatOptions />
</>
);
}
@@ -46,77 +43,188 @@ function FormattingToolbar() {
return (
<OptionsSection title={t("editing.editor_type.label")}>
<FormRadioGroup
name="editor-type"
currentValue={textNoteEditorType} onChange={setTextNoteEditorType}
values={[
{
value: "ckeditor-balloon",
label: t("editing.editor_type.floating.title"),
inlineDescription: t("editing.editor_type.floating.description")
},
{
value: "ckeditor-classic",
label: t("editing.editor_type.fixed.title"),
inlineDescription: t("editing.editor_type.fixed.description")
}
]}
/>
<OptionsRow name="editor-type" label={t("editing.editor_type.toolbar_style")}>
<RadioWithIllustration
currentValue={textNoteEditorType}
onChange={setTextNoteEditorType}
values={[
{
key: "ckeditor-balloon",
text: t("editing.editor_type.floating.title"),
illustration: <ToolbarIllustration type="floating" />
},
{
key: "ckeditor-classic",
text: t("editing.editor_type.fixed.title"),
illustration: <ToolbarIllustration type="fixed" />
}
]}
/>
</OptionsRow>
<FormCheckbox
<OptionsRowWithToggle
name="multiline-toolbar"
label={t("editing.editor_type.multiline-toolbar")}
currentValue={textNoteEditorMultilineToolbar} onChange={setTextNoteEditorMultilineToolbar}
containerStyle={{ marginInlineStart: "1em" }}
currentValue={textNoteEditorMultilineToolbar}
onChange={setTextNoteEditorMultilineToolbar}
disabled={textNoteEditorType === "ckeditor-balloon"}
/>
</OptionsSection>
);
}
function ToolbarIllustration({ type }: { type: "floating" | "fixed" }) {
return (
<div className="toolbar-illustration">
{type === "fixed" && (
<div className="toolbar-bar">
<ToolbarIcon />
<ToolbarIcon />
<ToolbarIcon />
<ToolbarIcon wide />
<ToolbarIcon />
<ToolbarIcon />
</div>
)}
<div className="document-area">
<div className="text-line" style={{ width: "90%" }} />
<div className="text-line" style={{ width: "75%" }} />
<div className="text-line-with-selection">
<span className="text-segment" style={{ width: "20%" }} />
<span className="text-selection" />
<span className="text-segment" style={{ width: "35%" }} />
</div>
<div className="text-line" style={{ width: "85%" }} />
<div className="text-line" style={{ width: "60%" }} />
</div>
{type === "floating" && (
<div className="floating-toolbar">
<ToolbarIcon />
<ToolbarIcon />
<ToolbarIcon />
<ToolbarIcon />
</div>
)}
</div>
);
}
function ToolbarIcon({ wide }: { wide?: boolean }) {
return <div className={`toolbar-icon${wide ? " wide" : ""}`} />;
}
function EditorFeatures() {
const [emojiCompletionEnabled, setEmojiCompletionEnabled] = useTriliumOptionBool("textNoteEmojiCompletionEnabled");
const [noteCompletionEnabled, setNoteCompletionEnabled] = useTriliumOptionBool("textNoteCompletionEnabled");
const [slashCommandsEnabled, setSlashCommandsEnabled] = useTriliumOptionBool("textNoteSlashCommandsEnabled");
return (
<OptionsSection title={t("editorfeatures.title")}>
<EditorFeature name="emoji-completion-enabled" optionName="textNoteEmojiCompletionEnabled" label={t("editorfeatures.emoji_completion_enabled")} description={t("editorfeatures.emoji_completion_description")} />
<EditorFeature name="note-completion-enabled" optionName="textNoteCompletionEnabled" label={t("editorfeatures.note_completion_enabled")} description={t("editorfeatures.note_completion_description")} />
<EditorFeature name="slash-commands-enabled" optionName="textNoteSlashCommandsEnabled" label={t("editorfeatures.slash_commands_enabled")} description={t("editorfeatures.slash_commands_description")} />
<OptionsRowWithToggle
name="emoji-completion-enabled"
label={t("editorfeatures.emoji_completion_enabled")}
description={t("editorfeatures.emoji_completion_description")}
currentValue={emojiCompletionEnabled}
onChange={setEmojiCompletionEnabled}
/>
<OptionsRowWithToggle
name="note-completion-enabled"
label={t("editorfeatures.note_completion_enabled")}
description={t("editorfeatures.note_completion_description")}
currentValue={noteCompletionEnabled}
onChange={setNoteCompletionEnabled}
/>
<OptionsRowWithToggle
name="slash-commands-enabled"
label={t("editorfeatures.slash_commands_enabled")}
description={t("editorfeatures.slash_commands_description")}
currentValue={slashCommandsEnabled}
onChange={setSlashCommandsEnabled}
/>
</OptionsSection>
);
}
function EditorFeature({ optionName, name, label, description }: { optionName: OptionNames, name: string, label: string, description: string }) {
const [ featureEnabled, setFeatureEnabled ] = useTriliumOptionBool(optionName);
return (
<FormCheckbox
name={name} label={label}
currentValue={featureEnabled} onChange={setFeatureEnabled}
hint={description}
/>
);
}
function HeadingStyle() {
const [ headingStyle, setHeadingStyle ] = useTriliumOption("headingStyle");
function Editor() {
const [headingStyle, setHeadingStyle] = useTriliumOption("headingStyle");
const [autoReadonlySize, setAutoReadonlySize] = useTriliumOption("autoReadonlySizeText");
const [customDateTimeFormat, setCustomDateTimeFormat] = useTriliumOption("customDateTimeFormat");
useEffect(() => {
toggleBodyClass("heading-style-", headingStyle);
}, [ headingStyle ]);
}, [headingStyle]);
return (
<OptionsSection title={t("heading_style.title")}>
<FormRadioGroup
name="heading-style"
currentValue={headingStyle} onChange={setHeadingStyle}
values={[
{ value: "plain", label: t("heading_style.plain") },
{ value: "underline", label: t("heading_style.underline") },
{ value: "markdown", label: t("heading_style.markdown") }
]}
/>
<OptionsSection title={t("text_editor.title")}>
<OptionsRow name="heading-style" label={t("heading_style.title")} description={t("heading_style.description")}>
<HeadingStyleSelector currentValue={headingStyle} onChange={setHeadingStyle} />
</OptionsRow>
<OptionsRow name="auto-readonly-size-text" label={t("text_auto_read_only_size.label")} description={t("text_auto_read_only_size.description")}>
<FormTextBoxWithUnit
type="number" min={0}
unit={t("text_auto_read_only_size.unit")}
currentValue={autoReadonlySize}
onBlur={setAutoReadonlySize}
/>
</OptionsRow>
<OptionsRow
name="custom-date-time-format"
label={t("custom_date_time_format.title")}
description={<>{t("custom_date_time_format.description_short")} {t("custom_date_time_format.preview", { preview: formatDateTime(new Date(), customDateTimeFormat) })}</>}
>
<FormTextBox
placeholder="YYYY-MM-DD HH:mm"
currentValue={customDateTimeFormat || "YYYY-MM-DD HH:mm"} onBlur={setCustomDateTimeFormat}
/>
</OptionsRow>
</OptionsSection>
);
}
const HEADING_STYLES = [
{ value: "plain", labelKey: "heading_style.plain" },
{ value: "underline", labelKey: "heading_style.underline" },
{ value: "markdown", labelKey: "heading_style.markdown" }
] as const;
function HeadingStyleSelector({ currentValue, onChange }: { currentValue: string, onChange: (value: string) => void }) {
const currentStyle = HEADING_STYLES.find(s => s.value === currentValue) ?? HEADING_STYLES[0];
return (
<Dropdown text={t(currentStyle.labelKey)}>
{HEADING_STYLES.map(({ value, labelKey }) => (
<FormListItem
key={value}
onClick={() => onChange(value)}
selected={currentValue === value}
>
<div className="heading-style-preview">
<HeadingPreview style={value} />
<span className="heading-style-label">{t(labelKey)}</span>
</div>
</FormListItem>
))}
</Dropdown>
);
}
function HeadingPreview({ style }: { style: string }) {
const previewClass = `heading-preview heading-preview-${style}`;
return (
<span className={previewClass}>
{style === "markdown" && <span className="heading-prefix">## </span>}
Aa
{style === "underline" && <span className="heading-underline" />}
</span>
);
}
function CodeBlockStyle() {
const themes = useMemo(() => {
const darkThemes: ThemeData[] = [];
@@ -159,26 +267,23 @@ function CodeBlockStyle() {
return (
<OptionsSection title={t("highlighting.title")}>
<div className="row" style={{ marginBottom: "15px" }}>
<FormGroup name="theme" className="col-md-6" label={t("highlighting.color-scheme")} style={{ marginBottom: 0 }}>
<FormSelectWithGroups
values={themes}
keyProperty="val" titleProperty="title"
currentValue={codeBlockTheme} onChange={(newTheme) => {
loadHighlightingTheme(newTheme);
setCodeBlockTheme(newTheme);
}}
/>
</FormGroup>
<OptionsRow name="code-block-theme" label={t("highlighting.color-scheme")}>
<FormSelectWithGroups
values={themes}
keyProperty="val" titleProperty="title"
currentValue={codeBlockTheme} onChange={(newTheme) => {
loadHighlightingTheme(newTheme);
setCodeBlockTheme(newTheme);
}}
/>
</OptionsRow>
<Column md={6} className="side-checkbox">
<FormCheckbox
name="word-wrap"
label={t("code_block.word_wrapping")}
currentValue={codeBlockWordWrap} onChange={setCodeBlockWordWrap}
/>
</Column>
</div>
<OptionsRowWithToggle
name="code-block-word-wrap"
label={t("code_block.word_wrapping")}
currentValue={codeBlockWordWrap}
onChange={setCodeBlockWordWrap}
/>
<CodeBlockPreview theme={codeBlockTheme} wordWrap={codeBlockWordWrap} />
</OptionsSection>
@@ -303,35 +408,3 @@ export function HighlightsListOptions() {
);
}
function DateTimeFormatOptions() {
const [ customDateTimeFormat, setCustomDateTimeFormat ] = useTriliumOption("customDateTimeFormat");
return (
<OptionsSection title={t("custom_date_time_format.title")}>
<FormText>
<Trans
i18nKey="custom_date_time_format.description"
components={{
shortcut: <KeyboardShortcut actionName="insertDateTimeToText" /> as React.ReactElement,
doc: <a href="https://day.js.org/docs/en/display/format" target="_blank" rel="noopener noreferrer" /> as React.ReactElement
}}
/>
</FormText>
<div className="row align-items-center">
<FormGroup name="custom-date-time-format" className="col-md-6" label={t("custom_date_time_format.format_string")}>
<FormTextBox
placeholder="YYYY-MM-DD HH:mm"
currentValue={customDateTimeFormat || "YYYY-MM-DD HH:mm"} onChange={setCustomDateTimeFormat}
/>
</FormGroup>
<FormGroup name="formatted-date" className="col-md-6" label={t("custom_date_time_format.formatted_time")}>
<div>
{formatDateTime(new Date(), customDateTimeFormat)}
</div>
</FormGroup>
</div>
</OptionsSection>
);
}

View File

@@ -5,14 +5,14 @@ import App from "./support/app";
test("Native Title Bar not displayed on web", async ({ page, context }) => {
const app = new App(page, context);
await app.goto({ url: "http://localhost:8082/#root/_hidden/_options/_optionsAppearance" });
await expect(app.currentNoteSplitContent.getByRole("heading", { name: "Theme" })).toBeVisible();
await expect(app.currentNoteSplitContent.getByRole("heading", { name: "User Interface" })).toBeVisible();
await expect(app.currentNoteSplitContent.getByRole("heading", { name: "Native Title Bar (requires" })).toBeHidden();
});
test("Tray settings not displayed on web", async ({ page, context }) => {
const app = new App(page, context);
await app.goto({ url: "http://localhost:8082/#root/_hidden/_options/_optionsOther" });
await expect(app.currentNoteSplitContent.getByRole("heading", { name: "Note Erasure Timeout" })).toBeVisible();
await expect(app.currentNoteSplitContent.getByRole("heading", { name: "Deleted Notes" })).toBeVisible();
await expect(app.currentNoteSplitContent.getByRole("heading", { name: "Tray" })).toBeHidden();
});

View File

@@ -212,6 +212,9 @@
"button": "Login",
"sign_in_with_sso": "Sign in with {{ ssoIssuerName }}"
},
"password": {
"incorrect": "The password you entered is incorrect."
},
"set_password": {
"title": "Set Password",
"heading": "Set password",

View File

@@ -1,23 +1,6 @@
import type { Request, Response } from "express";
import optionService from "../../services/options.js";
import type { OptionMap } from "@triliumnext/commons";
const SYSTEM_SANS_SERIF = [
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"Cantarell",
"Ubuntu",
"Noto Sans",
"Helvetica",
"Arial",
"sans-serif",
"Apple Color Emoji",
"Segoe UI Emoji"
].join(",");
const SYSTEM_MONOSPACE = ["ui-monospace", "SFMono-Regular", "SF Mono", "Consolas", "Source Code Pro", "Ubuntu Mono", "Menlo", "Liberation Mono", "monospace"].join(",");
import { SYSTEM_MONOSPACE_FONT_STACK, SYSTEM_SANS_SERIF_FONT_STACK, type OptionMap } from "@triliumnext/commons";
function getFontCss(req: Request, res: Response) {
res.setHeader("Content-Type", "text/css");
@@ -44,19 +27,19 @@ function getFontFamily({ mainFontFamily, treeFontFamily, detailFontFamily, monos
// System override
if (mainFontFamily === "system") {
mainFontFamily = SYSTEM_SANS_SERIF;
mainFontFamily = SYSTEM_SANS_SERIF_FONT_STACK;
}
if (treeFontFamily === "system") {
treeFontFamily = SYSTEM_SANS_SERIF;
treeFontFamily = SYSTEM_SANS_SERIF_FONT_STACK;
}
if (detailFontFamily === "system") {
detailFontFamily = SYSTEM_SANS_SERIF;
detailFontFamily = SYSTEM_SANS_SERIF_FONT_STACK;
}
if (monospaceFontFamily === "system") {
monospaceFontFamily = SYSTEM_MONOSPACE;
monospaceFontFamily = SYSTEM_MONOSPACE_FONT_STACK;
}
// Apply the font override if not using theme fonts.

View File

@@ -15,6 +15,7 @@ import etapiTokenService from "../../services/etapi_tokens.js";
import type { Request } from "express";
import totp from "../../services/totp";
import recoveryCodeService from "../../services/encryption/recovery_codes";
import { t } from "i18next";
/**
* @swagger
@@ -126,7 +127,7 @@ function loginToProtectedSession(req: Request) {
if (!passwordEncryptionService.verifyPassword(password)) {
return {
success: false,
message: "Given current password doesn't match hash"
message: t("password.incorrect")
};
}

View File

@@ -14,6 +14,7 @@ interface UserTheme {
val: string; // value of the theme, used in the URL
title: string; // title of the theme, displayed in the UI
noteId: string; // ID of the note containing the theme
icon: string; // icon class of the note
}
// options allowed to be updated directly in the Options dialog
@@ -180,16 +181,18 @@ function getUserThemes() {
const ret: UserTheme[] = [];
for (const note of notes) {
const title = note.getTitleOrProtected();
let value = note.getOwnedLabelValue("appTheme");
if (!value) {
value = note.title.toLowerCase().replace(/[^a-z0-9]/gi, "-");
value = title.toLowerCase().replace(/[^a-z0-9]/gi, "-");
}
ret.push({
val: value,
title: note.title,
noteId: note.noteId
title,
noteId: note.noteId,
icon: note.getIcon()
});
}

View File

@@ -4,6 +4,7 @@ import myScryptService from "./my_scrypt.js";
import { randomSecureToken, toBase64 } from "../utils.js";
import passwordEncryptionService from "./password_encryption.js";
import { ChangePasswordResponse } from "@triliumnext/commons";
import { t } from "i18next";
function isPasswordSet() {
return !!sql.getValue("SELECT value FROM options WHERE name = 'passwordVerificationHash'");
@@ -17,7 +18,7 @@ function changePassword(currentPassword: string, newPassword: string): ChangePas
if (!passwordEncryptionService.verifyPassword(currentPassword)) {
return {
success: false,
message: "Given current password doesn't match hash"
message: t("password.incorrect")
};
}

View File

@@ -14,6 +14,41 @@ type KeyboardShortcutsOptions<T extends KeyboardActionNames> = {
export type FontFamily = "theme" | "serif" | "sans-serif" | "monospace" | string;
/**
* System sans-serif font stack for cross-platform compatibility.
* Used when the user selects "System default" for non-monospace fonts.
*/
export const SYSTEM_SANS_SERIF_FONT_STACK = [
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"Cantarell",
"Ubuntu",
"Noto Sans",
"Helvetica",
"Arial",
"sans-serif",
"Apple Color Emoji",
"Segoe UI Emoji"
].join(",");
/**
* System monospace font stack for cross-platform compatibility.
* Used when the user selects "System default" for monospace fonts.
*/
export const SYSTEM_MONOSPACE_FONT_STACK = [
"ui-monospace",
"SFMono-Regular",
"SF Mono",
"Consolas",
"Source Code Pro",
"Ubuntu Mono",
"Menlo",
"Liberation Mono",
"monospace"
].join(",");
export interface OptionDefinitions extends KeyboardShortcutsOptions<KeyboardActionNames> {
openNoteContexts: string;
lastDailyBackupDate: string;