diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl index 2a3330d890..41a8268cb3 100644 --- a/templates/repo/diff/box.tmpl +++ b/templates/repo/diff/box.tmpl @@ -10,7 +10,7 @@ diff --git a/web_src/js/components/DiffFileTree.vue b/web_src/js/components/DiffFileTree.vue index e2934b967e..b662808566 100644 --- a/web_src/js/components/DiffFileTree.vue +++ b/web_src/js/components/DiffFileTree.vue @@ -4,6 +4,7 @@ import {toggleElem} from '../utils/dom.ts'; import {diffTreeStore} from '../modules/diff-file.ts'; import {setFileFolding} from '../features/file-fold.ts'; import {onMounted, onUnmounted} from 'vue'; +import {localUserSettings} from '../modules/user-settings.ts'; const LOCAL_STORAGE_KEY = 'diff_file_tree_visible'; @@ -11,7 +12,7 @@ const store = diffTreeStore(); onMounted(() => { // Default to true if unset - store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false'; + store.fileTreeIsVisible = localUserSettings.getBoolean(LOCAL_STORAGE_KEY, true); document.querySelector('.diff-toggle-file-tree-button')!.addEventListener('click', toggleVisibility); hashChangeListener(); @@ -43,7 +44,7 @@ function toggleVisibility() { function updateVisibility(visible: boolean) { store.fileTreeIsVisible = visible; - localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible.toString()); + localUserSettings.setBoolean(LOCAL_STORAGE_KEY, store.fileTreeIsVisible); updateState(store.fileTreeIsVisible); } diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index 357a2ba10e..e4c15d2001 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -8,6 +8,7 @@ import {renderAnsi} from '../render/ansi.ts'; import {POST, DELETE} from '../modules/fetch.ts'; import type {IntervalId} from '../types.ts'; import {toggleFullScreen} from '../utils.ts'; +import {localUserSettings} from '../modules/user-settings.ts'; // see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts" type RunStatus = 'unknown' | 'waiting' | 'running' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked'; @@ -71,15 +72,6 @@ type LocaleStorageOptions = { expandRunning: boolean; }; -function getLocaleStorageOptions(): LocaleStorageOptions { - try { - const optsJson = localStorage.getItem('actions-view-options'); - if (optsJson) return JSON.parse(optsJson); - } catch {} - // if no options in localStorage, or failed to parse, return default options - return {autoScroll: true, expandRunning: false}; -} - export default defineComponent({ name: 'RepoActionView', components: { @@ -106,7 +98,8 @@ export default defineComponent({ }, data() { - const {autoScroll, expandRunning} = getLocaleStorageOptions(); + const defaultViewOptions: LocaleStorageOptions = {autoScroll: true, expandRunning: false}; + const {autoScroll, expandRunning} = localUserSettings.getJsonObject('actions-view-options', defaultViewOptions); return { // internal state loadingAbortController: null as AbortController | null, @@ -224,7 +217,7 @@ export default defineComponent({ methods: { saveLocaleStorageOptions() { const opts: LocaleStorageOptions = {autoScroll: this.optionAlwaysAutoScroll, expandRunning: this.optionAlwaysExpandRunning}; - localStorage.setItem('actions-view-options', JSON.stringify(opts)); + localUserSettings.setJsonObject('actions-view-options', opts); }, // get the job step logs container ('.job-step-logs') diff --git a/web_src/js/features/citation.ts b/web_src/js/features/citation.ts index d5ecb52e72..c81c1af1fa 100644 --- a/web_src/js/features/citation.ts +++ b/web_src/js/features/citation.ts @@ -1,5 +1,6 @@ import {getCurrentLocale} from '../utils.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; +import {localUserSettings} from '../modules/user-settings.ts'; const {pageData} = window.config; @@ -38,7 +39,7 @@ export async function initCitationFileCopyContent() { if ((!citationCopyApa && !citationCopyBibtex) || !inputContent) return; const updateUi = () => { - const isBibtex = (localStorage.getItem('citation-copy-format') || defaultCitationFormat) === 'bibtex'; + const isBibtex = localUserSettings.getString('citation-copy-format', defaultCitationFormat) === 'bibtex'; const copyContent = (isBibtex ? citationCopyBibtex : citationCopyApa).getAttribute('data-text')!; inputContent.value = copyContent; citationCopyBibtex.classList.toggle('primary', isBibtex); @@ -55,12 +56,12 @@ export async function initCitationFileCopyContent() { updateUi(); citationCopyApa.addEventListener('click', () => { - localStorage.setItem('citation-copy-format', 'apa'); + localUserSettings.setString('citation-copy-format', 'apa'); updateUi(); }); citationCopyBibtex.addEventListener('click', () => { - localStorage.setItem('citation-copy-format', 'bibtex'); + localUserSettings.setString('citation-copy-format', 'bibtex'); updateUi(); }); diff --git a/web_src/js/features/comp/ComboMarkdownEditor.ts b/web_src/js/features/comp/ComboMarkdownEditor.ts index 46096ecac1..fdc8a1d601 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.ts +++ b/web_src/js/features/comp/ComboMarkdownEditor.ts @@ -24,6 +24,7 @@ import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts'; import {createTippy} from '../../modules/tippy.ts'; import {fomanticQuery} from '../../modules/fomantic/base.ts'; import type EasyMDE from 'easymde'; +import {localUserSettings} from '../../modules/user-settings.ts'; /** * validate if the given textarea is non-empty. @@ -81,6 +82,8 @@ export class ComboMarkdownEditor { textareaMarkdownToolbar: HTMLElement; textareaAutosize: any; + buttonMonospace: HTMLButtonElement; + dropzone: HTMLElement | null; attachedDropzoneInst: any; @@ -140,19 +143,13 @@ export class ComboMarkdownEditor { if (el.nodeName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button'); } - const monospaceButton = this.container.querySelector('.markdown-switch-monospace')!; - const monospaceEnabled = localStorage?.getItem('markdown-editor-monospace') === 'true'; - const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text')!; - monospaceButton.setAttribute('data-tooltip-content', monospaceText); - monospaceButton.setAttribute('aria-checked', String(monospaceEnabled)); - monospaceButton.addEventListener('click', (e) => { + this.buttonMonospace = this.container.querySelector('.markdown-switch-monospace')!; + this.applyMonospace(); + this.buttonMonospace.addEventListener('click', (e) => { e.preventDefault(); - const enabled = localStorage?.getItem('markdown-editor-monospace') !== 'true'; - localStorage.setItem('markdown-editor-monospace', String(enabled)); - this.textarea.classList.toggle('tw-font-mono', enabled); - const text = monospaceButton.getAttribute(enabled ? 'data-disable-text' : 'data-enable-text')!; - monospaceButton.setAttribute('data-tooltip-content', text); - monospaceButton.setAttribute('aria-checked', String(enabled)); + const enabled = !localUserSettings.getBoolean('markdown-editor-monospace'); + localUserSettings.setBoolean('markdown-editor-monospace', enabled); + applyMonospaceToAllEditors(); }); if (this.supportEasyMDE) { @@ -403,10 +400,27 @@ export class ComboMarkdownEditor { } get userPreferredEditor(): string { - return window.localStorage.getItem(`markdown-editor-${this.previewMode ?? 'default'}`) || ''; + return localUserSettings.getString(`markdown-editor-${this.previewMode ?? 'default'}`); } + set userPreferredEditor(s: string) { - window.localStorage.setItem(`markdown-editor-${this.previewMode ?? 'default'}`, s); + localUserSettings.setString(`markdown-editor-${this.previewMode ?? 'default'}`, s); + } + + applyMonospace() { + const enabled = localUserSettings.getBoolean('markdown-editor-monospace'); + const text = this.buttonMonospace.getAttribute(enabled ? 'data-disable-text' : 'data-enable-text')!; + this.textarea.classList.toggle('tw-font-mono', enabled); + this.buttonMonospace.setAttribute('data-tooltip-content', text); + this.buttonMonospace.setAttribute('aria-checked', String(enabled)); + } +} + +function applyMonospaceToAllEditors() { + const editors = document.querySelectorAll('.combo-markdown-editor'); + for (const editorContainer of editors) { + const editor = getComboMarkdownEditor(editorContainer); + if (editor) editor.applyMonospace(); } } diff --git a/web_src/js/features/repo-common.ts b/web_src/js/features/repo-common.ts index ac753805d3..e8fb257c18 100644 --- a/web_src/js/features/repo-common.ts +++ b/web_src/js/features/repo-common.ts @@ -6,6 +6,7 @@ import RepoActivityTopAuthors from '../components/RepoActivityTopAuthors.vue'; import {createApp} from 'vue'; import {toOriginUrl} from '../utils/url.ts'; import {createTippy} from '../modules/tippy.ts'; +import {localUserSettings} from '../modules/user-settings.ts'; async function onDownloadArchive(e: Event) { e.preventDefault(); @@ -57,7 +58,7 @@ function initCloneSchemeUrlSelection(parent: Element) { const tabSsh = parent.querySelector('.repo-clone-ssh'); const tabTea = parent.querySelector('.repo-clone-tea'); const updateClonePanelUi = function() { - let scheme = localStorage.getItem('repo-clone-protocol')!; + let scheme = localUserSettings.getString('repo-clone-protocol'); if (!['https', 'ssh', 'tea'].includes(scheme)) { scheme = 'https'; } @@ -114,15 +115,15 @@ function initCloneSchemeUrlSelection(parent: Element) { updateClonePanelUi(); // tabSsh or tabHttps might not both exist, eg: guest view, or one is disabled by the server tabHttps?.addEventListener('click', () => { - localStorage.setItem('repo-clone-protocol', 'https'); + localUserSettings.setString('repo-clone-protocol', 'https'); updateClonePanelUi(); }); tabSsh?.addEventListener('click', () => { - localStorage.setItem('repo-clone-protocol', 'ssh'); + localUserSettings.setString('repo-clone-protocol', 'ssh'); updateClonePanelUi(); }); tabTea?.addEventListener('click', () => { - localStorage.setItem('repo-clone-protocol', 'tea'); + localUserSettings.setString('repo-clone-protocol', 'tea'); updateClonePanelUi(); }); elCloneUrlInput.addEventListener('focus', () => { diff --git a/web_src/js/globals.d.ts b/web_src/js/globals.d.ts index 9770f639a7..5528c890dc 100644 --- a/web_src/js/globals.d.ts +++ b/web_src/js/globals.d.ts @@ -77,6 +77,7 @@ interface Window { push: (e: ErrorEvent & PromiseRejectionEvent) => void | number, }, codeEditors: any[], // export editor for customization + localUserSettings: typeof import('./modules/user-settings.ts').localUserSettings, // various captcha plugins grecaptcha: any, diff --git a/web_src/js/index.ts b/web_src/js/index.ts index 153b8049c9..2de29f52b9 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -6,6 +6,7 @@ import './bootstrap.ts'; import './globals.ts'; import './webcomponents/index.ts'; +import './modules/user-settings.ts'; // templates also need to use localUserSettings in inline scripts import {onDomReady} from './utils/dom.ts'; // TODO: There is a bug in htmx, it incorrectly checks "readyState === 'complete'" when the DOM tree is ready and won't trigger DOMContentLoaded diff --git a/web_src/js/modules/user-settings.ts b/web_src/js/modules/user-settings.ts new file mode 100644 index 0000000000..dbf69f384d --- /dev/null +++ b/web_src/js/modules/user-settings.ts @@ -0,0 +1,72 @@ +// Some people deploy Gitea under a subpath, so it needs prefix to avoid local storage key conflicts. +// And these keys are for user settings only, it also needs a specific prefix, +// in case in the future there are other uses of local storage, and/or we need to clear some keys when the quota is exceeded. +const itemKeyPrefix = 'gitea:setting:'; + +function handleLocalStorageError(e: any) { + // in the future, maybe we need to handle quota exceeded errors differently + console.error('Error using local storage for user settings', e); +} + +function getLocalStorageUserSetting(settingKey: string): string | null { + const legacyKey = settingKey; + const itemKey = `${itemKeyPrefix}${settingKey}`; + try { + const legacyValue = localStorage?.getItem(legacyKey) ?? null; + const value = localStorage?.getItem(itemKey) ?? null; // avoid undefined + if (value !== null && legacyValue !== null) { + // if both values exist, remove the legacy one + localStorage?.removeItem(legacyKey); + } else if (value === null && legacyValue !== null) { + // migrate legacy value to new key + localStorage?.removeItem(legacyKey); + localStorage?.setItem(itemKey, legacyValue); + return legacyValue; + } + return value; + } catch (e) { + handleLocalStorageError(e); + } + return null; +} + +function setLocalStorageUserSetting(settingKey: string, value: string) { + const legacyKey = settingKey; + const itemKey = `${itemKeyPrefix}${settingKey}`; + try { + localStorage?.removeItem(legacyKey); + localStorage?.setItem(itemKey, value); + } catch (e) { + handleLocalStorageError(e); + } +} + +export const localUserSettings = { + getString: (key: string, def: string = ''): string => { + return getLocalStorageUserSetting(key) ?? def; + }, + setString: (key: string, value: string) => { + setLocalStorageUserSetting(key, value); + }, + getBoolean: (key: string, def: boolean = false): boolean => { + return localUserSettings.getString(key, String(def)) === 'true'; + }, + setBoolean: (key: string, value: boolean) => { + localUserSettings.setString(key, String(value)); + }, + getJsonObject: >(key: string, def: T): T => { + const value = getLocalStorageUserSetting(key); + try { + const decoded = value !== null ? JSON.parse(value) : def; + return decoded ?? def; + } catch (e) { + console.error(`Unable to parse JSON value for local user settings ${key}=${value}`, e); + } + return def; + }, + setJsonObject: >(key: string, value: T) => { + localUserSettings.setString(key, JSON.stringify(value)); + }, +}; + +window.localUserSettings = localUserSettings;