diff options
Diffstat (limited to '')
76 files changed, 7882 insertions, 0 deletions
diff --git a/web_src/js/features/admin/common.js b/web_src/js/features/admin/common.js new file mode 100644 index 0000000..1a5bd6e --- /dev/null +++ b/web_src/js/features/admin/common.js @@ -0,0 +1,258 @@ +import $ from 'jquery'; +import {checkAppUrl} from '../common-global.js'; +import {hideElem, showElem, toggleElem} from '../../utils/dom.js'; +import {POST} from '../../modules/fetch.js'; + +const {appSubUrl} = window.config; + +function onSecurityProtocolChange() { + if (Number(document.getElementById('security_protocol')?.value) > 0) { + showElem('.has-tls'); + } else { + hideElem('.has-tls'); + } +} + +export function initAdminCommon() { + if (!document.querySelector('.page-content.admin')) return; + + // check whether appUrl(ROOT_URL) is correct, if not, show an error message + checkAppUrl(); + + // New user + if ($('.admin.new.user').length > 0 || $('.admin.edit.user').length > 0) { + document.getElementById('login_type')?.addEventListener('change', function () { + if (this.value?.substring(0, 1) === '0') { + document.getElementById('user_name')?.removeAttribute('disabled'); + document.getElementById('login_name')?.removeAttribute('required'); + hideElem('.non-local'); + showElem('.local'); + document.getElementById('user_name')?.focus(); + + if (this.getAttribute('data-password') === 'required') { + document.getElementById('password')?.setAttribute('required', 'required'); + } + } else { + if (document.querySelector('.admin.edit.user')) { + document.getElementById('user_name')?.setAttribute('disabled', 'disabled'); + } + document.getElementById('login_name')?.setAttribute('required', 'required'); + showElem('.non-local'); + hideElem('.local'); + document.getElementById('login_name')?.focus(); + + document.getElementById('password')?.removeAttribute('required'); + } + }); + } + + function onUsePagedSearchChange() { + const searchPageSizeElements = document.querySelectorAll('.search-page-size'); + if (document.getElementById('use_paged_search').checked) { + showElem('.search-page-size'); + for (const el of searchPageSizeElements) { + el.querySelector('input')?.setAttribute('required', 'required'); + } + } else { + hideElem('.search-page-size'); + for (const el of searchPageSizeElements) { + el.querySelector('input')?.removeAttribute('required'); + } + } + } + + function onOAuth2Change(applyDefaultValues) { + hideElem('.open_id_connect_auto_discovery_url, .oauth2_use_custom_url'); + for (const input of document.querySelectorAll('.open_id_connect_auto_discovery_url input[required]')) { + input.removeAttribute('required'); + } + + const provider = document.getElementById('oauth2_provider')?.value; + switch (provider) { + case 'openidConnect': + for (const input of document.querySelectorAll('.open_id_connect_auto_discovery_url input')) { + input.setAttribute('required', 'required'); + } + showElem('.open_id_connect_auto_discovery_url'); + break; + default: { + const customURLSettings = document.getElementById(`${provider}_customURLSettings`); + if (!customURLSettings) break; + const customURLRequired = (customURLSettings.getAttribute('data-required') === 'true'); + document.getElementById('oauth2_use_custom_url').checked = customURLRequired; + if (customURLRequired || customURLSettings.getAttribute('data-available') === 'true') { + showElem('.oauth2_use_custom_url'); + } + } + } + onOAuth2UseCustomURLChange(applyDefaultValues); + } + + function onOAuth2UseCustomURLChange(applyDefaultValues) { + const provider = document.getElementById('oauth2_provider')?.value; + hideElem('.oauth2_use_custom_url_field'); + for (const input of document.querySelectorAll('.oauth2_use_custom_url_field input[required]')) { + input.removeAttribute('required'); + } + + if (document.getElementById('oauth2_use_custom_url')?.checked) { + for (const custom of ['token_url', 'auth_url', 'profile_url', 'email_url', 'tenant']) { + const customInput = document.getElementById(`${provider}_${custom}`); + if (!customInput) continue; + if (applyDefaultValues) { + document.getElementById(`oauth2_${custom}`).value = customInput.value; + } + if (customInput.getAttribute('data-available') === 'true') { + for (const input of document.querySelectorAll(`.oauth2_${custom} input`)) { + input.setAttribute('required', 'required'); + } + showElem(`.oauth2_${custom}`); + } + } + } + } + + function onEnableLdapGroupsChange() { + toggleElem(document.getElementById('ldap-group-options'), $('.js-ldap-group-toggle')[0].checked); + } + + // New authentication + if (document.querySelector('.admin.new.authentication')) { + document.getElementById('auth_type')?.addEventListener('change', function () { + hideElem('.ldap, .dldap, .smtp, .pam, .oauth2, .has-tls, .search-page-size, .sspi'); + + for (const input of document.querySelectorAll('.ldap input[required], .binddnrequired input[required], .dldap input[required], .smtp input[required], .pam input[required], .oauth2 input[required], .has-tls input[required], .sspi input[required]')) { + input.removeAttribute('required'); + } + + document.querySelector('.binddnrequired')?.classList.remove('required'); + + const authType = this.value; + switch (authType) { + case '2': // LDAP + showElem('.ldap'); + for (const input of document.querySelectorAll('.binddnrequired input, .ldap div.required:not(.dldap) input')) { + input.setAttribute('required', 'required'); + } + document.querySelector('.binddnrequired')?.classList.add('required'); + break; + case '3': // SMTP + showElem('.smtp'); + showElem('.has-tls'); + for (const input of document.querySelectorAll('.smtp div.required input, .has-tls')) { + input.setAttribute('required', 'required'); + } + break; + case '4': // PAM + showElem('.pam'); + for (const input of document.querySelectorAll('.pam input')) { + input.setAttribute('required', 'required'); + } + break; + case '5': // LDAP + showElem('.dldap'); + for (const input of document.querySelectorAll('.dldap div.required:not(.ldap) input')) { + input.setAttribute('required', 'required'); + } + break; + case '6': // OAuth2 + showElem('.oauth2'); + for (const input of document.querySelectorAll('.oauth2 div.required:not(.oauth2_use_custom_url,.oauth2_use_custom_url_field,.open_id_connect_auto_discovery_url) input')) { + input.setAttribute('required', 'required'); + } + onOAuth2Change(true); + break; + case '7': // SSPI + showElem('.sspi'); + for (const input of document.querySelectorAll('.sspi div.required input')) { + input.setAttribute('required', 'required'); + } + break; + } + if (authType === '2' || authType === '5') { + onSecurityProtocolChange(); + onEnableLdapGroupsChange(); + } + if (authType === '2') { + onUsePagedSearchChange(); + } + }); + $('#auth_type').trigger('change'); + document.getElementById('security_protocol')?.addEventListener('change', onSecurityProtocolChange); + document.getElementById('use_paged_search')?.addEventListener('change', onUsePagedSearchChange); + document.getElementById('oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true)); + document.getElementById('oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(true)); + $('.js-ldap-group-toggle').on('change', onEnableLdapGroupsChange); + } + // Edit authentication + if (document.querySelector('.admin.edit.authentication')) { + const authType = document.getElementById('auth_type')?.value; + if (authType === '2' || authType === '5') { + document.getElementById('security_protocol')?.addEventListener('change', onSecurityProtocolChange); + $('.js-ldap-group-toggle').on('change', onEnableLdapGroupsChange); + onEnableLdapGroupsChange(); + if (authType === '2') { + document.getElementById('use_paged_search')?.addEventListener('change', onUsePagedSearchChange); + } + } else if (authType === '6') { + document.getElementById('oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true)); + document.getElementById('oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(false)); + onOAuth2Change(false); + } + } + + if (document.querySelector('.admin.authentication')) { + $('#auth_name').on('input', function () { + // appSubUrl is either empty or is a path that starts with `/` and doesn't have a trailing slash. + document.getElementById('oauth2-callback-url').textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${encodeURIComponent(this.value)}/callback`; + }).trigger('input'); + } + + // Notice + if (document.querySelector('.admin.notice')) { + const detailModal = document.getElementById('detail-modal'); + + // Attach view detail modals + $('.view-detail').on('click', function () { + const description = this.closest('tr').querySelector('.notice-description').textContent; + detailModal.querySelector('.content pre').textContent = description; + $(detailModal).modal('show'); + return false; + }); + + // Select actions + const checkboxes = document.querySelectorAll('.select.table .ui.checkbox input'); + + $('.select.action').on('click', function () { + switch ($(this).data('action')) { + case 'select-all': + for (const checkbox of checkboxes) { + checkbox.checked = true; + } + break; + case 'deselect-all': + for (const checkbox of checkboxes) { + checkbox.checked = false; + } + break; + case 'inverse': + for (const checkbox of checkboxes) { + checkbox.checked = !checkbox.checked; + } + break; + } + }); + document.getElementById('delete-selection')?.addEventListener('click', async function (e) { + e.preventDefault(); + this.classList.add('is-loading', 'disabled'); + const data = new FormData(); + for (const checkbox of checkboxes) { + if (checkbox.checked) { + data.append('ids[]', checkbox.closest('.ui.checkbox').getAttribute('data-id')); + } + } + await POST(this.getAttribute('data-link'), {data}); + window.location.href = this.getAttribute('data-redirect'); + }); + } +} diff --git a/web_src/js/features/admin/config.js b/web_src/js/features/admin/config.js new file mode 100644 index 0000000..c382342 --- /dev/null +++ b/web_src/js/features/admin/config.js @@ -0,0 +1,24 @@ +import {showTemporaryTooltip} from '../../modules/tippy.js'; +import {POST} from '../../modules/fetch.js'; + +const {appSubUrl} = window.config; + +export function initAdminConfigs() { + const elAdminConfig = document.querySelector('.page-content.admin.config'); + if (!elAdminConfig) return; + + for (const el of elAdminConfig.querySelectorAll('input[type="checkbox"][data-config-dyn-key]')) { + el.addEventListener('change', async () => { + try { + const resp = await POST(`${appSubUrl}/admin/config`, { + data: new URLSearchParams({key: el.getAttribute('data-config-dyn-key'), value: el.checked}), + }); + const json = await resp.json(); + if (json.errorMessage) throw new Error(json.errorMessage); + } catch (ex) { + showTemporaryTooltip(el, ex.toString()); + el.checked = !el.checked; + } + }); + } +} diff --git a/web_src/js/features/admin/emails.js b/web_src/js/features/admin/emails.js new file mode 100644 index 0000000..46fafa7 --- /dev/null +++ b/web_src/js/features/admin/emails.js @@ -0,0 +1,14 @@ +import $ from 'jquery'; + +export function initAdminEmails() { + function linkEmailAction(e) { + const $this = $(this); + $('#form-uid').val($this.data('uid')); + $('#form-email').val($this.data('email')); + $('#form-primary').val($this.data('primary')); + $('#form-activate').val($this.data('activate')); + $('#change-email-modal').modal('show'); + e.preventDefault(); + } + $('.link-email-action').on('click', linkEmailAction); +} diff --git a/web_src/js/features/admin/users.js b/web_src/js/features/admin/users.js new file mode 100644 index 0000000..7cac603 --- /dev/null +++ b/web_src/js/features/admin/users.js @@ -0,0 +1,39 @@ +export function initAdminUserListSearchForm() { + const searchForm = window.config.pageData.adminUserListSearchForm; + if (!searchForm) return; + + const form = document.querySelector('#user-list-search-form'); + if (!form) return; + + for (const button of form.querySelectorAll(`button[name=sort][value="${searchForm.SortType}"]`)) { + button.classList.add('active'); + } + + if (searchForm.StatusFilterMap) { + for (const [k, v] of Object.entries(searchForm.StatusFilterMap)) { + if (!v) continue; + for (const input of form.querySelectorAll(`input[name="status_filter[${k}]"][value="${v}"]`)) { + input.checked = true; + } + } + } + + for (const radio of form.querySelectorAll('input[type=radio]')) { + radio.addEventListener('click', () => { + form.submit(); + }); + } + + const resetButtons = form.querySelectorAll('.j-reset-status-filter'); + for (const button of resetButtons) { + button.addEventListener('click', (e) => { + e.preventDefault(); + for (const input of form.querySelectorAll('input[type=radio]')) { + if (input.name.startsWith('status_filter[')) { + input.checked = false; + } + } + form.submit(); + }); + } +} diff --git a/web_src/js/features/autofocus-end.js b/web_src/js/features/autofocus-end.js new file mode 100644 index 0000000..da71ce9 --- /dev/null +++ b/web_src/js/features/autofocus-end.js @@ -0,0 +1,6 @@ +export function initAutoFocusEnd() { + for (const el of document.querySelectorAll('.js-autofocus-end')) { + el.focus(); // expects only one such element on one page. If there are many, then the last one gets the focus. + el.setSelectionRange(el.value.length, el.value.length); + } +} diff --git a/web_src/js/features/captcha.js b/web_src/js/features/captcha.js new file mode 100644 index 0000000..c803a50 --- /dev/null +++ b/web_src/js/features/captcha.js @@ -0,0 +1,51 @@ +import {isDarkTheme} from '../utils.js'; + +export async function initCaptcha() { + const captchaEl = document.querySelector('#captcha'); + if (!captchaEl) return; + + const siteKey = captchaEl.getAttribute('data-sitekey'); + const isDark = isDarkTheme(); + + const params = { + sitekey: siteKey, + theme: isDark ? 'dark' : 'light', + }; + + switch (captchaEl.getAttribute('data-captcha-type')) { + case 'g-recaptcha': { + if (window.grecaptcha) { + window.grecaptcha.ready(() => { + window.grecaptcha.render(captchaEl, params); + }); + } + break; + } + case 'cf-turnstile': { + if (window.turnstile) { + window.turnstile.render(captchaEl, params); + } + break; + } + case 'h-captcha': { + if (window.hcaptcha) { + window.hcaptcha.render(captchaEl, params); + } + break; + } + case 'm-captcha': { + const {default: mCaptcha} = await import(/* webpackChunkName: "mcaptcha-vanilla-glue" */'@mcaptcha/vanilla-glue'); + mCaptcha.INPUT_NAME = 'm-captcha-response'; + const instanceURL = captchaEl.getAttribute('data-instance-url'); + + mCaptcha.default({ + siteKey: { + instanceUrl: new URL(instanceURL), + key: siteKey, + }, + }); + break; + } + default: + } +} diff --git a/web_src/js/features/citation.js b/web_src/js/features/citation.js new file mode 100644 index 0000000..7e26bff --- /dev/null +++ b/web_src/js/features/citation.js @@ -0,0 +1,50 @@ +import $ from 'jquery'; +import {getCurrentLocale} from '../utils.js'; + +const {pageData} = window.config; + +async function initInputCitationValue(inputContent) { + const [{Cite, plugins}] = await Promise.all([ + import(/* webpackChunkName: "citation-js-core" */'@citation-js/core'), + import(/* webpackChunkName: "citation-js-formats" */'@citation-js/plugin-software-formats'), + import(/* webpackChunkName: "citation-js-bibtex" */'@citation-js/plugin-bibtex'), + ]); + const {citationFileContent} = pageData; + const config = plugins.config.get('@bibtex'); + config.constants.fieldTypes.doi = ['field', 'literal']; + config.constants.fieldTypes.version = ['field', 'literal']; + const citationFormatter = new Cite(citationFileContent); + const lang = getCurrentLocale() || 'en-US'; + const bibtexOutput = citationFormatter.format('bibtex', {lang}); + inputContent.value = bibtexOutput; +} + +export async function initCitationFileCopyContent() { + if (!pageData.citationFileContent) return; + + const inputContent = document.getElementById('citation-copy-content'); + + if (!inputContent) return; + + document.getElementById('cite-repo-button')?.addEventListener('click', async (e) => { + const dropdownBtn = e.target.closest('.ui.dropdown.button'); + dropdownBtn.classList.add('is-loading'); + + try { + try { + await initInputCitationValue(inputContent); + } catch (e) { + console.error(`initCitationFileCopyContent error: ${e}`, e); + return; + } + + inputContent.addEventListener('click', () => { + inputContent.select(); + }); + } finally { + dropdownBtn.classList.remove('is-loading'); + } + + $('#cite-repo-modal').modal('show'); + }); +} diff --git a/web_src/js/features/clipboard.js b/web_src/js/features/clipboard.js new file mode 100644 index 0000000..daf7e2a --- /dev/null +++ b/web_src/js/features/clipboard.js @@ -0,0 +1,32 @@ +import {showTemporaryTooltip} from '../modules/tippy.js'; +import {toAbsoluteUrl} from '../utils.js'; +import {clippie} from 'clippie'; + +const {copy_success, copy_error} = window.config.i18n; + +// Enable clipboard copy from HTML attributes. These properties are supported: +// - data-clipboard-text: Direct text to copy +// - data-clipboard-target: Holds a selector for a <input> or <textarea> whose content is copied +// - data-clipboard-text-type: When set to 'url' will convert relative to absolute urls +export function initGlobalCopyToClipboardListener() { + document.addEventListener('click', async (e) => { + const target = e.target.closest('[data-clipboard-text], [data-clipboard-target]'); + if (!target) return; + + e.preventDefault(); + + let text = target.getAttribute('data-clipboard-text'); + if (!text) { + text = document.querySelector(target.getAttribute('data-clipboard-target'))?.value; + } + + if (text && target.getAttribute('data-clipboard-text-type') === 'url') { + text = toAbsoluteUrl(text); + } + + if (text) { + const success = await clippie(text); + showTemporaryTooltip(target, success ? copy_success : copy_error); + } + }); +} diff --git a/web_src/js/features/code-frequency.js b/web_src/js/features/code-frequency.js new file mode 100644 index 0000000..47e1539 --- /dev/null +++ b/web_src/js/features/code-frequency.js @@ -0,0 +1,21 @@ +import {createApp} from 'vue'; + +export async function initRepoCodeFrequency() { + const el = document.getElementById('repo-code-frequency-chart'); + if (!el) return; + + const {default: RepoCodeFrequency} = await import(/* webpackChunkName: "code-frequency-graph" */'../components/RepoCodeFrequency.vue'); + try { + const View = createApp(RepoCodeFrequency, { + locale: { + loadingTitle: el.getAttribute('data-locale-loading-title'), + loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'), + loadingInfo: el.getAttribute('data-locale-loading-info'), + }, + }); + View.mount(el); + } catch (err) { + console.error('RepoCodeFrequency failed to load', err); + el.textContent = el.getAttribute('data-locale-component-failed-to-load'); + } +} diff --git a/web_src/js/features/codeeditor.js b/web_src/js/features/codeeditor.js new file mode 100644 index 0000000..07a686f --- /dev/null +++ b/web_src/js/features/codeeditor.js @@ -0,0 +1,191 @@ +import tinycolor from 'tinycolor2'; +import {basename, extname, isObject, isDarkTheme} from '../utils.js'; +import {onInputDebounce} from '../utils/dom.js'; + +const languagesByFilename = {}; +const languagesByExt = {}; + +const baseOptions = { + fontFamily: 'var(--fonts-monospace)', + fontSize: 14, // https://github.com/microsoft/monaco-editor/issues/2242 + guides: {bracketPairs: false, indentation: false}, + links: false, + minimap: {enabled: false}, + occurrencesHighlight: 'off', + overviewRulerLanes: 0, + renderLineHighlight: 'all', + renderLineHighlightOnlyWhenFocus: true, + rulers: false, + scrollbar: {horizontalScrollbarSize: 6, verticalScrollbarSize: 6}, + scrollBeyondLastLine: false, + automaticLayout: true, +}; + +function getEditorconfig(input) { + try { + return JSON.parse(input.getAttribute('data-editorconfig')); + } catch { + return null; + } +} + +function initLanguages(monaco) { + for (const {filenames, extensions, id} of monaco.languages.getLanguages()) { + for (const filename of filenames || []) { + languagesByFilename[filename] = id; + } + for (const extension of extensions || []) { + languagesByExt[extension] = id; + } + } +} + +function getLanguage(filename) { + return languagesByFilename[filename] || languagesByExt[extname(filename)] || 'plaintext'; +} + +function updateEditor(monaco, editor, filename, lineWrapExts) { + editor.updateOptions(getFileBasedOptions(filename, lineWrapExts)); + const model = editor.getModel(); + const language = model.getLanguageId(); + const newLanguage = getLanguage(filename); + if (language !== newLanguage) monaco.editor.setModelLanguage(model, newLanguage); +} + +// export editor for customization - https://github.com/go-gitea/gitea/issues/10409 +function exportEditor(editor) { + if (!window.codeEditors) window.codeEditors = []; + if (!window.codeEditors.includes(editor)) window.codeEditors.push(editor); +} + +export async function createMonaco(textarea, filename, editorOpts) { + const monaco = await import(/* webpackChunkName: "monaco" */'monaco-editor'); + + initLanguages(monaco); + let {language, ...other} = editorOpts; + if (!language) language = getLanguage(filename); + + const container = document.createElement('div'); + container.className = 'monaco-editor-container'; + textarea.parentNode.append(container); + + // https://github.com/microsoft/monaco-editor/issues/2427 + // also, monaco can only parse 6-digit hex colors, so we convert the colors to that format + const styles = window.getComputedStyle(document.documentElement); + const getColor = (name) => tinycolor(styles.getPropertyValue(name).trim()).toString('hex6'); + + monaco.editor.defineTheme('gitea', { + base: isDarkTheme() ? 'vs-dark' : 'vs', + inherit: true, + rules: [ + { + background: getColor('--color-code-bg'), + }, + ], + colors: { + 'editor.background': getColor('--color-code-bg'), + 'editor.foreground': getColor('--color-text'), + 'editor.inactiveSelectionBackground': getColor('--color-primary-light-4'), + 'editor.lineHighlightBackground': getColor('--color-editor-line-highlight'), + 'editor.selectionBackground': getColor('--color-primary-light-3'), + 'editor.selectionForeground': getColor('--color-primary-light-3'), + 'editorLineNumber.background': getColor('--color-code-bg'), + 'editorLineNumber.foreground': getColor('--color-secondary-dark-6'), + 'editorWidget.background': getColor('--color-body'), + 'editorWidget.border': getColor('--color-secondary'), + 'input.background': getColor('--color-input-background'), + 'input.border': getColor('--color-input-border'), + 'input.foreground': getColor('--color-input-text'), + 'scrollbar.shadow': getColor('--color-shadow'), + 'progressBar.background': getColor('--color-primary'), + }, + }); + + const editor = monaco.editor.create(container, { + value: textarea.value, + theme: 'gitea', + language, + ...other, + }); + + monaco.editor.addKeybindingRules([ + {keybinding: monaco.KeyCode.Enter, command: null}, // disable enter from accepting code completion + ]); + + const model = editor.getModel(); + model.onDidChangeContent(() => { + textarea.value = editor.getValue({preserveBOM: true}); + textarea.dispatchEvent(new Event('change')); // seems to be needed for jquery-are-you-sure + }); + + exportEditor(editor); + + const loading = document.querySelector('.editor-loading'); + if (loading) loading.remove(); + + return {monaco, editor}; +} + +function getFileBasedOptions(filename, lineWrapExts) { + return { + wordWrap: (lineWrapExts || []).includes(extname(filename)) ? 'on' : 'off', + }; +} + +function togglePreviewDisplay(previewable) { + const previewTab = document.querySelector('a[data-tab="preview"]'); + if (!previewTab) return; + + if (previewable) { + const newUrl = (previewTab.getAttribute('data-url') || '').replace(/(.*)\/.*/, `$1/markup`); + previewTab.setAttribute('data-url', newUrl); + previewTab.style.display = ''; + } else { + previewTab.style.display = 'none'; + // If the "preview" tab was active, user changes the filename to a non-previewable one, + // then the "preview" tab becomes inactive (hidden), so the "write" tab should become active + if (previewTab.classList.contains('active')) { + const writeTab = document.querySelector('a[data-tab="write"]'); + writeTab.click(); + } + } +} + +export async function createCodeEditor(textarea, filenameInput) { + const filename = basename(filenameInput.value); + const previewableExts = new Set((textarea.getAttribute('data-previewable-extensions') || '').split(',')); + const lineWrapExts = (textarea.getAttribute('data-line-wrap-extensions') || '').split(','); + const previewable = previewableExts.has(extname(filename)); + const editorConfig = getEditorconfig(filenameInput); + + togglePreviewDisplay(previewable); + + const {monaco, editor} = await createMonaco(textarea, filename, { + ...baseOptions, + ...getFileBasedOptions(filenameInput.value, lineWrapExts), + ...getEditorConfigOptions(editorConfig), + }); + + filenameInput.addEventListener('input', onInputDebounce(() => { + const filename = filenameInput.value; + const previewable = previewableExts.has(extname(filename)); + togglePreviewDisplay(previewable); + updateEditor(monaco, editor, filename, lineWrapExts); + })); + + return editor; +} + +function getEditorConfigOptions(ec) { + if (!isObject(ec)) return {}; + + const opts = {}; + opts.detectIndentation = !('indent_style' in ec) || !('indent_size' in ec); + if ('indent_size' in ec) opts.indentSize = Number(ec.indent_size); + if ('tab_width' in ec) opts.tabSize = Number(ec.tab_width) || opts.indentSize; + if ('max_line_length' in ec) opts.rulers = [Number(ec.max_line_length)]; + opts.trimAutoWhitespace = ec.trim_trailing_whitespace === true; + opts.insertSpaces = ec.indent_style === 'space'; + opts.useTabStops = ec.indent_style === 'tab'; + return opts; +} diff --git a/web_src/js/features/colorpicker.js b/web_src/js/features/colorpicker.js new file mode 100644 index 0000000..6d00d90 --- /dev/null +++ b/web_src/js/features/colorpicker.js @@ -0,0 +1,66 @@ +import {createTippy} from '../modules/tippy.js'; + +export async function initColorPickers() { + const els = document.getElementsByClassName('js-color-picker-input'); + if (!els.length) return; + + await Promise.all([ + import(/* webpackChunkName: "colorpicker" */'vanilla-colorful/hex-color-picker.js'), + import(/* webpackChunkName: "colorpicker" */'../../css/features/colorpicker.css'), + ]); + + for (const el of els) { + initPicker(el); + } +} + +function updateSquare(el, newValue) { + el.style.color = /#[0-9a-f]{6}/i.test(newValue) ? newValue : 'transparent'; +} + +function updatePicker(el, newValue) { + el.setAttribute('color', newValue); +} + +function initPicker(el) { + const input = el.querySelector('input'); + + const square = document.createElement('div'); + square.classList.add('preview-square'); + updateSquare(square, input.value); + el.append(square); + + const picker = document.createElement('hex-color-picker'); + picker.addEventListener('color-changed', (e) => { + input.value = e.detail.value; + input.focus(); + updateSquare(square, e.detail.value); + }); + + input.addEventListener('input', (e) => { + updateSquare(square, e.target.value); + updatePicker(picker, e.target.value); + }); + + createTippy(input, { + trigger: 'focus click', + theme: 'bare', + hideOnClick: true, + content: picker, + placement: 'bottom-start', + interactive: true, + onShow() { + updatePicker(picker, input.value); + }, + }); + + // init precolors + for (const colorEl of el.querySelectorAll('.precolors .color')) { + colorEl.addEventListener('click', (e) => { + const newValue = e.target.getAttribute('data-color-hex'); + input.value = newValue; + input.dispatchEvent(new Event('input', {bubbles: true})); + updateSquare(square, newValue); + }); + } +} diff --git a/web_src/js/features/common-global.js b/web_src/js/features/common-global.js new file mode 100644 index 0000000..5a304d9 --- /dev/null +++ b/web_src/js/features/common-global.js @@ -0,0 +1,463 @@ +import $ from 'jquery'; +import '../vendor/jquery.are-you-sure.js'; +import {clippie} from 'clippie'; +import {createDropzone} from './dropzone.js'; +import {showGlobalErrorMessage} from '../bootstrap.js'; +import {handleGlobalEnterQuickSubmit} from './comp/QuickSubmit.js'; +import {svg} from '../svg.js'; +import {hideElem, showElem, toggleElem, initSubmitEventPolyfill, submitEventSubmitter} from '../utils/dom.js'; +import {htmlEscape} from 'escape-goat'; +import {showTemporaryTooltip} from '../modules/tippy.js'; +import {confirmModal} from './comp/ConfirmModal.js'; +import {showErrorToast} from '../modules/toast.js'; +import {request, POST, GET} from '../modules/fetch.js'; +import '../htmx.js'; + +const {appUrl, appSubUrl, csrfToken, i18n} = window.config; + +export function initGlobalFormDirtyLeaveConfirm() { + // Warn users that try to leave a page after entering data into a form. + // Except on sign-in pages, and for forms marked as 'ignore-dirty'. + if (!$('.user.signin').length) { + $('form:not(.ignore-dirty)').areYouSure(); + } +} + +export function initHeadNavbarContentToggle() { + const navbar = document.getElementById('navbar'); + const btn = document.getElementById('navbar-expand-toggle'); + if (!navbar || !btn) return; + + btn.addEventListener('click', () => { + const isExpanded = btn.classList.contains('active'); + navbar.classList.toggle('navbar-menu-open', !isExpanded); + btn.classList.toggle('active', !isExpanded); + }); +} + +export function initFootLanguageMenu() { + async function linkLanguageAction() { + const $this = $(this); + await GET($this.data('url')); + window.location.reload(); + } + + $('.language-menu a[lang]').on('click', linkLanguageAction); +} + +export function initGlobalEnterQuickSubmit() { + $(document).on('keydown', '.js-quick-submit', (e) => { + if (((e.ctrlKey && !e.altKey) || e.metaKey) && (e.key === 'Enter')) { + handleGlobalEnterQuickSubmit(e.target); + return false; + } + }); +} + +export function initGlobalButtonClickOnEnter() { + $(document).on('keypress', 'div.ui.button,span.ui.button', (e) => { + if (e.code === ' ' || e.code === 'Enter') { + $(e.target).trigger('click'); + e.preventDefault(); + } + }); +} + +// fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location" +// more details are in the backend's fetch-redirect handler +function fetchActionDoRedirect(redirect) { + const form = document.createElement('form'); + const input = document.createElement('input'); + form.method = 'post'; + form.action = `${appSubUrl}/-/fetch-redirect`; + input.type = 'hidden'; + input.name = 'redirect'; + input.value = redirect; + form.append(input); + document.body.append(form); + form.submit(); +} + +async function fetchActionDoRequest(actionElem, url, opt) { + try { + const resp = await request(url, opt); + if (resp.status === 200) { + let {redirect} = await resp.json(); + redirect = redirect || actionElem.getAttribute('data-redirect'); + actionElem.classList.remove('dirty'); // remove the areYouSure check before reloading + if (redirect) { + fetchActionDoRedirect(redirect); + } else { + window.location.reload(); + } + return; + } else if (resp.status >= 400 && resp.status < 500) { + const data = await resp.json(); + // the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error" + // but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond. + if (data.errorMessage) { + showErrorToast(data.errorMessage, {useHtmlBody: data.renderFormat === 'html'}); + } else { + showErrorToast(`server error: ${resp.status}`); + } + } else { + showErrorToast(`server error: ${resp.status}`); + } + } catch (e) { + if (e.name !== 'AbortError') { + console.error('error when doRequest', e); + showErrorToast(`${i18n.network_error} ${e}`); + } + } + actionElem.classList.remove('is-loading', 'loading-icon-2px'); +} + +async function formFetchAction(e) { + if (!e.target.classList.contains('form-fetch-action')) return; + + e.preventDefault(); + const formEl = e.target; + if (formEl.classList.contains('is-loading')) return; + + formEl.classList.add('is-loading'); + if (formEl.clientHeight < 50) { + formEl.classList.add('loading-icon-2px'); + } + + const formMethod = formEl.getAttribute('method') || 'get'; + const formActionUrl = formEl.getAttribute('action'); + const formData = new FormData(formEl); + const formSubmitter = submitEventSubmitter(e); + const [submitterName, submitterValue] = [formSubmitter?.getAttribute('name'), formSubmitter?.getAttribute('value')]; + if (submitterName) { + formData.append(submitterName, submitterValue || ''); + } + + let reqUrl = formActionUrl; + const reqOpt = {method: formMethod.toUpperCase()}; + if (formMethod.toLowerCase() === 'get') { + const params = new URLSearchParams(); + for (const [key, value] of formData) { + params.append(key, value.toString()); + } + const pos = reqUrl.indexOf('?'); + if (pos !== -1) { + reqUrl = reqUrl.slice(0, pos); + } + reqUrl += `?${params.toString()}`; + } else { + reqOpt.body = formData; + } + + await fetchActionDoRequest(formEl, reqUrl, reqOpt); +} + +export function initGlobalCommon() { + // Semantic UI modules. + const $uiDropdowns = $('.ui.dropdown'); + + // do not init "custom" dropdowns, "custom" dropdowns are managed by their own code. + $uiDropdowns.filter(':not(.custom)').dropdown(); + + // The "jump" means this dropdown is mainly used for "menu" purpose, + // clicking an item will jump to somewhere else or trigger an action/function. + // When a dropdown is used for non-refresh actions with tippy, + // it must have this "jump" class to hide the tippy when dropdown is closed. + $uiDropdowns.filter('.jump').dropdown({ + action: 'hide', + onShow() { + // hide associated tooltip while dropdown is open + this._tippy?.hide(); + this._tippy?.disable(); + }, + onHide() { + this._tippy?.enable(); + + // hide all tippy elements of items after a while. eg: use Enter to click "Copy Link" in the Issue Context Menu + setTimeout(() => { + const $dropdown = $(this); + if ($dropdown.dropdown('is hidden')) { + $(this).find('.menu > .item').each((_, item) => { + item._tippy?.hide(); + }); + } + }, 2000); + }, + }); + + // Special popup-directions, prevent Fomantic from guessing the popup direction. + // With default "direction: auto", if the viewport height is small, Fomantic would show the popup upward, + // if the dropdown is at the beginning of the page, then the top part would be clipped by the window view. + // eg: Issue List "Sort" dropdown + // But we can not set "direction: downward" for all dropdowns, because there is a bug in dropdown menu positioning when calculating the "left" position, + // which would make some dropdown popups slightly shift out of the right viewport edge in some cases. + // eg: the "Create New Repo" menu on the navbar. + $uiDropdowns.filter('.upward').dropdown('setting', 'direction', 'upward'); + $uiDropdowns.filter('.downward').dropdown('setting', 'direction', 'downward'); + + $('.tabular.menu .item').tab(); + + initSubmitEventPolyfill(); + document.addEventListener('submit', formFetchAction); + document.addEventListener('click', linkAction); +} + +export function initGlobalDropzone() { + for (const el of document.querySelectorAll('.dropzone')) { + initDropzone(el); + } +} + +export function initDropzone(el) { + const $dropzone = $(el); + const _promise = createDropzone(el, { + url: $dropzone.data('upload-url'), + headers: {'X-Csrf-Token': csrfToken}, + maxFiles: $dropzone.data('max-file'), + maxFilesize: $dropzone.data('max-size'), + acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'), + addRemoveLinks: true, + dictDefaultMessage: $dropzone.data('default-message'), + dictInvalidFileType: $dropzone.data('invalid-input-type'), + dictFileTooBig: $dropzone.data('file-too-big'), + dictRemoveFile: $dropzone.data('remove-file'), + timeout: 0, + thumbnailMethod: 'contain', + thumbnailWidth: 480, + thumbnailHeight: 480, + init() { + this.on('success', (file, data) => { + file.uuid = data.uuid; + const $input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid); + $dropzone.find('.files').append($input); + // Create a "Copy Link" element, to conveniently copy the image + // or file link as Markdown to the clipboard + const copyLinkElement = document.createElement('div'); + copyLinkElement.className = 'tw-text-center'; + // The a element has a hardcoded cursor: pointer because the default is overridden by .dropzone + copyLinkElement.innerHTML = `<a href="#" style="cursor: pointer;">${svg('octicon-copy', 14, 'copy link')} Copy link</a>`; + copyLinkElement.addEventListener('click', async (e) => { + e.preventDefault(); + let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`; + if (file.type.startsWith('image/')) { + fileMarkdown = `!${fileMarkdown}`; + } else if (file.type.startsWith('video/')) { + fileMarkdown = `<video src="/attachments/${file.uuid}" title="${htmlEscape(file.name)}" controls></video>`; + } + const success = await clippie(fileMarkdown); + showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error); + }); + file.previewTemplate.append(copyLinkElement); + }); + this.on('removedfile', (file) => { + $(`#${file.uuid}`).remove(); + if ($dropzone.data('remove-url')) { + POST($dropzone.data('remove-url'), { + data: new URLSearchParams({file: file.uuid}), + }); + } + }); + this.on('error', function (file, message) { + showErrorToast(message); + this.removeFile(file); + }); + }, + }); +} + +async function linkAction(e) { + // A "link-action" can post AJAX request to its "data-url" + // Then the browser is redirected to: the "redirect" in response, or "data-redirect" attribute, or current URL by reloading. + // If the "link-action" has "data-modal-confirm" attribute, a confirm modal dialog will be shown before taking action. + const el = e.target.closest('.link-action'); + if (!el) return; + + e.preventDefault(); + const url = el.getAttribute('data-url'); + const doRequest = async () => { + el.disabled = true; + await fetchActionDoRequest(el, url, {method: 'POST'}); + el.disabled = false; + }; + + const modalConfirmContent = htmlEscape(el.getAttribute('data-modal-confirm') || ''); + if (!modalConfirmContent) { + await doRequest(); + return; + } + + const isRisky = el.classList.contains('red') || el.classList.contains('yellow') || el.classList.contains('orange') || el.classList.contains('negative'); + if (await confirmModal({content: modalConfirmContent, buttonColor: isRisky ? 'orange' : 'primary'})) { + await doRequest(); + } +} + +export function initGlobalLinkActions() { + function showDeletePopup(e) { + e.preventDefault(); + const $this = $(this || e.target); + const dataArray = $this.data(); + let filter = ''; + if ($this[0].getAttribute('data-modal-id')) { + filter += `#${$this[0].getAttribute('data-modal-id')}`; + } + + const $dialog = $(`.delete.modal${filter}`); + $dialog.find('.name').text($this.data('name')); + for (const [key, value] of Object.entries(dataArray)) { + if (key && key.startsWith('data')) { + $dialog.find(`.${key}`).text(value); + } + } + + $dialog.modal({ + closable: false, + onApprove: async () => { + if ($this.data('type') === 'form') { + $($this.data('form')).trigger('submit'); + return; + } + if ($this[0].getAttribute('hx-confirm')) { + e.detail.issueRequest(true); + return; + } + const postData = new FormData(); + for (const [key, value] of Object.entries(dataArray)) { + if (key && key.startsWith('data')) { + postData.append(key.slice(4), value); + } + if (key === 'id') { + postData.append('id', value); + } + } + + const response = await POST($this.data('url'), {data: postData}); + if (response.ok) { + const data = await response.json(); + window.location.href = data.redirect; + } + }, + }).modal('show'); + } + + // Helpers. + $('.delete-button').on('click', showDeletePopup); + + document.addEventListener('htmx:confirm', (e) => { + e.preventDefault(); + // htmx:confirm is triggered for every HTMX request, even those that don't + // have the `hx-confirm` attribute specified. To avoid opening modals for + // those elements, check if 'e.detail.question' is empty, which contains the + // value of the `hx-confirm` attribute. + if (!e.detail.question) { + e.detail.issueRequest(true); + } else { + showDeletePopup(e); + } + }); +} + +function initGlobalShowModal() { + // A ".show-modal" button will show a modal dialog defined by its "data-modal" attribute. + // Each "data-modal-{target}" attribute will be filled to target element's value or text-content. + // * First, try to query '#target' + // * Then, try to query '.target' + // * Then, try to query 'target' as HTML tag + // If there is a ".{attr}" part like "data-modal-form.action", then the form's "action" attribute will be set. + $('.show-modal').on('click', function (e) { + e.preventDefault(); + const modalSelector = this.getAttribute('data-modal'); + const $modal = $(modalSelector); + if (!$modal.length) { + throw new Error('no modal for this action'); + } + const modalAttrPrefix = 'data-modal-'; + for (const attrib of this.attributes) { + if (!attrib.name.startsWith(modalAttrPrefix)) { + continue; + } + + const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length); + const [attrTargetName, attrTargetAttr] = attrTargetCombo.split('.'); + // try to find target by: "#target" -> ".target" -> "target tag" + let $attrTarget = $modal.find(`#${attrTargetName}`); + if (!$attrTarget.length) $attrTarget = $modal.find(`.${attrTargetName}`); + if (!$attrTarget.length) $attrTarget = $modal.find(`${attrTargetName}`); + if (!$attrTarget.length) continue; // TODO: show errors in dev mode to remind developers that there is a bug + + if (attrTargetAttr) { + $attrTarget[0][attrTargetAttr] = attrib.value; + } else if ($attrTarget[0].matches('input, textarea')) { + $attrTarget.val(attrib.value); // FIXME: add more supports like checkbox + } else { + $attrTarget.text(attrib.value); // FIXME: it should be more strict here, only handle div/span/p + } + } + + $modal.modal('setting', { + onApprove: () => { + // "form-fetch-action" can handle network errors gracefully, + // so keep the modal dialog to make users can re-submit the form if anything wrong happens. + if ($modal.find('.form-fetch-action').length) return false; + }, + }).modal('show'); + }); +} + +export function initGlobalButtons() { + // There are many "cancel button" elements in modal dialogs, Fomantic UI expects they are button-like elements but never submit a form. + // However, Gitea misuses the modal dialog and put the cancel buttons inside forms, so we must prevent the form submission. + // There are a few cancel buttons in non-modal forms, and there are some dynamically created forms (eg: the "Edit Issue Content") + $(document).on('click', 'form button.ui.cancel.button', (e) => { + e.preventDefault(); + }); + + $('.show-panel').on('click', function (e) { + // a '.show-panel' element can show a panel, by `data-panel="selector"` + // if it has "toggle" class, it toggles the panel + e.preventDefault(); + const sel = this.getAttribute('data-panel'); + if (this.classList.contains('toggle')) { + toggleElem(sel); + } else { + showElem(sel); + } + }); + + $('.hide-panel').on('click', function (e) { + // a `.hide-panel` element can hide a panel, by `data-panel="selector"` or `data-panel-closest="selector"` + e.preventDefault(); + let sel = this.getAttribute('data-panel'); + if (sel) { + hideElem($(sel)); + return; + } + sel = this.getAttribute('data-panel-closest'); + if (sel) { + hideElem($(this).closest(sel)); + return; + } + // should never happen, otherwise there is a bug in code + showErrorToast('Nothing to hide'); + }); + + initGlobalShowModal(); +} + +/** + * Too many users set their ROOT_URL to wrong value, and it causes a lot of problems: + * * Cross-origin API request without correct cookie + * * Incorrect href in <a> + * * ... + * So we check whether current URL starts with AppUrl(ROOT_URL). + * If they don't match, show a warning to users. + */ +export function checkAppUrl() { + const curUrl = window.location.href; + // some users visit "https://domain/gitea" while appUrl is "https://domain/gitea/", there should be no warning + if (curUrl.startsWith(appUrl) || `${curUrl}/` === appUrl) { + return; + } + showGlobalErrorMessage(`Your ROOT_URL in app.ini is "${appUrl}", it's unlikely matching the site you are visiting. +Mismatched ROOT_URL config causes wrong URL links for web UI/mail content/webhook notification/OAuth2 sign-in.`); +} diff --git a/web_src/js/features/common-issue-list.js b/web_src/js/features/common-issue-list.js new file mode 100644 index 0000000..0c0f6c5 --- /dev/null +++ b/web_src/js/features/common-issue-list.js @@ -0,0 +1,68 @@ +import {isElemHidden, onInputDebounce, submitEventSubmitter, toggleElem} from '../utils/dom.js'; +import {GET} from '../modules/fetch.js'; + +const {appSubUrl} = window.config; +const reIssueIndex = /^(\d+)$/; // eg: "123" +const reIssueSharpIndex = /^#(\d+)$/; // eg: "#123" +const reIssueOwnerRepoIndex = /^([-.\w]+)\/([-.\w]+)#(\d+)$/; // eg: "{owner}/{repo}#{index}" + +// if the searchText can be parsed to an "issue goto link", return the link, otherwise return empty string +export function parseIssueListQuickGotoLink(repoLink, searchText) { + searchText = searchText.trim(); + let targetUrl = ''; + if (repoLink) { + // try to parse it in current repo + if (reIssueIndex.test(searchText)) { + targetUrl = `${repoLink}/issues/${searchText}`; + } else if (reIssueSharpIndex.test(searchText)) { + targetUrl = `${repoLink}/issues/${searchText.substr(1)}`; + } + } else { + // try to parse it for a global search (eg: "owner/repo#123") + const matchIssueOwnerRepoIndex = searchText.match(reIssueOwnerRepoIndex); + if (matchIssueOwnerRepoIndex) { + const [_, owner, repo, index] = matchIssueOwnerRepoIndex; + targetUrl = `${appSubUrl}/${owner}/${repo}/issues/${index}`; + } + } + return targetUrl; +} + +export function initCommonIssueListQuickGoto() { + const goto = document.getElementById('issue-list-quick-goto'); + if (!goto) return; + + const form = goto.closest('form'); + const input = form.querySelector('input[name=q]'); + const repoLink = goto.getAttribute('data-repo-link'); + + form.addEventListener('submit', (e) => { + // if there is no goto button, or the form is submitted by non-quick-goto elements, submit the form directly + let doQuickGoto = !isElemHidden(goto); + const submitter = submitEventSubmitter(e); + if (submitter !== form && submitter !== input && submitter !== goto) doQuickGoto = false; + if (!doQuickGoto) return; + + // if there is a goto button, use its link + e.preventDefault(); + window.location.href = goto.getAttribute('data-issue-goto-link'); + }); + + const onInput = async () => { + const searchText = input.value; + // try to check whether the parsed goto link is valid + let targetUrl = parseIssueListQuickGotoLink(repoLink, searchText); + if (targetUrl) { + const res = await GET(`${targetUrl}/info`); + if (res.status !== 200) targetUrl = ''; + } + // if the input value has changed, then ignore the result + if (input.value !== searchText) return; + + toggleElem(goto, Boolean(targetUrl)); + goto.setAttribute('data-issue-goto-link', targetUrl); + }; + + input.addEventListener('input', onInputDebounce(onInput)); + onInput(); +} diff --git a/web_src/js/features/common-issue-list.test.js b/web_src/js/features/common-issue-list.test.js new file mode 100644 index 0000000..da7ea64 --- /dev/null +++ b/web_src/js/features/common-issue-list.test.js @@ -0,0 +1,16 @@ +import {parseIssueListQuickGotoLink} from './common-issue-list.js'; + +test('parseIssueListQuickGotoLink', () => { + expect(parseIssueListQuickGotoLink('/link', '')).toEqual(''); + expect(parseIssueListQuickGotoLink('/link', 'abc')).toEqual(''); + expect(parseIssueListQuickGotoLink('/link', '123')).toEqual('/link/issues/123'); + expect(parseIssueListQuickGotoLink('/link', '#123')).toEqual('/link/issues/123'); + expect(parseIssueListQuickGotoLink('/link', 'owner/repo#123')).toEqual(''); + + expect(parseIssueListQuickGotoLink('', '')).toEqual(''); + expect(parseIssueListQuickGotoLink('', 'abc')).toEqual(''); + expect(parseIssueListQuickGotoLink('', '123')).toEqual(''); + expect(parseIssueListQuickGotoLink('', '#123')).toEqual(''); + expect(parseIssueListQuickGotoLink('', 'owner/repo#')).toEqual(''); + expect(parseIssueListQuickGotoLink('', 'owner/repo#123')).toEqual('/owner/repo/issues/123'); +}); diff --git a/web_src/js/features/common-organization.js b/web_src/js/features/common-organization.js new file mode 100644 index 0000000..442714a --- /dev/null +++ b/web_src/js/features/common-organization.js @@ -0,0 +1,16 @@ +import {initCompLabelEdit} from './comp/LabelEdit.js'; +import {toggleElem} from '../utils/dom.js'; + +export function initCommonOrganization() { + if (!document.querySelectorAll('.organization').length) { + return; + } + + document.querySelector('.organization.settings.options #org_name')?.addEventListener('input', function () { + const nameChanged = this.value.toLowerCase() !== this.getAttribute('data-org-name').toLowerCase(); + toggleElem('#org-name-change-prompt', nameChanged); + }); + + // Labels + initCompLabelEdit('.organization.settings.labels'); +} diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js new file mode 100644 index 0000000..70e92de --- /dev/null +++ b/web_src/js/features/comp/ComboMarkdownEditor.js @@ -0,0 +1,413 @@ +import '@github/markdown-toolbar-element'; +import '@github/text-expander-element'; +import $ from 'jquery'; +import {attachTribute} from '../tribute.js'; +import {hideElem, showElem, autosize, isElemVisible} from '../../utils/dom.js'; +import {initEasyMDEPaste, initTextareaPaste} from './Paste.js'; +import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js'; +import {renderPreviewPanelContent} from '../repo-editor.js'; +import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js'; +import {initTextExpander} from './TextExpander.js'; +import {showErrorToast} from '../../modules/toast.js'; +import {POST} from '../../modules/fetch.js'; + +let elementIdCounter = 0; + +/** + * validate if the given textarea is non-empty. + * @param {HTMLElement} textarea - The textarea element to be validated. + * @returns {boolean} returns true if validation succeeded. + */ +export function validateTextareaNonEmpty(textarea) { + // When using EasyMDE, the original edit area HTML element is hidden, breaking HTML5 input validation. + // The workaround (https://github.com/sparksuite/simplemde-markdown-editor/issues/324) doesn't work with contenteditable, so we just show an alert. + if (!textarea.value) { + if (isElemVisible(textarea)) { + textarea.required = true; + const form = textarea.closest('form'); + form?.reportValidity(); + } else { + // The alert won't hurt users too much, because we are dropping the EasyMDE and the check only occurs in a few places. + showErrorToast('Require non-empty content'); + } + return false; + } + return true; +} + +class ComboMarkdownEditor { + constructor(container, options = {}) { + container._giteaComboMarkdownEditor = this; + this.options = options; + this.container = container; + } + + async init() { + this.prepareEasyMDEToolbarActions(); + this.setupContainer(); + this.setupTab(); + this.setupDropzone(); + this.setupTextarea(); + + await this.switchToUserPreference(); + } + + applyEditorHeights(el, heights) { + if (!heights) return; + if (heights.minHeight) el.style.minHeight = heights.minHeight; + if (heights.height) el.style.height = heights.height; + if (heights.maxHeight) el.style.maxHeight = heights.maxHeight; + } + + setupContainer() { + initTextExpander(this.container.querySelector('text-expander')); + this.container.addEventListener('ce-editor-content-changed', (e) => this.options?.onContentChanged?.(this, e)); + } + + setupTextarea() { + this.textarea = this.container.querySelector('.markdown-text-editor'); + this.textarea._giteaComboMarkdownEditor = this; + this.textarea.id = `_combo_markdown_editor_${String(elementIdCounter++)}`; + this.textarea.addEventListener('input', (e) => this.options?.onContentChanged?.(this, e)); + this.applyEditorHeights(this.textarea, this.options.editorHeights); + + if (this.textarea.getAttribute('data-disable-autosize') !== 'true') { + this.textareaAutosize = autosize(this.textarea, {viewportMarginBottom: 130}); + } + + this.textareaMarkdownToolbar = this.container.querySelector('markdown-toolbar'); + this.textareaMarkdownToolbar.setAttribute('for', this.textarea.id); + for (const el of this.textareaMarkdownToolbar.querySelectorAll('.markdown-toolbar-button')) { + // upstream bug: The role code is never executed in base MarkdownButtonElement https://github.com/github/markdown-toolbar-element/issues/70 + el.setAttribute('role', 'button'); + // the editor usually is in a form, so the buttons should have "type=button", avoiding conflicting with the form's submit. + if (el.nodeName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button'); + } + this.textareaMarkdownToolbar.querySelector('button[data-md-action="indent"]')?.addEventListener('click', () => { + this.indentSelection(false); + }); + this.textareaMarkdownToolbar.querySelector('button[data-md-action="unindent"]')?.addEventListener('click', () => { + this.indentSelection(true); + }); + + this.textarea.addEventListener('keydown', (e) => { + if (e.shiftKey) { + e.target._shiftDown = true; + } + if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.altKey) { + if (!this.breakLine()) return; // Nothing changed, let the default handler work. + this.options?.onContentChanged?.(this, e); + e.preventDefault(); + } + }); + this.textarea.addEventListener('keyup', (e) => { + if (!e.shiftKey) { + e.target._shiftDown = false; + } + }); + + 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) => { + 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 easymdeButton = this.container.querySelector('.markdown-switch-easymde'); + easymdeButton?.addEventListener('click', async (e) => { + e.preventDefault(); + this.userPreferredEditor = 'easymde'; + await this.switchToEasyMDE(); + }); + + if (this.dropzone) { + initTextareaPaste(this.textarea, this.dropzone); + } + } + + setupDropzone() { + const dropzoneParentContainer = this.container.getAttribute('data-dropzone-parent-container'); + if (dropzoneParentContainer) { + this.dropzone = this.container.closest(this.container.getAttribute('data-dropzone-parent-container'))?.querySelector('.dropzone'); + } + } + + setupTab() { + const $container = $(this.container); + const tabs = $container[0].querySelectorAll('.tabular.menu > .item'); + + // Fomantic Tab requires the "data-tab" to be globally unique. + // So here it uses our defined "data-tab-for" and "data-tab-panel" to generate the "data-tab" attribute for Fomantic. + const tabEditor = Array.from(tabs).find((tab) => tab.getAttribute('data-tab-for') === 'markdown-writer'); + const tabPreviewer = Array.from(tabs).find((tab) => tab.getAttribute('data-tab-for') === 'markdown-previewer'); + tabEditor.setAttribute('data-tab', `markdown-writer-${elementIdCounter}`); + tabPreviewer.setAttribute('data-tab', `markdown-previewer-${elementIdCounter}`); + const panelEditor = $container[0].querySelector('.ui.tab[data-tab-panel="markdown-writer"]'); + const panelPreviewer = $container[0].querySelector('.ui.tab[data-tab-panel="markdown-previewer"]'); + panelEditor.setAttribute('data-tab', `markdown-writer-${elementIdCounter}`); + panelPreviewer.setAttribute('data-tab', `markdown-previewer-${elementIdCounter}`); + elementIdCounter++; + + tabEditor.addEventListener('click', () => { + requestAnimationFrame(() => { + this.focus(); + }); + }); + + $(tabs).tab(); + + this.previewUrl = tabPreviewer.getAttribute('data-preview-url'); + this.previewContext = tabPreviewer.getAttribute('data-preview-context'); + this.previewMode = this.options.previewMode ?? 'comment'; + this.previewWiki = this.options.previewWiki ?? false; + tabPreviewer.addEventListener('click', async () => { + const formData = new FormData(); + formData.append('mode', this.previewMode); + formData.append('context', this.previewContext); + formData.append('text', this.value()); + formData.append('wiki', this.previewWiki); + const response = await POST(this.previewUrl, {data: formData}); + const data = await response.text(); + renderPreviewPanelContent($(panelPreviewer), data); + }); + } + + prepareEasyMDEToolbarActions() { + this.easyMDEToolbarDefault = [ + 'bold', 'italic', 'strikethrough', '|', 'heading-1', 'heading-2', 'heading-3', + 'heading-bigger', 'heading-smaller', '|', 'code', 'quote', '|', 'gitea-checkbox-empty', + 'gitea-checkbox-checked', '|', 'unordered-list', 'ordered-list', '|', 'link', 'image', + 'table', 'horizontal-rule', '|', 'gitea-switch-to-textarea', + ]; + } + + parseEasyMDEToolbar(EasyMDE, actions) { + this.easyMDEToolbarActions = this.easyMDEToolbarActions || easyMDEToolbarActions(EasyMDE, this); + const processed = []; + for (const action of actions) { + const actionButton = this.easyMDEToolbarActions[action]; + if (!actionButton) throw new Error(`Unknown EasyMDE toolbar action ${action}`); + processed.push(actionButton); + } + return processed; + } + + async switchToUserPreference() { + if (this.userPreferredEditor === 'easymde') { + await this.switchToEasyMDE(); + } else { + this.switchToTextarea(); + } + } + + switchToTextarea() { + if (!this.easyMDE) return; + showElem(this.textareaMarkdownToolbar); + if (this.easyMDE) { + this.easyMDE.toTextArea(); + this.easyMDE = null; + } + } + + async switchToEasyMDE() { + if (this.easyMDE) return; + // EasyMDE's CSS should be loaded via webpack config, otherwise our own styles can not overwrite the default styles. + const {default: EasyMDE} = await import(/* webpackChunkName: "easymde" */'easymde'); + const easyMDEOpt = { + autoDownloadFontAwesome: false, + element: this.textarea, + forceSync: true, + renderingConfig: {singleLineBreaks: false}, + indentWithTabs: false, + tabSize: 4, + spellChecker: false, + inputStyle: 'contenteditable', // nativeSpellcheck requires contenteditable + nativeSpellcheck: true, + ...this.options.easyMDEOptions, + }; + easyMDEOpt.toolbar = this.parseEasyMDEToolbar(EasyMDE, easyMDEOpt.toolbar ?? this.easyMDEToolbarDefault); + + this.easyMDE = new EasyMDE(easyMDEOpt); + this.easyMDE.codemirror.on('change', (...args) => {this.options?.onContentChanged?.(this, ...args)}); + this.easyMDE.codemirror.setOption('extraKeys', { + 'Cmd-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()), + 'Ctrl-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()), + Enter: (cm) => { + const tributeContainer = document.querySelector('.tribute-container'); + if (!tributeContainer || tributeContainer.style.display === 'none') { + cm.execCommand('newlineAndIndent'); + } + }, + Up: (cm) => { + const tributeContainer = document.querySelector('.tribute-container'); + if (!tributeContainer || tributeContainer.style.display === 'none') { + return cm.execCommand('goLineUp'); + } + }, + Down: (cm) => { + const tributeContainer = document.querySelector('.tribute-container'); + if (!tributeContainer || tributeContainer.style.display === 'none') { + return cm.execCommand('goLineDown'); + } + }, + }); + this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll'), this.options.editorHeights); + await attachTribute(this.easyMDE.codemirror.getInputField(), {mentions: true, emoji: true}); + initEasyMDEPaste(this.easyMDE, this.dropzone); + hideElem(this.textareaMarkdownToolbar); + } + + value(v = undefined) { + if (v === undefined) { + if (this.easyMDE) { + return this.easyMDE.value(); + } + return this.textarea.value; + } + + if (this.easyMDE) { + this.easyMDE.value(v); + } else { + this.textarea.value = v; + } + this.textareaAutosize?.resizeToFit(); + } + + focus() { + if (this.easyMDE) { + this.easyMDE.codemirror.focus(); + } else { + this.textarea.focus(); + } + } + + moveCursorToEnd() { + this.textarea.focus(); + this.textarea.setSelectionRange(this.textarea.value.length, this.textarea.value.length); + if (this.easyMDE) { + this.easyMDE.codemirror.focus(); + this.easyMDE.codemirror.setCursor(this.easyMDE.codemirror.lineCount(), 0); + } + } + + indentSelection(unindent) { + // Indent with 4 spaces, unindent 4 spaces or fewer or a lost tab. + const indentPrefix = ' '; + const unindentRegex = /^( {1,4}|\t)/; + + // Indent all lines that are included in the selection, partially or whole, while preserving the original selection at the end. + const lines = this.textarea.value.split('\n'); + const changedLines = []; + // The current selection or cursor position. + const [start, end] = [this.textarea.selectionStart, this.textarea.selectionEnd]; + // The range containing whole lines that will effectively be replaced. + let [editStart, editEnd] = [start, end]; + // The range that needs to be re-selected to match previous selection. + let [newStart, newEnd] = [start, end]; + // The start and end position of the current line (where end points to the newline or EOF) + let [lineStart, lineEnd] = [0, 0]; + + for (const line of lines) { + lineEnd = lineStart + line.length + 1; + if (lineEnd <= start) { + lineStart = lineEnd; + continue; + } + + const updated = unindent ? line.replace(unindentRegex, '') : indentPrefix + line; + changedLines.push(updated); + const move = updated.length - line.length; + + if (start >= lineStart && start < lineEnd) { + editStart = lineStart; + newStart = Math.max(start + move, lineStart); + } + + newEnd += move; + editEnd = lineEnd - 1; + lineStart = lineEnd; + if (lineStart > end) break; + } + + // Update changed lines whole. + const text = changedLines.join('\n'); + this.textarea.focus(); + this.textarea.setSelectionRange(editStart, editEnd); + if (!document.execCommand('insertText', false, text)) { + // execCommand is deprecated, but setRangeText (and any other direct value modifications) erases the native undo history. + // So only fall back to it if execCommand fails. + this.textarea.setRangeText(text); + } + + // Set selection to (effectively) be the same as before. + this.textarea.setSelectionRange(newStart, Math.max(newStart, newEnd)); + } + + breakLine() { + const [start, end] = [this.textarea.selectionStart, this.textarea.selectionEnd]; + + // Do nothing if a range is selected + if (start !== end) return false; + + const value = this.textarea.value; + // Find the beginning of the current line. + const lineStart = Math.max(0, value.lastIndexOf('\n', start - 1) + 1); + // Find the end and extract the line. + const lineEnd = value.indexOf('\n', start); + const line = value.slice(lineStart, lineEnd < 0 ? value.length : lineEnd); + // Match any whitespace at the start + any repeatable prefix + exactly one space after. + const prefix = line.match(/^\s*((\d+)[.)]\s|[-*+]\s+(\[[ x]\]\s?)?|(>\s+)+)?/); + + // Defer to browser if we can't do anything more useful, or if the cursor is inside the prefix. + if (!prefix || !prefix[0].length || lineStart + prefix[0].length > start) return false; + + // Insert newline + prefix. + let text = `\n${prefix[0]}`; + // Increment a number if present. (perhaps detecting repeating 1. and not doing that then would be a good idea) + const num = text.match(/\d+/); + if (num) text = text.replace(num[0], Number(num[0]) + 1); + text = text.replace('[x]', '[ ]'); + + if (!document.execCommand('insertText', false, text)) { + this.textarea.setRangeText(text); + } + + return true; + } + + get userPreferredEditor() { + return window.localStorage.getItem(`markdown-editor-${this.options.useScene ?? 'default'}`); + } + set userPreferredEditor(s) { + window.localStorage.setItem(`markdown-editor-${this.options.useScene ?? 'default'}`, s); + } +} + +export function getComboMarkdownEditor(el) { + if (el instanceof $) el = el[0]; + return el?._giteaComboMarkdownEditor; +} + +export async function initComboMarkdownEditor(container, options = {}) { + if (container instanceof $) { + if (container.length !== 1) { + throw new Error('initComboMarkdownEditor: container must be a single element'); + } + container = container[0]; + } + if (!container) { + throw new Error('initComboMarkdownEditor: container is null'); + } + const editor = new ComboMarkdownEditor(container, options); + await editor.init(); + return editor; +} diff --git a/web_src/js/features/comp/ConfirmModal.js b/web_src/js/features/comp/ConfirmModal.js new file mode 100644 index 0000000..e64996a --- /dev/null +++ b/web_src/js/features/comp/ConfirmModal.js @@ -0,0 +1,30 @@ +import $ from 'jquery'; +import {svg} from '../../svg.js'; +import {htmlEscape} from 'escape-goat'; + +const {i18n} = window.config; + +export async function confirmModal(opts = {content: '', buttonColor: 'primary'}) { + return new Promise((resolve) => { + const $modal = $(` +<div class="ui g-modal-confirm modal"> + <div class="content">${htmlEscape(opts.content)}</div> + <div class="actions"> + <button class="ui cancel button">${svg('octicon-x')} ${i18n.modal_cancel}</button> + <button class="ui ${opts.buttonColor || 'primary'} ok button">${svg('octicon-check')} ${i18n.modal_confirm}</button> + </div> +</div> +`); + + $modal.appendTo(document.body); + $modal.modal({ + onApprove() { + resolve(true); + }, + onHidden() { + $modal.remove(); + resolve(false); + }, + }).modal('show'); + }); +} diff --git a/web_src/js/features/comp/EasyMDEToolbarActions.js b/web_src/js/features/comp/EasyMDEToolbarActions.js new file mode 100644 index 0000000..35abb87 --- /dev/null +++ b/web_src/js/features/comp/EasyMDEToolbarActions.js @@ -0,0 +1,152 @@ +import {svg} from '../../svg.js'; + +export function easyMDEToolbarActions(EasyMDE, editor) { + const actions = { + '|': '|', + 'heading-1': { + action: EasyMDE.toggleHeading1, + icon: svg('octicon-heading'), + title: 'Heading 1', + }, + 'heading-2': { + action: EasyMDE.toggleHeading2, + icon: svg('octicon-heading'), + title: 'Heading 2', + }, + 'heading-3': { + action: EasyMDE.toggleHeading3, + icon: svg('octicon-heading'), + title: 'Heading 3', + }, + 'heading-smaller': { + action: EasyMDE.toggleHeadingSmaller, + icon: svg('octicon-heading'), + title: 'Decrease Heading', + }, + 'heading-bigger': { + action: EasyMDE.toggleHeadingBigger, + icon: svg('octicon-heading'), + title: 'Increase Heading', + }, + 'bold': { + action: EasyMDE.toggleBold, + icon: svg('octicon-bold'), + title: 'Bold', + }, + 'italic': { + action: EasyMDE.toggleItalic, + icon: svg('octicon-italic'), + title: 'Italic', + }, + 'strikethrough': { + action: EasyMDE.toggleStrikethrough, + icon: svg('octicon-strikethrough'), + title: 'Strikethrough', + }, + 'quote': { + action: EasyMDE.toggleBlockquote, + icon: svg('octicon-quote'), + title: 'Quote', + }, + 'code': { + action: EasyMDE.toggleCodeBlock, + icon: svg('octicon-code'), + title: 'Code', + }, + 'link': { + action: EasyMDE.drawLink, + icon: svg('octicon-link'), + title: 'Link', + }, + 'unordered-list': { + action: EasyMDE.toggleUnorderedList, + icon: svg('octicon-list-unordered'), + title: 'Unordered List', + }, + 'ordered-list': { + action: EasyMDE.toggleOrderedList, + icon: svg('octicon-list-ordered'), + title: 'Ordered List', + }, + 'image': { + action: EasyMDE.drawImage, + icon: svg('octicon-image'), + title: 'Image', + }, + 'table': { + action: EasyMDE.drawTable, + icon: svg('octicon-table'), + title: 'Table', + }, + 'horizontal-rule': { + action: EasyMDE.drawHorizontalRule, + icon: svg('octicon-horizontal-rule'), + title: 'Horizontal Rule', + }, + 'preview': { + action: EasyMDE.togglePreview, + icon: svg('octicon-eye'), + title: 'Preview', + }, + 'fullscreen': { + action: EasyMDE.toggleFullScreen, + icon: svg('octicon-screen-full'), + title: 'Fullscreen', + }, + 'side-by-side': { + action: EasyMDE.toggleSideBySide, + icon: svg('octicon-columns'), + title: 'Side by Side', + }, + + // Forgejo custom actions + 'gitea-checkbox-empty': { + action(e) { + const cm = e.codemirror; + cm.replaceSelection(`\n- [ ] ${cm.getSelection()}`); + cm.focus(); + }, + icon: svg('gitea-empty-checkbox'), + title: 'Add Checkbox (empty)', + }, + 'gitea-checkbox-checked': { + action(e) { + const cm = e.codemirror; + cm.replaceSelection(`\n- [x] ${cm.getSelection()}`); + cm.focus(); + }, + icon: svg('octicon-checkbox'), + title: 'Add Checkbox (checked)', + }, + 'gitea-switch-to-textarea': { + action: () => { + editor.userPreferredEditor = 'textarea'; + editor.switchToTextarea(); + }, + icon: svg('octicon-arrow-switch'), + title: 'Revert to simple textarea', + }, + 'gitea-code-inline': { + action(e) { + const cm = e.codemirror; + const selection = cm.getSelection(); + cm.replaceSelection(`\`${selection}\``); + if (!selection) { + const cursorPos = cm.getCursor(); + cm.setCursor(cursorPos.line, cursorPos.ch - 1); + } + cm.focus(); + }, + icon: svg('octicon-chevron-right'), + title: 'Add Inline Code', + }, + }; + + for (const [key, value] of Object.entries(actions)) { + if (typeof value !== 'string') { + value.name = key; + } + } + + return actions; +} diff --git a/web_src/js/features/comp/LabelEdit.js b/web_src/js/features/comp/LabelEdit.js new file mode 100644 index 0000000..2cc75cc --- /dev/null +++ b/web_src/js/features/comp/LabelEdit.js @@ -0,0 +1,96 @@ +import $ from 'jquery'; + +function isExclusiveScopeName(name) { + return /.*[^/]\/[^/].*/.test(name); +} + +function updateExclusiveLabelEdit(form) { + const nameInput = document.querySelector(`${form} .label-name-input`); + const exclusiveField = document.querySelector(`${form} .label-exclusive-input-field`); + const exclusiveCheckbox = document.querySelector(`${form} .label-exclusive-input`); + const exclusiveWarning = document.querySelector(`${form} .label-exclusive-warning`); + + if (isExclusiveScopeName(nameInput.value)) { + exclusiveField?.classList.remove('muted'); + exclusiveField?.removeAttribute('aria-disabled'); + if (exclusiveCheckbox.checked && exclusiveCheckbox.getAttribute('data-exclusive-warn')) { + exclusiveWarning?.classList.remove('tw-hidden'); + } else { + exclusiveWarning?.classList.add('tw-hidden'); + } + } else { + exclusiveField?.classList.add('muted'); + exclusiveField?.setAttribute('aria-disabled', 'true'); + exclusiveWarning?.classList.add('tw-hidden'); + } +} + +export function initCompLabelEdit(selector) { + if (!$(selector).length) return; + + // Create label + $('.new-label.button').on('click', () => { + updateExclusiveLabelEdit('.new-label'); + $('.new-label.modal').modal({ + onApprove() { + const form = document.querySelector('.new-label.form'); + if (!form.checkValidity()) { + form.reportValidity(); + return false; + } + $('.new-label.form').trigger('submit'); + }, + }).modal('show'); + return false; + }); + + // Edit label + $('.edit-label-button').on('click', function () { + $('#label-modal-id').val($(this).data('id')); + + const $nameInput = $('.edit-label .label-name-input'); + $nameInput.val($(this).data('title')); + + const $isArchivedCheckbox = $('.edit-label .label-is-archived-input'); + $isArchivedCheckbox[0].checked = this.hasAttribute('data-is-archived'); + + const $exclusiveCheckbox = $('.edit-label .label-exclusive-input'); + $exclusiveCheckbox[0].checked = this.hasAttribute('data-exclusive'); + // Warn when label was previously not exclusive and used in issues + $exclusiveCheckbox.data('exclusive-warn', + $(this).data('num-issues') > 0 && + (!this.hasAttribute('data-exclusive') || !isExclusiveScopeName($nameInput.val()))); + updateExclusiveLabelEdit('.edit-label'); + + $('.edit-label .label-desc-input').val(this.getAttribute('data-description')); + + const colorInput = document.querySelector('.edit-label .js-color-picker-input input'); + colorInput.value = this.getAttribute('data-color'); + colorInput.dispatchEvent(new Event('input', {bubbles: true})); + + $('.edit-label.modal').modal({ + onApprove() { + const form = document.querySelector('.edit-label.form'); + if (!form.checkValidity()) { + form.reportValidity(); + return false; + } + $('.edit-label.form').trigger('submit'); + }, + }).modal('show'); + return false; + }); + + $('.new-label .label-name-input').on('input', () => { + updateExclusiveLabelEdit('.new-label'); + }); + $('.new-label .label-exclusive-input').on('change', () => { + updateExclusiveLabelEdit('.new-label'); + }); + $('.edit-label .label-name-input').on('input', () => { + updateExclusiveLabelEdit('.edit-label'); + }); + $('.edit-label .label-exclusive-input').on('change', () => { + updateExclusiveLabelEdit('.edit-label'); + }); +} diff --git a/web_src/js/features/comp/Paste.js b/web_src/js/features/comp/Paste.js new file mode 100644 index 0000000..7e4ecbb --- /dev/null +++ b/web_src/js/features/comp/Paste.js @@ -0,0 +1,144 @@ +import {POST} from '../../modules/fetch.js'; +import {getPastedContent, replaceTextareaSelection} from '../../utils/dom.js'; +import {isUrl} from '../../utils/url.js'; + +async function uploadFile(file, uploadUrl) { + const formData = new FormData(); + formData.append('file', file, file.name); + + const res = await POST(uploadUrl, {data: formData}); + return await res.json(); +} + +function triggerEditorContentChanged(target) { + target.dispatchEvent(new CustomEvent('ce-editor-content-changed', {bubbles: true})); +} + +class TextareaEditor { + constructor(editor) { + this.editor = editor; + } + + insertPlaceholder(value) { + const editor = this.editor; + const startPos = editor.selectionStart; + const endPos = editor.selectionEnd; + editor.value = editor.value.substring(0, startPos) + value + editor.value.substring(endPos); + editor.selectionStart = startPos; + editor.selectionEnd = startPos + value.length; + editor.focus(); + triggerEditorContentChanged(editor); + } + + replacePlaceholder(oldVal, newVal) { + const editor = this.editor; + const startPos = editor.selectionStart; + const endPos = editor.selectionEnd; + if (editor.value.substring(startPos, endPos) === oldVal) { + editor.value = editor.value.substring(0, startPos) + newVal + editor.value.substring(endPos); + editor.selectionEnd = startPos + newVal.length; + } else { + editor.value = editor.value.replace(oldVal, newVal); + editor.selectionEnd -= oldVal.length; + editor.selectionEnd += newVal.length; + } + editor.selectionStart = editor.selectionEnd; + editor.focus(); + triggerEditorContentChanged(editor); + } +} + +class CodeMirrorEditor { + constructor(editor) { + this.editor = editor; + } + + insertPlaceholder(value) { + const editor = this.editor; + const startPoint = editor.getCursor('start'); + const endPoint = editor.getCursor('end'); + editor.replaceSelection(value); + endPoint.ch = startPoint.ch + value.length; + editor.setSelection(startPoint, endPoint); + editor.focus(); + triggerEditorContentChanged(editor.getTextArea()); + } + + replacePlaceholder(oldVal, newVal) { + const editor = this.editor; + const endPoint = editor.getCursor('end'); + if (editor.getSelection() === oldVal) { + editor.replaceSelection(newVal); + } else { + editor.setValue(editor.getValue().replace(oldVal, newVal)); + } + endPoint.ch -= oldVal.length; + endPoint.ch += newVal.length; + editor.setSelection(endPoint, endPoint); + editor.focus(); + triggerEditorContentChanged(editor.getTextArea()); + } +} + +async function handleClipboardImages(editor, dropzone, images, e) { + const uploadUrl = dropzone.getAttribute('data-upload-url'); + const filesContainer = dropzone.querySelector('.files'); + + if (!dropzone || !uploadUrl || !filesContainer || !images.length) return; + + e.preventDefault(); + e.stopPropagation(); + + for (const img of images) { + const name = img.name.slice(0, img.name.lastIndexOf('.')); + + const placeholder = `![${name}](uploading ...)`; + editor.insertPlaceholder(placeholder); + + const {uuid} = await uploadFile(img, uploadUrl); + + const url = `/attachments/${uuid}`; + const text = `![${name}](${url})`; + editor.replacePlaceholder(placeholder, text); + + const input = document.createElement('input'); + input.setAttribute('name', 'files'); + input.setAttribute('type', 'hidden'); + input.setAttribute('id', uuid); + input.value = uuid; + filesContainer.append(input); + } +} + +function handleClipboardText(textarea, text, e) { + // when pasting links over selected text, turn it into [text](link), except when shift key is held + const {value, selectionStart, selectionEnd, _shiftDown} = textarea; + if (_shiftDown) return; + const selectedText = value.substring(selectionStart, selectionEnd); + const trimmedText = text.trim(); + if (selectedText && isUrl(trimmedText) && !isUrl(selectedText)) { + e.stopPropagation(); + e.preventDefault(); + replaceTextareaSelection(textarea, `[${selectedText}](${trimmedText})`); + } +} + +export function initEasyMDEPaste(easyMDE, dropzone) { + easyMDE.codemirror.on('paste', (_, e) => { + const {images} = getPastedContent(e); + if (images.length) { + handleClipboardImages(new CodeMirrorEditor(easyMDE.codemirror), dropzone, images, e); + } + }); +} + +export function initTextareaPaste(textarea, dropzone) { + textarea.addEventListener('paste', (e) => { + const {images, text} = getPastedContent(e); + if (images.length) { + handleClipboardImages(new TextareaEditor(textarea), dropzone, images, e); + } else if (text) { + handleClipboardText(textarea, text, e); + } + }); +} diff --git a/web_src/js/features/comp/QuickSubmit.js b/web_src/js/features/comp/QuickSubmit.js new file mode 100644 index 0000000..e6d7080 --- /dev/null +++ b/web_src/js/features/comp/QuickSubmit.js @@ -0,0 +1,17 @@ +export function handleGlobalEnterQuickSubmit(target) { + const form = target.closest('form'); + if (form) { + if (!form.checkValidity()) { + form.reportValidity(); + return; + } + + // here use the event to trigger the submit event (instead of calling `submit()` method directly) + // otherwise the `areYouSure` handler won't be executed, then there will be an annoying "confirm to leave" dialog + form.dispatchEvent(new SubmitEvent('submit', {bubbles: true, cancelable: true})); + } else { + // if no form, then the editor is for an AJAX request, dispatch an event to the target, let the target's event handler to do the AJAX request. + // the 'ce-' prefix means this is a CustomEvent + target.dispatchEvent(new CustomEvent('ce-quick-submit', {bubbles: true})); + } +} diff --git a/web_src/js/features/comp/ReactionSelector.js b/web_src/js/features/comp/ReactionSelector.js new file mode 100644 index 0000000..fd4601f --- /dev/null +++ b/web_src/js/features/comp/ReactionSelector.js @@ -0,0 +1,38 @@ +import $ from 'jquery'; +import {POST} from '../../modules/fetch.js'; + +export function initCompReactionSelector($parent) { + $parent.find(`.select-reaction .item.reaction, .comment-reaction-button`).on('click', async function (e) { + e.preventDefault(); + + if (this.classList.contains('disabled')) return; + + const actionUrl = this.closest('[data-action-url]')?.getAttribute('data-action-url'); + const reactionContent = this.getAttribute('data-reaction-content'); + const hasReacted = this.closest('.comment')?.querySelector(`.ui.segment.reactions a[data-reaction-content="${reactionContent}"]`)?.getAttribute('data-has-reacted') === 'true'; + + const res = await POST(`${actionUrl}/${hasReacted ? 'unreact' : 'react'}`, { + data: new URLSearchParams({content: reactionContent}), + }); + + const data = await res.json(); + if (data && (data.html || data.empty)) { + const $content = $(this).closest('.content'); + let $react = $content.find('.segment.reactions'); + if ((!data.empty || data.html === '') && $react.length > 0) { + $react.remove(); + } + if (!data.empty) { + const $attachments = $content.find('.segment.bottom:first'); + $react = $(data.html); + if ($attachments.length > 0) { + $react.insertBefore($attachments); + } else { + $react.appendTo($content); + } + $react.find('.dropdown').dropdown(); + initCompReactionSelector($react); + } + } + }); +} diff --git a/web_src/js/features/comp/SearchUserBox.js b/web_src/js/features/comp/SearchUserBox.js new file mode 100644 index 0000000..081c474 --- /dev/null +++ b/web_src/js/features/comp/SearchUserBox.js @@ -0,0 +1,51 @@ +import $ from 'jquery'; +import {htmlEscape} from 'escape-goat'; + +const {appSubUrl} = window.config; +const looksLikeEmailAddressCheck = /^\S+@\S+$/; + +export function initCompSearchUserBox() { + const searchUserBox = document.getElementById('search-user-box'); + if (!searchUserBox) return; + + const $searchUserBox = $(searchUserBox); + const allowEmailInput = searchUserBox.getAttribute('data-allow-email') === 'true'; + const allowEmailDescription = searchUserBox.getAttribute('data-allow-email-description') ?? undefined; + $searchUserBox.search({ + minCharacters: 2, + apiSettings: { + url: `${appSubUrl}/user/search?active=1&q={query}`, + onResponse(response) { + const items = []; + const searchQuery = $searchUserBox.find('input').val(); + const searchQueryUppercase = searchQuery.toUpperCase(); + $.each(response.data, (_i, item) => { + const resultItem = { + title: item.login, + image: item.avatar_url, + }; + if (item.full_name) { + resultItem.description = htmlEscape(item.full_name); + } + if (searchQueryUppercase === item.login.toUpperCase()) { + items.unshift(resultItem); + } else { + items.push(resultItem); + } + }); + + if (allowEmailInput && !items.length && looksLikeEmailAddressCheck.test(searchQuery)) { + const resultItem = { + title: searchQuery, + description: allowEmailDescription, + }; + items.push(resultItem); + } + + return {results: items}; + }, + }, + searchFields: ['login', 'full_name'], + showNoResults: false, + }); +} diff --git a/web_src/js/features/comp/TextExpander.js b/web_src/js/features/comp/TextExpander.js new file mode 100644 index 0000000..128a2dd --- /dev/null +++ b/web_src/js/features/comp/TextExpander.js @@ -0,0 +1,61 @@ +import {matchEmoji, matchMention} from '../../utils/match.js'; +import {emojiString} from '../emoji.js'; + +export function initTextExpander(expander) { + expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => { + if (key === ':') { + const matches = matchEmoji(text); + if (!matches.length) return provide({matched: false}); + + const ul = document.createElement('ul'); + ul.classList.add('suggestions'); + for (const name of matches) { + const emoji = emojiString(name); + const li = document.createElement('li'); + li.setAttribute('role', 'option'); + li.setAttribute('data-value', emoji); + li.textContent = `${emoji} ${name}`; + ul.append(li); + } + + provide({matched: true, fragment: ul}); + } else if (key === '@') { + const matches = matchMention(text); + if (!matches.length) return provide({matched: false}); + + const ul = document.createElement('ul'); + ul.classList.add('suggestions'); + for (const {value, name, fullname, avatar} of matches) { + const li = document.createElement('li'); + li.setAttribute('role', 'option'); + li.setAttribute('data-value', `${key}${value}`); + + const img = document.createElement('img'); + img.src = avatar; + li.append(img); + + const nameSpan = document.createElement('span'); + nameSpan.textContent = name; + li.append(nameSpan); + + if (fullname && fullname.toLowerCase() !== name) { + const fullnameSpan = document.createElement('span'); + fullnameSpan.classList.add('fullname'); + fullnameSpan.textContent = fullname; + li.append(fullnameSpan); + } + + ul.append(li); + } + + provide({matched: true, fragment: ul}); + } + }); + expander?.addEventListener('text-expander-value', ({detail}) => { + if (detail?.item) { + // add a space after @mentions as it's likely the user wants one + const suffix = detail.key === '@' ? ' ' : ''; + detail.value = `${detail.item.getAttribute('data-value')}${suffix}`; + } + }); +} diff --git a/web_src/js/features/comp/WebHookEditor.js b/web_src/js/features/comp/WebHookEditor.js new file mode 100644 index 0000000..522daa1 --- /dev/null +++ b/web_src/js/features/comp/WebHookEditor.js @@ -0,0 +1,28 @@ +import {POST} from '../../modules/fetch.js'; +import {toggleElem} from '../../utils/dom.js'; + +export function initCompWebHookEditor() { + if (!document.querySelectorAll('.new.webhook').length) { + return; + } + + // some webhooks (like Gitea) allow to set the request method (GET/POST), and it would toggle the "Content Type" field + const httpMethodInput = document.getElementById('http_method'); + if (httpMethodInput) { + const updateContentType = function () { + const visible = httpMethodInput.value === 'POST'; + toggleElem(document.getElementById('content_type').closest('.field'), visible); + }; + updateContentType(); + httpMethodInput.addEventListener('change', updateContentType); + } + + // Test delivery + document.getElementById('test-delivery')?.addEventListener('click', async function () { + this.classList.add('is-loading', 'disabled'); + await POST(this.getAttribute('data-link')); + setTimeout(() => { + window.location.href = this.getAttribute('data-redirect'); + }, 5000); + }); +} diff --git a/web_src/js/features/contextpopup.js b/web_src/js/features/contextpopup.js new file mode 100644 index 0000000..ce90f3e --- /dev/null +++ b/web_src/js/features/contextpopup.js @@ -0,0 +1,43 @@ +import {createApp} from 'vue'; +import ContextPopup from '../components/ContextPopup.vue'; +import {parseIssueHref} from '../utils.js'; +import {createTippy} from '../modules/tippy.js'; + +export function initContextPopups() { + const refIssues = document.querySelectorAll('.ref-issue'); + attachRefIssueContextPopup(refIssues); +} + +export function attachRefIssueContextPopup(refIssues) { + for (const refIssue of refIssues) { + if (refIssue.classList.contains('ref-external-issue')) { + return; + } + + const {owner, repo, index} = parseIssueHref(refIssue.getAttribute('href')); + if (!owner) return; + + const el = document.createElement('div'); + refIssue.parentNode.insertBefore(el, refIssue.nextSibling); + + const view = createApp(ContextPopup); + + try { + view.mount(el); + } catch (err) { + console.error(err); + el.textContent = 'ContextPopup failed to load'; + } + + createTippy(refIssue, { + content: el, + placement: 'top-start', + interactive: true, + role: 'dialog', + interactiveBorder: 5, + onShow: () => { + el.firstChild.dispatchEvent(new CustomEvent('ce-load-context-popup', {detail: {owner, repo, index}})); + }, + }); + } +} diff --git a/web_src/js/features/contributors.js b/web_src/js/features/contributors.js new file mode 100644 index 0000000..79b3389 --- /dev/null +++ b/web_src/js/features/contributors.js @@ -0,0 +1,29 @@ +import {createApp} from 'vue'; + +export async function initRepoContributors() { + const el = document.getElementById('repo-contributors-chart'); + if (!el) return; + + const {default: RepoContributors} = await import(/* webpackChunkName: "contributors-graph" */'../components/RepoContributors.vue'); + try { + const View = createApp(RepoContributors, { + repoLink: el.getAttribute('data-repo-link'), + locale: { + filterLabel: el.getAttribute('data-locale-filter-label'), + contributionType: { + commits: el.getAttribute('data-locale-contribution-type-commits'), + additions: el.getAttribute('data-locale-contribution-type-additions'), + deletions: el.getAttribute('data-locale-contribution-type-deletions'), + }, + + loadingTitle: el.getAttribute('data-locale-loading-title'), + loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'), + loadingInfo: el.getAttribute('data-locale-loading-info'), + }, + }); + View.mount(el); + } catch (err) { + console.error('RepoContributors failed to load', err); + el.textContent = el.getAttribute('data-locale-component-failed-to-load'); + } +} diff --git a/web_src/js/features/copycontent.js b/web_src/js/features/copycontent.js new file mode 100644 index 0000000..03efe00 --- /dev/null +++ b/web_src/js/features/copycontent.js @@ -0,0 +1,56 @@ +import {clippie} from 'clippie'; +import {showTemporaryTooltip} from '../modules/tippy.js'; +import {convertImage} from '../utils.js'; +import {GET} from '../modules/fetch.js'; + +const {i18n} = window.config; + +export function initCopyContent() { + const btn = document.getElementById('copy-content'); + if (!btn || btn.classList.contains('disabled')) return; + + btn.addEventListener('click', async () => { + if (btn.classList.contains('is-loading')) return; + let content; + let isRasterImage = false; + const link = btn.getAttribute('data-link'); + + // when data-link is present, we perform a fetch. this is either because + // the text to copy is not in the DOM or it is an image which should be + // fetched to copy in full resolution + if (link) { + btn.classList.add('is-loading', 'loading-icon-2px'); + try { + const res = await GET(link, {credentials: 'include', redirect: 'follow'}); + const contentType = res.headers.get('content-type'); + + if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) { + isRasterImage = true; + content = await res.blob(); + } else { + content = await res.text(); + } + } catch { + return showTemporaryTooltip(btn, i18n.copy_error); + } finally { + btn.classList.remove('is-loading', 'loading-icon-2px'); + } + } else { // text, read from DOM + const lineEls = document.querySelectorAll('.file-view .lines-code'); + content = Array.from(lineEls, (el) => el.textContent).join(''); + } + + // try copy original first, if that fails and it's an image, convert it to png + const success = await clippie(content); + if (success) { + showTemporaryTooltip(btn, i18n.copy_success); + } else { + if (isRasterImage) { + const success = await clippie(await convertImage(content, 'image/png')); + showTemporaryTooltip(btn, success ? i18n.copy_success : i18n.copy_error); + } else { + showTemporaryTooltip(btn, i18n.copy_error); + } + } + }); +} diff --git a/web_src/js/features/dropzone.js b/web_src/js/features/dropzone.js new file mode 100644 index 0000000..e7b8a9d --- /dev/null +++ b/web_src/js/features/dropzone.js @@ -0,0 +1,7 @@ +export async function createDropzone(el, opts) { + const [{Dropzone}] = await Promise.all([ + import(/* webpackChunkName: "dropzone" */'dropzone'), + import(/* webpackChunkName: "dropzone" */'dropzone/dist/dropzone.css'), + ]); + return new Dropzone(el, opts); +} diff --git a/web_src/js/features/emoji.js b/web_src/js/features/emoji.js new file mode 100644 index 0000000..032a3ef --- /dev/null +++ b/web_src/js/features/emoji.js @@ -0,0 +1,38 @@ +import emojis from '../../../assets/emoji.json'; + +const {assetUrlPrefix, customEmojis} = window.config; + +const tempMap = {...customEmojis}; +for (const {emoji, aliases} of emojis) { + for (const alias of aliases || []) { + tempMap[alias] = emoji; + } +} + +export const emojiKeys = Object.keys(tempMap).sort((a, b) => { + if (a === '+1' || a === '-1') return -1; + if (b === '+1' || b === '-1') return 1; + return a.localeCompare(b); +}); + +const emojiMap = {}; +for (const key of emojiKeys) { + emojiMap[key] = tempMap[key]; +} + +// retrieve HTML for given emoji name +export function emojiHTML(name) { + let inner; + if (Object.hasOwn(customEmojis, name)) { + inner = `<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`; + } else { + inner = emojiString(name); + } + + return `<span class="emoji" title=":${name}:">${inner}</span>`; +} + +// retrieve string for given emoji name +export function emojiString(name) { + return emojiMap[name] || `:${name}:`; +} diff --git a/web_src/js/features/eventsource.sharedworker.js b/web_src/js/features/eventsource.sharedworker.js new file mode 100644 index 0000000..62581cf --- /dev/null +++ b/web_src/js/features/eventsource.sharedworker.js @@ -0,0 +1,141 @@ +const sourcesByUrl = {}; +const sourcesByPort = {}; + +class Source { + constructor(url) { + this.url = url; + this.eventSource = new EventSource(url); + this.listening = {}; + this.clients = []; + this.listen('open'); + this.listen('close'); + this.listen('logout'); + this.listen('notification-count'); + this.listen('stopwatches'); + this.listen('error'); + } + + register(port) { + if (this.clients.includes(port)) return; + + this.clients.push(port); + + port.postMessage({ + type: 'status', + message: `registered to ${this.url}`, + }); + } + + deregister(port) { + const portIdx = this.clients.indexOf(port); + if (portIdx < 0) { + return this.clients.length; + } + this.clients.splice(portIdx, 1); + return this.clients.length; + } + + close() { + if (!this.eventSource) return; + + this.eventSource.close(); + this.eventSource = null; + } + + listen(eventType) { + if (this.listening[eventType]) return; + this.listening[eventType] = true; + this.eventSource.addEventListener(eventType, (event) => { + this.notifyClients({ + type: eventType, + data: event.data, + }); + }); + } + + notifyClients(event) { + for (const client of this.clients) { + client.postMessage(event); + } + } + + status(port) { + port.postMessage({ + type: 'status', + message: `url: ${this.url} readyState: ${this.eventSource.readyState}`, + }); + } +} + +self.addEventListener('connect', (e) => { + for (const port of e.ports) { + port.addEventListener('message', (event) => { + if (!self.EventSource) { + // some browsers (like PaleMoon, Firefox<53) don't support EventSource in SharedWorkerGlobalScope. + // this event handler needs EventSource when doing "new Source(url)", so just post a message back to the caller, + // in case the caller would like to use a fallback method to do its work. + port.postMessage({type: 'no-event-source'}); + return; + } + if (event.data.type === 'start') { + const url = event.data.url; + if (sourcesByUrl[url]) { + // we have a Source registered to this url + const source = sourcesByUrl[url]; + source.register(port); + sourcesByPort[port] = source; + return; + } + let source = sourcesByPort[port]; + if (source) { + if (source.eventSource && source.url === url) return; + + // How this has happened I don't understand... + // deregister from that source + const count = source.deregister(port); + // Clean-up + if (count === 0) { + source.close(); + sourcesByUrl[source.url] = null; + } + } + // Create a new Source + source = new Source(url); + source.register(port); + sourcesByUrl[url] = source; + sourcesByPort[port] = source; + } else if (event.data.type === 'listen') { + const source = sourcesByPort[port]; + source.listen(event.data.eventType); + } else if (event.data.type === 'close') { + const source = sourcesByPort[port]; + + if (!source) return; + + const count = source.deregister(port); + if (count === 0) { + source.close(); + sourcesByUrl[source.url] = null; + sourcesByPort[port] = null; + } + } else if (event.data.type === 'status') { + const source = sourcesByPort[port]; + if (!source) { + port.postMessage({ + type: 'status', + message: 'not connected', + }); + return; + } + source.status(port); + } else { + // just send it back + port.postMessage({ + type: 'error', + message: `received but don't know how to handle: ${event.data}`, + }); + } + }); + port.start(); + } +}); diff --git a/web_src/js/features/file-fold.js b/web_src/js/features/file-fold.js new file mode 100644 index 0000000..3efefaf --- /dev/null +++ b/web_src/js/features/file-fold.js @@ -0,0 +1,19 @@ +import {svg} from '../svg.js'; + +// Hides the file if newFold is true, and shows it otherwise. The actual hiding is performed using CSS. +// +// The fold arrow is the icon displayed on the upper left of the file box, especially intended for components having the 'fold-file' class. +// The file content box is the box that should be hidden or shown, especially intended for components having the 'file-content' class. +// +export function setFileFolding(fileContentBox, foldArrow, newFold) { + foldArrow.innerHTML = svg(`octicon-chevron-${newFold ? 'right' : 'down'}`, 18); + fileContentBox.setAttribute('data-folded', newFold); + if (newFold && fileContentBox.getBoundingClientRect().top < 0) { + fileContentBox.scrollIntoView(); + } +} + +// Like `setFileFolding`, except that it automatically inverts the current file folding state. +export function invertFileFolding(fileContentBox, foldArrow) { + setFileFolding(fileContentBox, foldArrow, fileContentBox.getAttribute('data-folded') !== 'true'); +} diff --git a/web_src/js/features/heatmap.js b/web_src/js/features/heatmap.js new file mode 100644 index 0000000..688f06c --- /dev/null +++ b/web_src/js/features/heatmap.js @@ -0,0 +1,40 @@ +import {createApp} from 'vue'; +import ActivityHeatmap from '../components/ActivityHeatmap.vue'; +import {translateMonth, translateDay} from '../utils.js'; + +export function initHeatmap() { + const el = document.getElementById('user-heatmap'); + if (!el) return; + + try { + const heatmap = {}; + for (const {contributions, timestamp} of JSON.parse(el.getAttribute('data-heatmap-data'))) { + // Convert to user timezone and sum contributions by date + const dateStr = new Date(timestamp * 1000).toDateString(); + heatmap[dateStr] = (heatmap[dateStr] || 0) + contributions; + } + + const values = Object.keys(heatmap).map((v) => { + return {date: new Date(v), count: heatmap[v]}; + }); + + const locale = { + months: new Array(12).fill().map((_, idx) => translateMonth(idx)), + days: new Array(7).fill().map((_, idx) => translateDay(idx)), + contributions_in_the_last_12_months: el.getAttribute('data-locale-total-contributions'), + contributions_zero: el.getAttribute('data-locale-contributions-zero'), + contributions_format: el.getAttribute('data-locale-contributions-format'), + contributions_one: el.getAttribute('data-locale-contributions-one'), + contributions_few: el.getAttribute('data-locale-contributions-few'), + more: el.getAttribute('data-locale-more'), + less: el.getAttribute('data-locale-less'), + }; + + const View = createApp(ActivityHeatmap, {values, locale}); + View.mount(el); + el.classList.remove('is-loading'); + } catch (err) { + console.error('Heatmap failed to load', err); + el.textContent = 'Heatmap failed to load'; + } +} diff --git a/web_src/js/features/imagediff.js b/web_src/js/features/imagediff.js new file mode 100644 index 0000000..d1b139f --- /dev/null +++ b/web_src/js/features/imagediff.js @@ -0,0 +1,271 @@ +import $ from 'jquery'; +import {GET} from '../modules/fetch.js'; +import {hideElem, loadElem, queryElemChildren} from '../utils/dom.js'; +import {parseDom} from '../utils.js'; + +function getDefaultSvgBoundsIfUndefined(text, src) { + const DefaultSize = 300; + const MaxSize = 99999; + + const svgDoc = parseDom(text, 'image/svg+xml'); + const svg = svgDoc.documentElement; + const width = svg?.width?.baseVal; + const height = svg?.height?.baseVal; + if (width === undefined || height === undefined) { + return null; // in case some svg is invalid or doesn't have the width/height + } + if (width.unitType === SVGLength.SVG_LENGTHTYPE_PERCENTAGE || height.unitType === SVGLength.SVG_LENGTHTYPE_PERCENTAGE) { + const img = new Image(); + img.src = src; + if (img.width > 1 && img.width < MaxSize && img.height > 1 && img.height < MaxSize) { + return { + width: img.width, + height: img.height, + }; + } + if (svg.hasAttribute('viewBox')) { + const viewBox = svg.viewBox.baseVal; + return { + width: DefaultSize, + height: DefaultSize * viewBox.width / viewBox.height, + }; + } + return { + width: DefaultSize, + height: DefaultSize, + }; + } + return null; +} + +function createContext(imageAfter, imageBefore) { + const sizeAfter = { + width: imageAfter?.width || 0, + height: imageAfter?.height || 0, + }; + const sizeBefore = { + width: imageBefore?.width || 0, + height: imageBefore?.height || 0, + }; + const maxSize = { + width: Math.max(sizeBefore.width, sizeAfter.width), + height: Math.max(sizeBefore.height, sizeAfter.height), + }; + + return { + imageAfter, + imageBefore, + sizeAfter, + sizeBefore, + maxSize, + ratio: [ + Math.floor(maxSize.width - sizeAfter.width) / 2, + Math.floor(maxSize.height - sizeAfter.height) / 2, + Math.floor(maxSize.width - sizeBefore.width) / 2, + Math.floor(maxSize.height - sizeBefore.height) / 2, + ], + }; +} + +export function initImageDiff() { + $('.image-diff:not([data-image-diff-loaded])').each(async function() { + const $container = $(this); + this.setAttribute('data-image-diff-loaded', 'true'); + + // the container may be hidden by "viewed" checkbox, so use the parent's width for reference + const diffContainerWidth = Math.max($container.closest('.diff-file-box').width() - 300, 100); + + const imageInfos = [{ + path: this.getAttribute('data-path-after'), + mime: this.getAttribute('data-mime-after'), + $images: $container.find('img.image-after'), // matches 3 <img> + $boundsInfo: $container.find('.bounds-info-after'), + }, { + path: this.getAttribute('data-path-before'), + mime: this.getAttribute('data-mime-before'), + $images: $container.find('img.image-before'), // matches 3 <img> + $boundsInfo: $container.find('.bounds-info-before'), + }]; + + await Promise.all(imageInfos.map(async (info) => { + const [success] = await Promise.all(Array.from(info.$images, (img) => { + return loadElem(img, info.path); + })); + // only the first images is associated with $boundsInfo + if (!success) info.$boundsInfo.text('(image error)'); + if (info.mime === 'image/svg+xml') { + const resp = await GET(info.path); + const text = await resp.text(); + const bounds = getDefaultSvgBoundsIfUndefined(text, info.path); + if (bounds) { + info.$images.each(function() { + this.setAttribute('width', bounds.width); + this.setAttribute('height', bounds.height); + }); + hideElem(info.$boundsInfo); + } + } + })); + + const $imagesAfter = imageInfos[0].$images; + const $imagesBefore = imageInfos[1].$images; + + initSideBySide(this, createContext($imagesAfter[0], $imagesBefore[0])); + if ($imagesAfter.length > 0 && $imagesBefore.length > 0) { + initSwipe(createContext($imagesAfter[1], $imagesBefore[1])); + initOverlay(createContext($imagesAfter[2], $imagesBefore[2])); + } + + queryElemChildren(this, '.image-diff-tabs', (el) => el.classList.remove('is-loading')); + + function initSideBySide(container, sizes) { + let factor = 1; + if (sizes.maxSize.width > (diffContainerWidth - 24) / 2) { + factor = (diffContainerWidth - 24) / 2 / sizes.maxSize.width; + } + + const widthChanged = sizes.imageAfter && sizes.imageBefore && sizes.imageAfter.naturalWidth !== sizes.imageBefore.naturalWidth; + const heightChanged = sizes.imageAfter && sizes.imageBefore && sizes.imageAfter.naturalHeight !== sizes.imageBefore.naturalHeight; + if (sizes.imageAfter) { + const boundsInfoAfterWidth = container.querySelector('.bounds-info-after .bounds-info-width'); + if (boundsInfoAfterWidth) { + boundsInfoAfterWidth.textContent = `${sizes.imageAfter.naturalWidth}px`; + boundsInfoAfterWidth.classList.toggle('green', widthChanged); + } + const boundsInfoAfterHeight = container.querySelector('.bounds-info-after .bounds-info-height'); + if (boundsInfoAfterHeight) { + boundsInfoAfterHeight.textContent = `${sizes.imageAfter.naturalHeight}px`; + boundsInfoAfterHeight.classList.toggle('green', heightChanged); + } + } + + if (sizes.imageBefore) { + const boundsInfoBeforeWidth = container.querySelector('.bounds-info-before .bounds-info-width'); + if (boundsInfoBeforeWidth) { + boundsInfoBeforeWidth.textContent = `${sizes.imageBefore.naturalWidth}px`; + boundsInfoBeforeWidth.classList.toggle('red', widthChanged); + } + const boundsInfoBeforeHeight = container.querySelector('.bounds-info-before .bounds-info-height'); + if (boundsInfoBeforeHeight) { + boundsInfoBeforeHeight.textContent = `${sizes.imageBefore.naturalHeight}px`; + boundsInfoBeforeHeight.classList.add('red', heightChanged); + } + } + + if (sizes.imageAfter) { + const container = sizes.imageAfter.parentNode; + sizes.imageAfter.style.width = `${sizes.sizeAfter.width * factor}px`; + sizes.imageAfter.style.height = `${sizes.sizeAfter.height * factor}px`; + container.style.margin = '10px auto'; + container.style.width = `${sizes.sizeAfter.width * factor + 2}px`; + container.style.height = `${sizes.sizeAfter.height * factor + 2}px`; + } + + if (sizes.imageBefore) { + const container = sizes.imageBefore.parentNode; + sizes.imageBefore.style.width = `${sizes.sizeBefore.width * factor}px`; + sizes.imageBefore.style.height = `${sizes.sizeBefore.height * factor}px`; + container.style.margin = '10px auto'; + container.style.width = `${sizes.sizeBefore.width * factor + 2}px`; + container.style.height = `${sizes.sizeBefore.height * factor + 2}px`; + } + } + + function initSwipe(sizes) { + let factor = 1; + if (sizes.maxSize.width > diffContainerWidth - 12) { + factor = (diffContainerWidth - 12) / sizes.maxSize.width; + } + + if (sizes.imageAfter) { + const container = sizes.imageAfter.parentNode; + const swipeFrame = container.parentNode; + sizes.imageAfter.style.width = `${sizes.sizeAfter.width * factor}px`; + sizes.imageAfter.style.height = `${sizes.sizeAfter.height * factor}px`; + container.style.margin = `0px ${sizes.ratio[0] * factor}px`; + container.style.width = `${sizes.sizeAfter.width * factor + 2}px`; + container.style.height = `${sizes.sizeAfter.height * factor + 2}px`; + swipeFrame.style.padding = `${sizes.ratio[1] * factor}px 0 0 0`; + swipeFrame.style.width = `${sizes.maxSize.width * factor + 2}px`; + } + + if (sizes.imageBefore) { + const container = sizes.imageBefore.parentNode; + const swipeFrame = container.parentNode; + sizes.imageBefore.style.width = `${sizes.sizeBefore.width * factor}px`; + sizes.imageBefore.style.height = `${sizes.sizeBefore.height * factor}px`; + container.style.margin = `${sizes.ratio[3] * factor}px ${sizes.ratio[2] * factor}px`; + container.style.width = `${sizes.sizeBefore.width * factor + 2}px`; + container.style.height = `${sizes.sizeBefore.height * factor + 2}px`; + swipeFrame.style.width = `${sizes.maxSize.width * factor + 2}px`; + swipeFrame.style.height = `${sizes.maxSize.height * factor + 2}px`; + } + + // extra height for inner "position: absolute" elements + const swipe = $container.find('.diff-swipe')[0]; + if (swipe) { + swipe.style.width = `${sizes.maxSize.width * factor + 2}px`; + swipe.style.height = `${sizes.maxSize.height * factor + 30}px`; + } + + $container.find('.swipe-bar').on('mousedown', function(e) { + e.preventDefault(); + + const $swipeBar = $(this); + const $swipeFrame = $swipeBar.parent(); + const width = $swipeFrame.width() - $swipeBar.width() - 2; + + $(document).on('mousemove.diff-swipe', (e2) => { + e2.preventDefault(); + + const value = Math.max(0, Math.min(e2.clientX - $swipeFrame.offset().left, width)); + $swipeBar[0].style.left = `${value}px`; + $container.find('.swipe-container')[0].style.width = `${$swipeFrame.width() - value}px`; + + $(document).on('mouseup.diff-swipe', () => { + $(document).off('.diff-swipe'); + }); + }); + }); + } + + function initOverlay(sizes) { + let factor = 1; + if (sizes.maxSize.width > diffContainerWidth - 12) { + factor = (diffContainerWidth - 12) / sizes.maxSize.width; + } + + if (sizes.imageAfter) { + const container = sizes.imageAfter.parentNode; + sizes.imageAfter.style.width = `${sizes.sizeAfter.width * factor}px`; + sizes.imageAfter.style.height = `${sizes.sizeAfter.height * factor}px`; + container.style.margin = `${sizes.ratio[1] * factor}px ${sizes.ratio[0] * factor}px`; + container.style.width = `${sizes.sizeAfter.width * factor + 2}px`; + container.style.height = `${sizes.sizeAfter.height * factor + 2}px`; + } + + if (sizes.imageBefore) { + const container = sizes.imageBefore.parentNode; + const overlayFrame = container.parentNode; + sizes.imageBefore.style.width = `${sizes.sizeBefore.width * factor}px`; + sizes.imageBefore.style.height = `${sizes.sizeBefore.height * factor}px`; + container.style.margin = `${sizes.ratio[3] * factor}px ${sizes.ratio[2] * factor}px`; + container.style.width = `${sizes.sizeBefore.width * factor + 2}px`; + container.style.height = `${sizes.sizeBefore.height * factor + 2}px`; + + // some inner elements are `position: absolute`, so the container's height must be large enough + overlayFrame.style.width = `${sizes.maxSize.width * factor + 2}px`; + overlayFrame.style.height = `${sizes.maxSize.height * factor + 2}px`; + } + + const rangeInput = $container[0].querySelector('input[type="range"]'); + function updateOpacity() { + if (sizes.imageAfter) { + sizes.imageAfter.parentNode.style.opacity = `${rangeInput.value / 100}`; + } + } + rangeInput?.addEventListener('input', updateOpacity); + updateOpacity(); + } + }); +} diff --git a/web_src/js/features/install.js b/web_src/js/features/install.js new file mode 100644 index 0000000..897f5fb --- /dev/null +++ b/web_src/js/features/install.js @@ -0,0 +1,119 @@ +import {hideElem, showElem} from '../utils/dom.js'; +import {GET} from '../modules/fetch.js'; + +export function initInstall() { + const page = document.querySelector('.page-content.install'); + if (!page) { + return; + } + if (page.classList.contains('post-install')) { + initPostInstall(); + } else { + initPreInstall(); + } +} +function initPreInstall() { + const defaultDbUser = 'forgejo'; + const defaultDbName = 'forgejo'; + + const defaultDbHosts = { + mysql: '127.0.0.1:3306', + postgres: '127.0.0.1:5432', + }; + + const dbHost = document.getElementById('db_host'); + const dbUser = document.getElementById('db_user'); + const dbName = document.getElementById('db_name'); + + // Database type change detection. + document.getElementById('db_type').addEventListener('change', function () { + const dbType = this.value; + hideElem('div[data-db-setting-for]'); + showElem(`div[data-db-setting-for=${dbType}]`); + + if (dbType !== 'sqlite3') { + // for most remote database servers + showElem('div[data-db-setting-for=common-host]'); + const lastDbHost = dbHost.value; + const isDbHostDefault = !lastDbHost || Object.values(defaultDbHosts).includes(lastDbHost); + if (isDbHostDefault) { + dbHost.value = defaultDbHosts[dbType] ?? ''; + } + if (!dbUser.value && !dbName.value) { + dbUser.value = defaultDbUser; + dbName.value = defaultDbName; + } + } // else: for SQLite3, the default path is always prepared by backend code (setting) + }); + document.getElementById('db_type').dispatchEvent(new Event('change')); + + const appUrl = document.getElementById('app_url'); + if (appUrl.value.includes('://localhost')) { + appUrl.value = window.location.href; + } + + const domain = document.getElementById('domain'); + if (domain.value.trim() === 'localhost') { + domain.value = window.location.hostname; + } + + // TODO: better handling of exclusive relations. + document.querySelector('#offline-mode input').addEventListener('change', function () { + if (this.checked) { + document.querySelector('#disable-gravatar input').checked = true; + document.querySelector('#federated-avatar-lookup input').checked = false; + } + }); + document.querySelector('#disable-gravatar input').addEventListener('change', function () { + if (this.checked) { + document.querySelector('#federated-avatar-lookup input').checked = false; + } else { + document.querySelector('#offline-mode input').checked = false; + } + }); + document.querySelector('#federated-avatar-lookup input').addEventListener('change', function () { + if (this.checked) { + document.querySelector('#disable-gravatar input').checked = false; + document.querySelector('#offline-mode input').checked = false; + } + }); + document.querySelector('#enable-openid-signin input').addEventListener('change', function () { + if (this.checked) { + if (!document.querySelector('#disable-registration input').checked) { + document.querySelector('#enable-openid-signup input').checked = true; + } + } else { + document.querySelector('#enable-openid-signup input').checked = false; + } + }); + document.querySelector('#disable-registration input').addEventListener('change', function () { + if (this.checked) { + document.querySelector('#enable-captcha input').checked = false; + document.querySelector('#enable-openid-signup input').checked = false; + } else { + document.querySelector('#enable-openid-signup input').checked = true; + } + }); + document.querySelector('#enable-captcha input').addEventListener('change', function () { + if (this.checked) { + document.querySelector('#disable-registration input').checked = false; + } + }); +} + +function initPostInstall() { + const el = document.getElementById('goto-user-login'); + if (!el) return; + + const targetUrl = el.getAttribute('href'); + let tid = setInterval(async () => { + try { + const resp = await GET(targetUrl); + if (tid && resp.status === 200) { + clearInterval(tid); + tid = null; + window.location.href = targetUrl; + } + } catch {} + }, 1000); +} diff --git a/web_src/js/features/notification.js b/web_src/js/features/notification.js new file mode 100644 index 0000000..b57df9c --- /dev/null +++ b/web_src/js/features/notification.js @@ -0,0 +1,192 @@ +import $ from 'jquery'; +import {GET} from '../modules/fetch.js'; +import {toggleElem} from '../utils/dom.js'; + +const {appSubUrl, notificationSettings, assetVersionEncoded} = window.config; +let notificationSequenceNumber = 0; + +export function initNotificationsTable() { + const table = document.getElementById('notification_table'); + if (!table) return; + + // when page restores from bfcache, delete previously clicked items + window.addEventListener('pageshow', (e) => { + if (e.persisted) { // page was restored from bfcache + const table = document.getElementById('notification_table'); + const unreadCountEl = document.querySelector('.notifications-unread-count'); + let unreadCount = parseInt(unreadCountEl.textContent); + for (const item of table.querySelectorAll('.notifications-item[data-remove="true"]')) { + item.remove(); + unreadCount -= 1; + } + unreadCountEl.textContent = unreadCount; + } + }); + + // mark clicked unread links for deletion on bfcache restore + for (const link of table.querySelectorAll('.notifications-item[data-status="1"] .notifications-link')) { + link.addEventListener('click', (e) => { + e.target.closest('.notifications-item').setAttribute('data-remove', 'true'); + }); + } +} + +async function receiveUpdateCount(event) { + try { + const data = JSON.parse(event.data); + + for (const count of document.querySelectorAll('.notification_count')) { + count.classList.toggle('tw-hidden', data.Count === 0); + count.textContent = `${data.Count}`; + } + await updateNotificationTable(); + } catch (error) { + console.error(error, event); + } +} + +export function initNotificationCount() { + const $notificationCount = $('.notification_count'); + + if (!$notificationCount.length) { + return; + } + + let usingPeriodicPoller = false; + const startPeriodicPoller = (timeout, lastCount) => { + if (timeout <= 0 || !Number.isFinite(timeout)) return; + usingPeriodicPoller = true; + lastCount = lastCount ?? $notificationCount.text(); + setTimeout(async () => { + await updateNotificationCountWithCallback(startPeriodicPoller, timeout, lastCount); + }, timeout); + }; + + if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) { + // Try to connect to the event source via the shared worker first + const worker = new SharedWorker(`${__webpack_public_path__}js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, 'notification-worker'); + worker.addEventListener('error', (event) => { + console.error('worker error', event); + }); + worker.port.addEventListener('messageerror', () => { + console.error('unable to deserialize message'); + }); + worker.port.postMessage({ + type: 'start', + url: `${window.location.origin}${appSubUrl}/user/events`, + }); + worker.port.addEventListener('message', (event) => { + if (!event.data || !event.data.type) { + console.error('unknown worker message event', event); + return; + } + if (event.data.type === 'notification-count') { + const _promise = receiveUpdateCount(event.data); + } else if (event.data.type === 'no-event-source') { + // browser doesn't support EventSource, falling back to periodic poller + if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout); + } else if (event.data.type === 'error') { + console.error('worker port event error', event.data); + } else if (event.data.type === 'logout') { + if (event.data.data !== 'here') { + return; + } + worker.port.postMessage({ + type: 'close', + }); + worker.port.close(); + window.location.href = `${window.location.origin}${appSubUrl}/`; + } else if (event.data.type === 'close') { + worker.port.postMessage({ + type: 'close', + }); + worker.port.close(); + } + }); + worker.port.addEventListener('error', (e) => { + console.error('worker port error', e); + }); + worker.port.start(); + window.addEventListener('beforeunload', () => { + worker.port.postMessage({ + type: 'close', + }); + worker.port.close(); + }); + + return; + } + + startPeriodicPoller(notificationSettings.MinTimeout); +} + +async function updateNotificationCountWithCallback(callback, timeout, lastCount) { + const currentCount = $('.notification_count').text(); + if (lastCount !== currentCount) { + callback(notificationSettings.MinTimeout, currentCount); + return; + } + + const newCount = await updateNotificationCount(); + let needsUpdate = false; + + if (lastCount !== newCount) { + needsUpdate = true; + timeout = notificationSettings.MinTimeout; + } else if (timeout < notificationSettings.MaxTimeout) { + timeout += notificationSettings.TimeoutStep; + } + + callback(timeout, newCount); + if (needsUpdate) { + await updateNotificationTable(); + } +} + +async function updateNotificationTable() { + const notificationDiv = document.getElementById('notification_div'); + if (notificationDiv) { + try { + const params = new URLSearchParams(window.location.search); + params.set('div-only', true); + params.set('sequence-number', ++notificationSequenceNumber); + const url = `${appSubUrl}/notifications?${params.toString()}`; + const response = await GET(url); + + if (!response.ok) { + throw new Error('Failed to fetch notification table'); + } + + const data = await response.text(); + if ($(data).data('sequence-number') === notificationSequenceNumber) { + notificationDiv.outerHTML = data; + initNotificationsTable(); + } + } catch (error) { + console.error(error); + } + } +} + +async function updateNotificationCount() { + try { + const response = await GET(`${appSubUrl}/notifications/new`); + + if (!response.ok) { + throw new Error('Failed to fetch notification count'); + } + + const data = await response.json(); + + toggleElem('.notification_count', data.new !== 0); + + for (const el of document.getElementsByClassName('notification_count')) { + el.textContent = `${data.new}`; + } + + return `${data.new}`; + } catch (error) { + console.error(error); + return '0'; + } +} diff --git a/web_src/js/features/org-team.js b/web_src/js/features/org-team.js new file mode 100644 index 0000000..9b059b3 --- /dev/null +++ b/web_src/js/features/org-team.js @@ -0,0 +1,26 @@ +import $ from 'jquery'; + +const {appSubUrl} = window.config; + +export function initOrgTeamSearchRepoBox() { + const $searchRepoBox = $('#search-repo-box'); + $searchRepoBox.search({ + minCharacters: 2, + apiSettings: { + url: `${appSubUrl}/repo/search?q={query}&uid=${$searchRepoBox.data('uid')}`, + onResponse(response) { + const items = []; + $.each(response.data, (_i, item) => { + items.push({ + title: item.repository.full_name.split('/')[1], + description: item.repository.full_name, + }); + }); + + return {results: items}; + }, + }, + searchFields: ['full_name'], + showNoResults: false, + }); +} diff --git a/web_src/js/features/pull-view-file.js b/web_src/js/features/pull-view-file.js new file mode 100644 index 0000000..2472e5a --- /dev/null +++ b/web_src/js/features/pull-view-file.js @@ -0,0 +1,96 @@ +import {diffTreeStore} from '../modules/stores.js'; +import {setFileFolding} from './file-fold.js'; +import {POST} from '../modules/fetch.js'; + +const {pageData} = window.config; +const prReview = pageData.prReview || {}; +const viewedStyleClass = 'viewed-file-checked-form'; +const viewedCheckboxSelector = '.viewed-file-form'; // Selector under which all "Viewed" checkbox forms can be found +const expandFilesBtnSelector = '#expand-files-btn'; +const collapseFilesBtnSelector = '#collapse-files-btn'; + +// Refreshes the summary of viewed files if present +// The data used will be window.config.pageData.prReview.numberOf{Viewed}Files +function refreshViewedFilesSummary() { + const viewedFilesProgress = document.getElementById('viewed-files-summary'); + viewedFilesProgress?.setAttribute('value', prReview.numberOfViewedFiles); + const summaryLabel = document.getElementById('viewed-files-summary-label'); + if (summaryLabel) summaryLabel.innerHTML = summaryLabel.getAttribute('data-text-changed-template') + .replace('%[1]d', prReview.numberOfViewedFiles) + .replace('%[2]d', prReview.numberOfFiles); +} + +// Explicitly recounts how many files the user has currently reviewed by counting the number of checked "viewed" checkboxes +// Additionally, the viewed files summary will be updated if it exists +export function countAndUpdateViewedFiles() { + // The number of files is constant, but the number of viewed files can change because files can be loaded dynamically + prReview.numberOfViewedFiles = document.querySelectorAll(`${viewedCheckboxSelector} > input[type=checkbox][checked]`).length; + refreshViewedFilesSummary(); +} + +// Initializes a listener for all children of the given html element +// (for example 'document' in the most basic case) +// to watch for changes of viewed-file checkboxes +export function initViewedCheckboxListenerFor() { + for (const form of document.querySelectorAll(`${viewedCheckboxSelector}:not([data-has-viewed-checkbox-listener="true"])`)) { + // To prevent double addition of listeners + form.setAttribute('data-has-viewed-checkbox-listener', true); + + // The checkbox consists of a div containing the real checkbox with its label and the CSRF token, + // hence the actual checkbox first has to be found + const checkbox = form.querySelector('input[type=checkbox]'); + checkbox.addEventListener('input', function() { + // Mark the file as viewed visually - will especially change the background + if (this.checked) { + form.classList.add(viewedStyleClass); + checkbox.setAttribute('checked', ''); + prReview.numberOfViewedFiles++; + } else { + form.classList.remove(viewedStyleClass); + checkbox.removeAttribute('checked'); + prReview.numberOfViewedFiles--; + } + + // Update viewed-files summary and remove "has changed" label if present + refreshViewedFilesSummary(); + const hasChangedLabel = form.parentNode.querySelector('.changed-since-last-review'); + hasChangedLabel?.remove(); + + const fileName = checkbox.getAttribute('name'); + + // check if the file is in our difftreestore and if we find it -> change the IsViewed status + const fileInPageData = diffTreeStore().files.find((x) => x.Name === fileName); + if (fileInPageData) { + fileInPageData.IsViewed = this.checked; + } + + // Unfortunately, actual forms cause too many problems, hence another approach is needed + const files = {}; + files[fileName] = this.checked; + const data = {files}; + const headCommitSHA = form.getAttribute('data-headcommit'); + if (headCommitSHA) data.headCommitSHA = headCommitSHA; + POST(form.getAttribute('data-link'), {data}); + + // Fold the file accordingly + const parentBox = form.closest('.diff-file-header'); + setFileFolding(parentBox.closest('.file-content'), parentBox.querySelector('.fold-file'), this.checked); + }); + } +} + +export function initExpandAndCollapseFilesButton() { + // expand btn + document.querySelector(expandFilesBtnSelector)?.addEventListener('click', () => { + for (const box of document.querySelectorAll('.file-content[data-folded="true"]')) { + setFileFolding(box, box.querySelector('.fold-file'), false); + } + }); + // collapse btn, need to exclude the div of “show more” + document.querySelector(collapseFilesBtnSelector)?.addEventListener('click', () => { + for (const box of document.querySelectorAll('.file-content:not([data-folded="true"])')) { + if (box.getAttribute('id') === 'diff-incomplete') continue; + setFileFolding(box, box.querySelector('.fold-file'), true); + } + }); +} diff --git a/web_src/js/features/recent-commits.js b/web_src/js/features/recent-commits.js new file mode 100644 index 0000000..030c251 --- /dev/null +++ b/web_src/js/features/recent-commits.js @@ -0,0 +1,21 @@ +import {createApp} from 'vue'; + +export async function initRepoRecentCommits() { + const el = document.getElementById('repo-recent-commits-chart'); + if (!el) return; + + const {default: RepoRecentCommits} = await import(/* webpackChunkName: "recent-commits-graph" */'../components/RepoRecentCommits.vue'); + try { + const View = createApp(RepoRecentCommits, { + locale: { + loadingTitle: el.getAttribute('data-locale-loading-title'), + loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'), + loadingInfo: el.getAttribute('data-locale-loading-info'), + }, + }); + View.mount(el); + } catch (err) { + console.error('RepoRecentCommits failed to load', err); + el.textContent = el.getAttribute('data-locale-component-failed-to-load'); + } +} diff --git a/web_src/js/features/repo-branch.js b/web_src/js/features/repo-branch.js new file mode 100644 index 0000000..b9ffc61 --- /dev/null +++ b/web_src/js/features/repo-branch.js @@ -0,0 +1,42 @@ +import $ from 'jquery'; +import {toggleElem} from '../utils/dom.js'; + +export function initRepoBranchButton() { + initRepoCreateBranchButton(); + initRepoRenameBranchButton(); +} + +function initRepoCreateBranchButton() { + // 2 pages share this code, one is the branch list page, the other is the commit view page: create branch/tag from current commit (dirty code) + for (const el of document.querySelectorAll('.show-create-branch-modal')) { + el.addEventListener('click', () => { + const modalFormName = el.getAttribute('data-modal-form') || '#create-branch-form'; + const modalForm = document.querySelector(modalFormName); + if (!modalForm) return; + modalForm.action = `${modalForm.getAttribute('data-base-action')}${el.getAttribute('data-branch-from-urlcomponent')}`; + + const fromSpanName = el.getAttribute('data-modal-from-span') || '#modal-create-branch-from-span'; + document.querySelector(fromSpanName).textContent = el.getAttribute('data-branch-from'); + + $(el.getAttribute('data-modal')).modal('show'); + }); + } +} + +function initRepoRenameBranchButton() { + for (const el of document.querySelectorAll('.show-rename-branch-modal')) { + el.addEventListener('click', () => { + const target = el.getAttribute('data-modal'); + const modal = document.querySelector(target); + const oldBranchName = el.getAttribute('data-old-branch-name'); + modal.querySelector('input[name=from]').value = oldBranchName; + + // display the warning that the branch which is chosen is the default branch + const warn = modal.querySelector('.default-branch-warning'); + toggleElem(warn, el.getAttribute('data-is-default-branch') === 'true'); + + const text = modal.querySelector('[data-rename-branch-to]'); + text.textContent = text.getAttribute('data-rename-branch-to').replace('%s', oldBranchName); + }); + } +} diff --git a/web_src/js/features/repo-code.js b/web_src/js/features/repo-code.js new file mode 100644 index 0000000..794cc38 --- /dev/null +++ b/web_src/js/features/repo-code.js @@ -0,0 +1,195 @@ +import $ from 'jquery'; +import {svg} from '../svg.js'; +import {invertFileFolding} from './file-fold.js'; +import {createTippy} from '../modules/tippy.js'; +import {clippie} from 'clippie'; +import {toAbsoluteUrl} from '../utils.js'; + +export const singleAnchorRegex = /^#(L|n)([1-9][0-9]*)$/; +export const rangeAnchorRegex = /^#(L[1-9][0-9]*)-(L[1-9][0-9]*)$/; + +function changeHash(hash) { + if (window.history.pushState) { + window.history.pushState(null, null, hash); + } else { + window.location.hash = hash; + } +} + +function isBlame() { + return Boolean(document.querySelector('div.blame')); +} + +function getLineEls() { + return document.querySelectorAll(`.code-view td.lines-code${isBlame() ? '.blame-code' : ''}`); +} + +function selectRange($linesEls, $selectionEndEl, $selectionStartEls) { + for (const el of $linesEls) { + el.closest('tr').classList.remove('active'); + } + + // add hashchange to permalink + const refInNewIssue = document.querySelector('a.ref-in-new-issue'); + const copyPermalink = document.querySelector('a.copy-line-permalink'); + const viewGitBlame = document.querySelector('a.view_git_blame'); + + const updateIssueHref = function (anchor) { + if (!refInNewIssue) return; + const urlIssueNew = refInNewIssue.getAttribute('data-url-issue-new'); + const urlParamBodyLink = refInNewIssue.getAttribute('data-url-param-body-link'); + const issueContent = `${toAbsoluteUrl(urlParamBodyLink)}#${anchor}`; // the default content for issue body + refInNewIssue.setAttribute('href', `${urlIssueNew}?body=${encodeURIComponent(issueContent)}`); + }; + + const updateViewGitBlameFragment = function (anchor) { + if (!viewGitBlame) return; + let href = viewGitBlame.getAttribute('href'); + href = `${href.replace(/#L\d+$|#L\d+-L\d+$/, '')}`; + if (anchor.length !== 0) { + href = `${href}#${anchor}`; + } + viewGitBlame.setAttribute('href', href); + }; + + const updateCopyPermalinkUrl = function (anchor) { + if (!copyPermalink) return; + let link = copyPermalink.getAttribute('data-url'); + link = `${link.replace(/#L\d+$|#L\d+-L\d+$/, '')}#${anchor}`; + copyPermalink.setAttribute('data-url', link); + }; + + if ($selectionStartEls) { + let a = parseInt($selectionEndEl[0].getAttribute('rel').slice(1)); + let b = parseInt($selectionStartEls[0].getAttribute('rel').slice(1)); + let c; + if (a !== b) { + if (a > b) { + c = a; + a = b; + b = c; + } + const classes = []; + for (let i = a; i <= b; i++) { + classes.push(`[rel=L${i}]`); + } + $linesEls.filter(classes.join(',')).each(function () { + this.closest('tr').classList.add('active'); + }); + changeHash(`#L${a}-L${b}`); + + updateIssueHref(`L${a}-L${b}`); + updateViewGitBlameFragment(`L${a}-L${b}`); + updateCopyPermalinkUrl(`L${a}-L${b}`); + return; + } + } + $selectionEndEl[0].closest('tr').classList.add('active'); + changeHash(`#${$selectionEndEl[0].getAttribute('rel')}`); + + updateIssueHref($selectionEndEl[0].getAttribute('rel')); + updateViewGitBlameFragment($selectionEndEl[0].getAttribute('rel')); + updateCopyPermalinkUrl($selectionEndEl[0].getAttribute('rel')); +} + +function showLineButton() { + const menu = document.querySelector('.code-line-menu'); + if (!menu) return; + + // remove all other line buttons + for (const el of document.querySelectorAll('.code-line-button')) { + el.remove(); + } + + // find active row and add button + const tr = document.querySelector('.code-view tr.active'); + const td = tr.querySelector('td.lines-num'); + const btn = document.createElement('button'); + btn.classList.add('code-line-button', 'ui', 'basic', 'button'); + btn.innerHTML = svg('octicon-kebab-horizontal'); + td.prepend(btn); + + // put a copy of the menu back into DOM for the next click + btn.closest('.code-view').append(menu.cloneNode(true)); + + createTippy(btn, { + trigger: 'click', + hideOnClick: true, + content: menu, + placement: 'right-start', + interactive: true, + onShow: (tippy) => { + tippy.popper.addEventListener('click', () => { + tippy.hide(); + }, {once: true}); + }, + }); +} + +export function initRepoCodeView() { + if ($('.code-view .lines-num').length > 0) { + $(document).on('click', '.lines-num span', function (e) { + const linesEls = getLineEls(); + const selectedEls = Array.from(linesEls).filter((el) => { + return el.matches(`[rel=${this.getAttribute('id')}]`); + }); + + let from; + if (e.shiftKey) { + from = Array.from(linesEls).filter((el) => { + return el.closest('tr').classList.contains('active'); + }); + } + selectRange($(linesEls), $(selectedEls), from ? $(from) : null); + + if (window.getSelection) { + window.getSelection().removeAllRanges(); + } else { + document.selection.empty(); + } + + showLineButton(); + }); + + $(window).on('hashchange', () => { + let m = window.location.hash.match(rangeAnchorRegex); + const $linesEls = $(getLineEls()); + let $first; + if (m) { + $first = $linesEls.filter(`[rel=${m[1]}]`); + if ($first.length) { + const $last = $linesEls.filter(`[rel=${m[2]}]`); + selectRange($linesEls, $first, $last.length ? $last : $linesEls.last()); + + // show code view menu marker (don't show in blame page) + if (!isBlame()) { + showLineButton(); + } + + $('html, body').scrollTop($first.offset().top - 200); + return; + } + } + m = window.location.hash.match(singleAnchorRegex); + if (m) { + $first = $linesEls.filter(`[rel=L${m[2]}]`); + if ($first.length) { + selectRange($linesEls, $first); + + // show code view menu marker (don't show in blame page) + if (!isBlame()) { + showLineButton(); + } + + $('html, body').scrollTop($first.offset().top - 200); + } + } + }).trigger('hashchange'); + } + $(document).on('click', '.fold-file', ({currentTarget}) => { + invertFileFolding(currentTarget.closest('.file-content'), currentTarget); + }); + $(document).on('click', '.copy-line-permalink', async ({currentTarget}) => { + await clippie(toAbsoluteUrl(currentTarget.getAttribute('data-url'))); + }); +} diff --git a/web_src/js/features/repo-code.test.js b/web_src/js/features/repo-code.test.js new file mode 100644 index 0000000..0e0062a --- /dev/null +++ b/web_src/js/features/repo-code.test.js @@ -0,0 +1,17 @@ +import {singleAnchorRegex, rangeAnchorRegex} from './repo-code.js'; + +test('singleAnchorRegex', () => { + expect(singleAnchorRegex.test('#L0')).toEqual(false); + expect(singleAnchorRegex.test('#L1')).toEqual(true); + expect(singleAnchorRegex.test('#L01')).toEqual(false); + expect(singleAnchorRegex.test('#n0')).toEqual(false); + expect(singleAnchorRegex.test('#n1')).toEqual(true); + expect(singleAnchorRegex.test('#n01')).toEqual(false); +}); + +test('rangeAnchorRegex', () => { + expect(rangeAnchorRegex.test('#L0-L10')).toEqual(false); + expect(rangeAnchorRegex.test('#L1-L10')).toEqual(true); + expect(rangeAnchorRegex.test('#L01-L10')).toEqual(false); + expect(rangeAnchorRegex.test('#L1-L01')).toEqual(false); +}); diff --git a/web_src/js/features/repo-commit.js b/web_src/js/features/repo-commit.js new file mode 100644 index 0000000..f61ea08 --- /dev/null +++ b/web_src/js/features/repo-commit.js @@ -0,0 +1,27 @@ +import {createTippy} from '../modules/tippy.js'; +import {toggleElem} from '../utils/dom.js'; + +export function initRepoEllipsisButton() { + for (const button of document.querySelectorAll('.js-toggle-commit-body')) { + button.addEventListener('click', function (e) { + e.preventDefault(); + const expanded = this.getAttribute('aria-expanded') === 'true'; + toggleElem(this.parentElement.querySelector('.commit-body')); + this.setAttribute('aria-expanded', String(!expanded)); + }); + } +} + +export function initCommitStatuses() { + for (const element of document.querySelectorAll('[data-tippy="commit-statuses"]')) { + const top = document.querySelector('.repository.file.list') || document.querySelector('.repository.diff'); + + createTippy(element, { + content: element.nextElementSibling, + placement: top ? 'top-start' : 'bottom-start', + interactive: true, + role: 'dialog', + theme: 'box-with-header', + }); + } +} diff --git a/web_src/js/features/repo-common.js b/web_src/js/features/repo-common.js new file mode 100644 index 0000000..88aa93d --- /dev/null +++ b/web_src/js/features/repo-common.js @@ -0,0 +1,83 @@ +import $ from 'jquery'; +import {hideElem, queryElems, showElem} from '../utils/dom.js'; +import {POST} from '../modules/fetch.js'; +import {showErrorToast} from '../modules/toast.js'; +import {sleep} from '../utils.js'; + +async function onDownloadArchive(e) { + e.preventDefault(); + // there are many places using the "archive-link", eg: the dropdown on the repo code page, the release list + const el = e.target.closest('a.archive-link[href]'); + const targetLoading = el.closest('.ui.dropdown') ?? el; + targetLoading.classList.add('is-loading', 'loading-icon-2px'); + try { + for (let tryCount = 0; ;tryCount++) { + const response = await POST(el.href); + if (!response.ok) throw new Error(`Invalid server response: ${response.status}`); + + const data = await response.json(); + if (data.complete) break; + await sleep(Math.min((tryCount + 1) * 750, 2000)); + } + window.location.href = el.href; // the archive is ready, start real downloading + } catch (e) { + console.error(e); + showErrorToast(`Failed to download the archive: ${e}`, {duration: 2500}); + } finally { + targetLoading.classList.remove('is-loading', 'loading-icon-2px'); + } +} + +export function initRepoArchiveLinks() { + queryElems('a.archive-link[href]', (el) => el.addEventListener('click', onDownloadArchive)); +} + +export function initRepoCloneLink() { + const $repoCloneSsh = $('#repo-clone-ssh'); + const $repoCloneHttps = $('#repo-clone-https'); + const $inputLink = $('#repo-clone-url'); + + if ((!$repoCloneSsh.length && !$repoCloneHttps.length) || !$inputLink.length) { + return; + } + + $repoCloneSsh.on('click', () => { + localStorage.setItem('repo-clone-protocol', 'ssh'); + window.updateCloneStates(); + }); + $repoCloneHttps.on('click', () => { + localStorage.setItem('repo-clone-protocol', 'https'); + window.updateCloneStates(); + }); + + $inputLink.on('focus', () => { + $inputLink.trigger('select'); + }); +} + +export function initRepoCommonBranchOrTagDropdown(selector) { + $(selector).each(function () { + const $dropdown = $(this); + $dropdown.find('.reference.column').on('click', function () { + hideElem($dropdown.find('.scrolling.reference-list-menu')); + showElem($($(this).data('target'))); + return false; + }); + }); +} + +export function initRepoCommonFilterSearchDropdown(selector) { + const $dropdown = $(selector); + if (!$dropdown.length) return; + + $dropdown.dropdown({ + fullTextSearch: 'exact', + selectOnKeydown: false, + onChange(_text, _value, $choice) { + if ($choice[0].getAttribute('data-url')) { + window.location.href = $choice[0].getAttribute('data-url'); + } + }, + message: {noResults: $dropdown[0].getAttribute('data-no-results')}, + }); +} diff --git a/web_src/js/features/repo-diff-commit.js b/web_src/js/features/repo-diff-commit.js new file mode 100644 index 0000000..aa7fc38 --- /dev/null +++ b/web_src/js/features/repo-diff-commit.js @@ -0,0 +1,53 @@ +import {hideElem, showElem, toggleElem} from '../utils/dom.js'; +import {GET} from '../modules/fetch.js'; + +async function loadBranchesAndTags(area, loadingButton) { + loadingButton.classList.add('disabled'); + try { + const res = await GET(loadingButton.getAttribute('data-fetch-url')); + const data = await res.json(); + hideElem(loadingButton); + addTags(area, data.tags); + addBranches(area, data.branches, data.default_branch); + showElem(area.querySelectorAll('.branch-and-tag-detail')); + } finally { + loadingButton.classList.remove('disabled'); + } +} + +function addTags(area, tags) { + const tagArea = area.querySelector('.tag-area'); + toggleElem(tagArea.parentElement, tags.length > 0); + for (const tag of tags) { + addLink(tagArea, tag.web_link, tag.name); + } +} + +function addBranches(area, branches, defaultBranch) { + const defaultBranchTooltip = area.getAttribute('data-text-default-branch-tooltip'); + const branchArea = area.querySelector('.branch-area'); + toggleElem(branchArea.parentElement, branches.length > 0); + for (const branch of branches) { + const tooltip = defaultBranch === branch.name ? defaultBranchTooltip : null; + addLink(branchArea, branch.web_link, branch.name, tooltip); + } +} + +function addLink(parent, href, text, tooltip) { + const link = document.createElement('a'); + link.classList.add('muted', 'tw-px-1'); + link.href = href; + link.textContent = text; + if (tooltip) { + link.classList.add('tw-border', 'tw-border-secondary', 'tw-rounded'); + link.setAttribute('data-tooltip-content', tooltip); + } + parent.append(link); +} + +export function initRepoDiffCommitBranchesAndTags() { + for (const area of document.querySelectorAll('.branch-and-tag-area')) { + const btn = area.querySelector('.load-branches-and-tags'); + btn.addEventListener('click', () => loadBranchesAndTags(area, btn)); + } +} diff --git a/web_src/js/features/repo-diff-commitselect.js b/web_src/js/features/repo-diff-commitselect.js new file mode 100644 index 0000000..ebac64e --- /dev/null +++ b/web_src/js/features/repo-diff-commitselect.js @@ -0,0 +1,10 @@ +import {createApp} from 'vue'; +import DiffCommitSelector from '../components/DiffCommitSelector.vue'; + +export function initDiffCommitSelect() { + const el = document.getElementById('diff-commit-select'); + if (!el) return; + + const commitSelect = createApp(DiffCommitSelector); + commitSelect.mount(el); +} diff --git a/web_src/js/features/repo-diff-filetree.js b/web_src/js/features/repo-diff-filetree.js new file mode 100644 index 0000000..5dd2c42 --- /dev/null +++ b/web_src/js/features/repo-diff-filetree.js @@ -0,0 +1,17 @@ +import {createApp} from 'vue'; +import DiffFileTree from '../components/DiffFileTree.vue'; +import DiffFileList from '../components/DiffFileList.vue'; + +export function initDiffFileTree() { + const el = document.getElementById('diff-file-tree'); + if (!el) return; + + const fileTreeView = createApp(DiffFileTree); + fileTreeView.mount(el); + + const fileListElement = document.getElementById('diff-file-list'); + if (!fileListElement) return; + + const fileListView = createApp(DiffFileList); + fileListView.mount(fileListElement); +} diff --git a/web_src/js/features/repo-diff.js b/web_src/js/features/repo-diff.js new file mode 100644 index 0000000..e5723af --- /dev/null +++ b/web_src/js/features/repo-diff.js @@ -0,0 +1,232 @@ +import $ from 'jquery'; +import {initCompReactionSelector} from './comp/ReactionSelector.js'; +import {initRepoIssueContentHistory} from './repo-issue-content.js'; +import {initDiffFileTree} from './repo-diff-filetree.js'; +import {initDiffCommitSelect} from './repo-diff-commitselect.js'; +import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.js'; +import {initViewedCheckboxListenerFor, countAndUpdateViewedFiles, initExpandAndCollapseFilesButton} from './pull-view-file.js'; +import {initImageDiff} from './imagediff.js'; +import {showErrorToast} from '../modules/toast.js'; +import {submitEventSubmitter, queryElemSiblings, hideElem, showElem} from '../utils/dom.js'; +import {POST, GET} from '../modules/fetch.js'; + +const {pageData, i18n} = window.config; + +function initRepoDiffReviewButton() { + const reviewBox = document.getElementById('review-box'); + if (!reviewBox) return; + + const counter = reviewBox.querySelector('.review-comments-counter'); + if (!counter) return; + + $(document).on('click', 'button[name="pending_review"]', (e) => { + const $form = $(e.target).closest('form'); + // Watch for the form's submit event. + $form.on('submit', () => { + const num = parseInt(counter.getAttribute('data-pending-comment-number')) + 1 || 1; + counter.setAttribute('data-pending-comment-number', num); + counter.textContent = num; + + reviewBox.classList.remove('pulse'); + requestAnimationFrame(() => { + reviewBox.classList.add('pulse'); + }); + }); + }); +} + +function initRepoDiffFileViewToggle() { + $('.file-view-toggle').on('click', function () { + for (const el of queryElemSiblings(this)) { + el.classList.remove('active'); + } + this.classList.add('active'); + + const target = document.querySelector(this.getAttribute('data-toggle-selector')); + if (!target) return; + + hideElem(queryElemSiblings(target)); + showElem(target); + }); +} + +function initRepoDiffConversationForm() { + $(document).on('submit', '.conversation-holder form', async (e) => { + e.preventDefault(); + + const $form = $(e.target); + const textArea = e.target.querySelector('textarea'); + if (!validateTextareaNonEmpty(textArea)) { + return; + } + + if (e.target.classList.contains('is-loading')) return; + try { + e.target.classList.add('is-loading'); + const formData = new FormData($form[0]); + + // If the form is submitted by a button, append the button's name and value to the form data. + // originalEvent can be undefined, such as an event that's caused by Ctrl+Enter, in that case + // sent the event itself. + const submitter = submitEventSubmitter(e.originalEvent ?? e); + const isSubmittedByButton = (submitter?.nodeName === 'BUTTON') || (submitter?.nodeName === 'INPUT' && submitter.type === 'submit'); + if (isSubmittedByButton && submitter.name) { + formData.append(submitter.name, submitter.value); + } + + const response = await POST(e.target.getAttribute('action'), {data: formData}); + const $newConversationHolder = $(await response.text()); + const {path, side, idx} = $newConversationHolder.data(); + + $form.closest('.conversation-holder').replaceWith($newConversationHolder); + let selector; + if ($form.closest('tr').data('line-type') === 'same') { + selector = `[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`; + } else { + selector = `[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`; + } + for (const el of document.querySelectorAll(selector)) { + el.classList.add('tw-invisible'); + } + $newConversationHolder.find('.dropdown').dropdown(); + initCompReactionSelector($newConversationHolder); + } catch { // here the caught error might be a jQuery AJAX error (thrown by await $.post), which is not good to use for error message handling + console.error('error when submitting conversation', e); + showErrorToast(i18n.network_error); + } finally { + e.target.classList.remove('is-loading'); + } + }); + + $(document).on('click', '.resolve-conversation', async function (e) { + e.preventDefault(); + const comment_id = $(this).data('comment-id'); + const origin = $(this).data('origin'); + const action = $(this).data('action'); + const url = $(this).data('update-url'); + + try { + const response = await POST(url, {data: new URLSearchParams({origin, action, comment_id})}); + const data = await response.text(); + + if ($(this).closest('.conversation-holder').length) { + const $conversation = $(data); + $(this).closest('.conversation-holder').replaceWith($conversation); + $conversation.find('.dropdown').dropdown(); + initCompReactionSelector($conversation); + } else { + window.location.reload(); + } + } catch (error) { + console.error('Error:', error); + } + }); +} + +export function initRepoDiffConversationNav() { + // Previous/Next code review conversation + $(document).on('click', '.previous-conversation', (e) => { + const $conversation = $(e.currentTarget).closest('.comment-code-cloud'); + const $conversations = $('.comment-code-cloud:not(.tw-hidden)'); + const index = $conversations.index($conversation); + const previousIndex = index > 0 ? index - 1 : $conversations.length - 1; + const $previousConversation = $conversations.eq(previousIndex); + const anchor = $previousConversation.find('.comment').first()[0].getAttribute('id'); + window.location.href = `#${anchor}`; + }); + $(document).on('click', '.next-conversation', (e) => { + const $conversation = $(e.currentTarget).closest('.comment-code-cloud'); + const $conversations = $('.comment-code-cloud:not(.tw-hidden)'); + const index = $conversations.index($conversation); + const nextIndex = index < $conversations.length - 1 ? index + 1 : 0; + const $nextConversation = $conversations.eq(nextIndex); + const anchor = $nextConversation.find('.comment').first()[0].getAttribute('id'); + window.location.href = `#${anchor}`; + }); +} + +// Will be called when the show more (files) button has been pressed +function onShowMoreFiles() { + initRepoIssueContentHistory(); + initViewedCheckboxListenerFor(); + countAndUpdateViewedFiles(); + initImageDiff(); +} + +export async function loadMoreFiles(url) { + const target = document.querySelector('a#diff-show-more-files'); + if (target?.classList.contains('disabled') || pageData.diffFileInfo.isLoadingNewData) { + return; + } + + pageData.diffFileInfo.isLoadingNewData = true; + target?.classList.add('disabled'); + + try { + const response = await GET(url); + const resp = await response.text(); + const $resp = $(resp); + // the response is a full HTML page, we need to extract the relevant contents: + // 1. append the newly loaded file list items to the existing list + $('#diff-incomplete').replaceWith($resp.find('#diff-file-boxes').children()); + // 2. re-execute the script to append the newly loaded items to the JS variables to refresh the DiffFileTree + $('body').append($resp.find('script#diff-data-script')); + + onShowMoreFiles(); + } catch (error) { + console.error('Error:', error); + showErrorToast('An error occurred while loading more files.'); + } finally { + target?.classList.remove('disabled'); + pageData.diffFileInfo.isLoadingNewData = false; + } +} + +function initRepoDiffShowMore() { + $(document).on('click', 'a#diff-show-more-files', (e) => { + e.preventDefault(); + + const linkLoadMore = e.target.getAttribute('data-href'); + loadMoreFiles(linkLoadMore); + }); + + $(document).on('click', 'a.diff-load-button', async (e) => { + e.preventDefault(); + const $target = $(e.target); + + if (e.target.classList.contains('disabled')) { + return; + } + + e.target.classList.add('disabled'); + + const url = $target.data('href'); + + try { + const response = await GET(url); + const resp = await response.text(); + + if (!resp) { + return; + } + $target.parent().replaceWith($(resp).find('#diff-file-boxes .diff-file-body .file-body').children()); + onShowMoreFiles(); + } catch (error) { + console.error('Error:', error); + } finally { + e.target.classList.remove('disabled'); + } + }); +} + +export function initRepoDiffView() { + initRepoDiffConversationForm(); + if (!$('#diff-file-list').length) return; + initDiffFileTree(); + initDiffCommitSelect(); + initRepoDiffShowMore(); + initRepoDiffReviewButton(); + initRepoDiffFileViewToggle(); + initViewedCheckboxListenerFor(); + initExpandAndCollapseFilesButton(); +} diff --git a/web_src/js/features/repo-editor.js b/web_src/js/features/repo-editor.js new file mode 100644 index 0000000..faf8ba1 --- /dev/null +++ b/web_src/js/features/repo-editor.js @@ -0,0 +1,203 @@ +import $ from 'jquery'; +import {htmlEscape} from 'escape-goat'; +import {createCodeEditor} from './codeeditor.js'; +import {hideElem, showElem, createElementFromHTML} from '../utils/dom.js'; +import {initMarkupContent} from '../markup/content.js'; +import {attachRefIssueContextPopup} from './contextpopup.js'; +import {POST} from '../modules/fetch.js'; + +function initEditPreviewTab($form) { + const $tabMenu = $form.find('.tabular.menu'); + $tabMenu.find('.item').tab(); + const $previewTab = $tabMenu.find( + `.item[data-tab="${$tabMenu.data('preview')}"]`, + ); + if ($previewTab.length) { + $previewTab.on('click', async function () { + const $this = $(this); + let context = `${$this.data('context')}/`; + const mode = $this.data('markup-mode') || 'comment'; + const $treePathEl = $form.find('input#tree_path'); + if ($treePathEl.length > 0) { + context += $treePathEl.val(); + } + context = context.substring(0, context.lastIndexOf('/')); + + const formData = new FormData(); + formData.append('mode', mode); + formData.append('context', context); + formData.append( + 'text', + $form.find(`.tab[data-tab="${$tabMenu.data('write')}"] textarea`).val(), + ); + formData.append('file_path', $treePathEl.val()); + try { + const response = await POST($this.data('url'), {data: formData}); + const data = await response.text(); + const $previewPanel = $form.find( + `.tab[data-tab="${$tabMenu.data('preview')}"]`, + ); + renderPreviewPanelContent($previewPanel, data); + } catch (error) { + console.error('Error:', error); + } + }); + } +} + +function initEditorForm() { + const $form = $('.repository .edit.form'); + if (!$form) return; + initEditPreviewTab($form); +} + +function getCursorPosition($e) { + const el = $e.get(0); + let pos = 0; + if ('selectionStart' in el) { + pos = el.selectionStart; + } else if ('selection' in document) { + el.focus(); + const Sel = document.selection.createRange(); + const SelLength = document.selection.createRange().text.length; + Sel.moveStart('character', -el.value.length); + pos = Sel.text.length - SelLength; + } + return pos; +} + +export function initRepoEditor() { + initEditorForm(); + + $('.js-quick-pull-choice-option').on('change', function () { + if ($(this).val() === 'commit-to-new-branch') { + showElem('.quick-pull-branch-name'); + document.querySelector('.quick-pull-branch-name input').required = true; + } else { + hideElem('.quick-pull-branch-name'); + document.querySelector('.quick-pull-branch-name input').required = false; + } + $('#commit-button').text(this.getAttribute('button_text')); + }); + + const joinTreePath = ($fileNameEl) => { + const parts = []; + $('.breadcrumb span.section').each(function () { + const $element = $(this); + if ($element.find('a').length) { + parts.push($element.find('a').text()); + } else { + parts.push($element.text()); + } + }); + if ($fileNameEl.val()) parts.push($fileNameEl.val()); + $('#tree_path').val(parts.join('/')); + }; + + const $editFilename = $('#file-name'); + $editFilename.on('input', function () { + const parts = $(this).val().split('/'); + + if (parts.length > 1) { + for (let i = 0; i < parts.length; ++i) { + const value = parts[i]; + if (i < parts.length - 1) { + if (value.length) { + $editFilename[0].before( + createElementFromHTML( + `<span class="section"><a href="#">${htmlEscape(value)}</a></span>`, + ), + ); + $editFilename[0].before( + createElementFromHTML(`<div class="breadcrumb-divider">/</div>`), + ); + } + } else { + $(this).val(value); + } + this.setSelectionRange(0, 0); + } + } + + joinTreePath($(this)); + }); + + $editFilename.on('keydown', function (e) { + const $section = $('.breadcrumb span.section'); + + // Jump back to last directory once the filename is empty + if ( + e.code === 'Backspace' && + getCursorPosition($(this)) === 0 && + $section.length > 0 + ) { + e.preventDefault(); + const $divider = $('.breadcrumb .breadcrumb-divider'); + const value = $section.last().find('a').text(); + $(this).val(value + $(this).val()); + this.setSelectionRange(value.length, value.length); + $section.last().remove(); + $divider.last().remove(); + joinTreePath($(this)); + } + }); + + const $editArea = $('.repository.editor textarea#edit_area'); + if (!$editArea.length) return; + + (async () => { + const editor = await createCodeEditor($editArea[0], $editFilename[0]); + + // Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage + // to enable or disable the commit button + const commitButton = document.getElementById('commit-button'); + const $editForm = $('.ui.edit.form'); + const dirtyFileClass = 'dirty-file'; + + // Disabling the button at the start + if ($('input[name="page_has_posted"]').val() !== 'true') { + commitButton.disabled = true; + } + + // Registering a custom listener for the file path and the file content + $editForm.areYouSure({ + silent: true, + dirtyClass: dirtyFileClass, + fieldSelector: ':input:not(.commit-form-wrapper :input)', + change($form) { + const dirty = $form[0]?.classList.contains(dirtyFileClass); + commitButton.disabled = !dirty; + }, + }); + + // Update the editor from query params, if available, + // only after the dirtyFileClass initialization + const params = new URLSearchParams(window.location.search); + const value = params.get('value'); + if (value) { + editor.setValue(value); + } + + commitButton?.addEventListener('click', (e) => { + // A modal which asks if an empty file should be committed + if (!$editArea.val()) { + $('#edit-empty-content-modal') + .modal({ + onApprove() { + $('.edit.form').trigger('submit'); + }, + }) + .modal('show'); + e.preventDefault(); + } + }); + })(); +} + +export function renderPreviewPanelContent($panelPreviewer, data) { + $panelPreviewer.html(data); + initMarkupContent(); + + const $refIssues = $panelPreviewer.find('p .ref-issue'); + attachRefIssueContextPopup($refIssues); +} diff --git a/web_src/js/features/repo-findfile.js b/web_src/js/features/repo-findfile.js new file mode 100644 index 0000000..cff5068 --- /dev/null +++ b/web_src/js/features/repo-findfile.js @@ -0,0 +1,117 @@ +import {svg} from '../svg.js'; +import {toggleElem} from '../utils/dom.js'; +import {pathEscapeSegments} from '../utils/url.js'; +import {GET} from '../modules/fetch.js'; + +const threshold = 50; +let files = []; +let repoFindFileInput, repoFindFileTableBody, repoFindFileNoResult; + +// return the case-insensitive sub-match result as an array: [unmatched, matched, unmatched, matched, ...] +// res[even] is unmatched, res[odd] is matched, see unit tests for examples +// argument subLower must be a lower-cased string. +export function strSubMatch(full, subLower) { + const res = ['']; + let i = 0, j = 0; + const fullLower = full.toLowerCase(); + while (i < subLower.length && j < fullLower.length) { + if (subLower[i] === fullLower[j]) { + if (res.length % 2 !== 0) res.push(''); + res[res.length - 1] += full[j]; + j++; + i++; + } else { + if (res.length % 2 === 0) res.push(''); + res[res.length - 1] += full[j]; + j++; + } + } + if (i !== subLower.length) { + // if the sub string doesn't match the full, only return the full as unmatched. + return [full]; + } + if (j < full.length) { + // append remaining chars from full to result as unmatched + if (res.length % 2 === 0) res.push(''); + res[res.length - 1] += full.substring(j); + } + return res; +} + +export function calcMatchedWeight(matchResult) { + let weight = 0; + for (let i = 0; i < matchResult.length; i++) { + if (i % 2 === 1) { // matches are on odd indices, see strSubMatch + // use a function f(x+x) > f(x) + f(x) to make the longer matched string has higher weight. + weight += matchResult[i].length * matchResult[i].length; + } + } + return weight; +} + +export function filterRepoFilesWeighted(files, filter) { + let filterResult = []; + if (filter) { + const filterLower = filter.toLowerCase(); + // TODO: for large repo, this loop could be slow, maybe there could be one more limit: + // ... && filterResult.length < threshold * 20, wait for more feedbacks + for (let i = 0; i < files.length; i++) { + const res = strSubMatch(files[i], filterLower); + if (res.length > 1) { // length==1 means unmatched, >1 means having matched sub strings + filterResult.push({matchResult: res, matchWeight: calcMatchedWeight(res)}); + } + } + filterResult.sort((a, b) => b.matchWeight - a.matchWeight); + filterResult = filterResult.slice(0, threshold); + } else { + for (let i = 0; i < files.length && i < threshold; i++) { + filterResult.push({matchResult: [files[i]], matchWeight: 0}); + } + } + return filterResult; +} + +function filterRepoFiles(filter) { + const treeLink = repoFindFileInput.getAttribute('data-url-tree-link'); + repoFindFileTableBody.innerHTML = ''; + + const filterResult = filterRepoFilesWeighted(files, filter); + + toggleElem(repoFindFileNoResult, !filterResult.length); + for (const r of filterResult) { + const row = document.createElement('tr'); + const cell = document.createElement('td'); + const a = document.createElement('a'); + a.setAttribute('href', `${treeLink}/${pathEscapeSegments(r.matchResult.join(''))}`); + a.innerHTML = svg('octicon-file', 16, 'tw-mr-2'); + row.append(cell); + cell.append(a); + for (const [index, part] of r.matchResult.entries()) { + const span = document.createElement('span'); + // safely escape by using textContent + span.textContent = part; + // if the target file path is "abc/xyz", to search "bx", then the matchResult is ['a', 'b', 'c/', 'x', 'yz'] + // the matchResult[odd] is matched and highlighted to red. + if (index % 2 === 1) span.classList.add('ui', 'text', 'red'); + a.append(span); + } + repoFindFileTableBody.append(row); + } +} + +async function loadRepoFiles() { + const response = await GET(repoFindFileInput.getAttribute('data-url-data-link')); + files = await response.json(); + filterRepoFiles(repoFindFileInput.value); +} + +export function initFindFileInRepo() { + repoFindFileInput = document.getElementById('repo-file-find-input'); + if (!repoFindFileInput) return; + + repoFindFileTableBody = document.querySelector('#repo-find-file-table tbody'); + repoFindFileNoResult = document.getElementById('repo-find-file-no-result'); + repoFindFileInput.addEventListener('input', () => filterRepoFiles(repoFindFileInput.value)); + + loadRepoFiles(); +} diff --git a/web_src/js/features/repo-findfile.test.js b/web_src/js/features/repo-findfile.test.js new file mode 100644 index 0000000..2d96ed4 --- /dev/null +++ b/web_src/js/features/repo-findfile.test.js @@ -0,0 +1,34 @@ +import {strSubMatch, calcMatchedWeight, filterRepoFilesWeighted} from './repo-findfile.js'; + +describe('Repo Find Files', () => { + test('strSubMatch', () => { + expect(strSubMatch('abc', '')).toEqual(['abc']); + expect(strSubMatch('abc', 'a')).toEqual(['', 'a', 'bc']); + expect(strSubMatch('abc', 'b')).toEqual(['a', 'b', 'c']); + expect(strSubMatch('abc', 'c')).toEqual(['ab', 'c']); + expect(strSubMatch('abc', 'ac')).toEqual(['', 'a', 'b', 'c']); + expect(strSubMatch('abc', 'z')).toEqual(['abc']); + expect(strSubMatch('abc', 'az')).toEqual(['abc']); + + expect(strSubMatch('ABc', 'ac')).toEqual(['', 'A', 'B', 'c']); + expect(strSubMatch('abC', 'ac')).toEqual(['', 'a', 'b', 'C']); + + expect(strSubMatch('aabbcc', 'abc')).toEqual(['', 'a', 'a', 'b', 'b', 'c', 'c']); + expect(strSubMatch('the/directory', 'hedir')).toEqual(['t', 'he', '/', 'dir', 'ectory']); + }); + + test('calcMatchedWeight', () => { + expect(calcMatchedWeight(['a', 'b', 'c', 'd']) < calcMatchedWeight(['a', 'bc', 'c'])).toBeTruthy(); + }); + + test('filterRepoFilesWeighted', () => { + // the first matched result should always be the "word.txt" + let res = filterRepoFilesWeighted(['word.txt', 'we-got-result.dat'], 'word'); + expect(res).toHaveLength(2); + expect(res[0].matchResult).toEqual(['', 'word', '.txt']); + + res = filterRepoFilesWeighted(['we-got-result.dat', 'word.txt'], 'word'); + expect(res).toHaveLength(2); + expect(res[0].matchResult).toEqual(['', 'word', '.txt']); + }); +}); diff --git a/web_src/js/features/repo-graph.js b/web_src/js/features/repo-graph.js new file mode 100644 index 0000000..689b6f1 --- /dev/null +++ b/web_src/js/features/repo-graph.js @@ -0,0 +1,155 @@ +import $ from 'jquery'; +import {hideElem, showElem} from '../utils/dom.js'; +import {GET} from '../modules/fetch.js'; + +export function initRepoGraphGit() { + const graphContainer = document.getElementById('git-graph-container'); + if (!graphContainer) return; + + document.getElementById('flow-color-monochrome')?.addEventListener('click', () => { + document.getElementById('flow-color-monochrome').classList.add('active'); + document.getElementById('flow-color-colored')?.classList.remove('active'); + graphContainer.classList.remove('colored'); + graphContainer.classList.add('monochrome'); + const params = new URLSearchParams(window.location.search); + params.set('mode', 'monochrome'); + const queryString = params.toString(); + if (queryString) { + window.history.replaceState({}, '', `?${queryString}`); + } else { + window.history.replaceState({}, '', window.location.pathname); + } + for (const link of document.querySelectorAll('.pagination a')) { + const href = link.getAttribute('href'); + if (!href) continue; + const url = new URL(href, window.location); + const params = url.searchParams; + params.set('mode', 'monochrome'); + url.search = `?${params.toString()}`; + link.setAttribute('href', url.href); + } + }); + + document.getElementById('flow-color-colored')?.addEventListener('click', () => { + document.getElementById('flow-color-colored').classList.add('active'); + document.getElementById('flow-color-monochrome')?.classList.remove('active'); + graphContainer.classList.add('colored'); + graphContainer.classList.remove('monochrome'); + for (const link of document.querySelectorAll('.pagination a')) { + const href = link.getAttribute('href'); + if (!href) continue; + const url = new URL(href, window.location); + const params = url.searchParams; + params.delete('mode'); + url.search = `?${params.toString()}`; + link.setAttribute('href', url.href); + } + const params = new URLSearchParams(window.location.search); + params.delete('mode'); + const queryString = params.toString(); + if (queryString) { + window.history.replaceState({}, '', `?${queryString}`); + } else { + window.history.replaceState({}, '', window.location.pathname); + } + }); + const url = new URL(window.location); + const params = url.searchParams; + const updateGraph = () => { + const queryString = params.toString(); + const ajaxUrl = new URL(url); + ajaxUrl.searchParams.set('div-only', 'true'); + window.history.replaceState({}, '', queryString ? `?${queryString}` : window.location.pathname); + document.getElementById('pagination').innerHTML = ''; + hideElem('#rel-container'); + hideElem('#rev-container'); + showElem('#loading-indicator'); + (async () => { + const response = await GET(String(ajaxUrl)); + const html = await response.text(); + const div = document.createElement('div'); + div.innerHTML = html; + document.getElementById('pagination').innerHTML = div.querySelector('#pagination').innerHTML; + document.getElementById('rel-container').innerHTML = div.querySelector('#rel-container').innerHTML; + document.getElementById('rev-container').innerHTML = div.querySelector('#rev-container').innerHTML; + hideElem('#loading-indicator'); + showElem('#rel-container'); + showElem('#rev-container'); + })(); + }; + const dropdownSelected = params.getAll('branch'); + if (params.has('hide-pr-refs') && params.get('hide-pr-refs') === 'true') { + dropdownSelected.splice(0, 0, '...flow-hide-pr-refs'); + } + + const flowSelectRefsDropdown = document.getElementById('flow-select-refs-dropdown'); + $(flowSelectRefsDropdown).dropdown('set selected', dropdownSelected); + $(flowSelectRefsDropdown).dropdown({ + clearable: true, + fullTextSeach: 'exact', + onRemove(toRemove) { + if (toRemove === '...flow-hide-pr-refs') { + params.delete('hide-pr-refs'); + } else { + const branches = params.getAll('branch'); + params.delete('branch'); + for (const branch of branches) { + if (branch !== toRemove) { + params.append('branch', branch); + } + } + } + updateGraph(); + }, + onAdd(toAdd) { + if (toAdd === '...flow-hide-pr-refs') { + params.set('hide-pr-refs', true); + } else { + params.append('branch', toAdd); + } + updateGraph(); + }, + }); + + graphContainer.addEventListener('mouseenter', (e) => { + if (e.target.matches('#rev-list li')) { + const flow = e.target.getAttribute('data-flow'); + if (flow === '0') return; + document.getElementById(`flow-${flow}`)?.classList.add('highlight'); + e.target.classList.add('hover'); + for (const item of document.querySelectorAll(`#rev-list li[data-flow='${flow}']`)) { + item.classList.add('highlight'); + } + } else if (e.target.matches('#rel-container .flow-group')) { + e.target.classList.add('highlight'); + const flow = e.target.getAttribute('data-flow'); + for (const item of document.querySelectorAll(`#rev-list li[data-flow='${flow}']`)) { + item.classList.add('highlight'); + } + } else if (e.target.matches('#rel-container .flow-commit')) { + const rev = e.target.getAttribute('data-rev'); + document.querySelector(`#rev-list li#commit-${rev}`)?.classList.add('hover'); + } + }); + + graphContainer.addEventListener('mouseleave', (e) => { + if (e.target.matches('#rev-list li')) { + const flow = e.target.getAttribute('data-flow'); + if (flow === '0') return; + document.getElementById(`flow-${flow}`)?.classList.remove('highlight'); + e.target.classList.remove('hover'); + for (const item of document.querySelectorAll(`#rev-list li[data-flow='${flow}']`)) { + item.classList.remove('highlight'); + } + } else if (e.target.matches('#rel-container .flow-group')) { + e.target.classList.remove('highlight'); + const flow = e.target.getAttribute('data-flow'); + for (const item of document.querySelectorAll(`#rev-list li[data-flow='${flow}']`)) { + item.classList.remove('highlight'); + } + } else if (e.target.matches('#rel-container .flow-commit')) { + const rev = e.target.getAttribute('data-rev'); + document.querySelector(`#rev-list li#commit-${rev}`)?.classList.remove('hover'); + } + }); +} diff --git a/web_src/js/features/repo-home.js b/web_src/js/features/repo-home.js new file mode 100644 index 0000000..6a5bce8 --- /dev/null +++ b/web_src/js/features/repo-home.js @@ -0,0 +1,147 @@ +import $ from 'jquery'; +import {stripTags} from '../utils.js'; +import {hideElem, queryElemChildren, showElem} from '../utils/dom.js'; +import {POST} from '../modules/fetch.js'; +import {showErrorToast} from '../modules/toast.js'; + +const {appSubUrl} = window.config; + +export function initRepoTopicBar() { + const mgrBtn = document.getElementById('manage_topic'); + if (!mgrBtn) return; + + const editDiv = document.getElementById('topic_edit'); + const viewDiv = document.getElementById('repo-topics'); + const topicDropdown = editDiv.querySelector('.ui.dropdown'); + let lastErrorToast; + + mgrBtn.addEventListener('click', () => { + hideElem(viewDiv); + showElem(editDiv); + topicDropdown.querySelector('input.search').focus(); + }); + + document.querySelector('#cancel_topic_edit').addEventListener('click', () => { + lastErrorToast?.hideToast(); + hideElem(editDiv); + showElem(viewDiv); + mgrBtn.focus(); + }); + + document.getElementById('save_topic').addEventListener('click', async (e) => { + lastErrorToast?.hideToast(); + const topics = editDiv.querySelector('input[name=topics]').value; + + const data = new FormData(); + data.append('topics', topics); + + const response = await POST(e.target.getAttribute('data-link'), {data}); + + if (response.ok) { + const responseData = await response.json(); + if (responseData.status === 'ok') { + queryElemChildren(viewDiv, '.repo-topic', (el) => el.remove()); + if (topics.length) { + const topicArray = topics.split(','); + topicArray.sort(); + for (const topic of topicArray) { + // it should match the code in repo/home.tmpl + const link = document.createElement('a'); + link.classList.add('repo-topic', 'ui', 'large', 'label'); + link.href = `${appSubUrl}/explore/repos?q=${encodeURIComponent(topic)}&topic=1`; + link.textContent = topic; + mgrBtn.parentNode.insertBefore(link, mgrBtn); // insert all new topics before manage button + } + } + hideElem(editDiv); + showElem(viewDiv); + } + } else if (response.status === 422) { + // how to test: input topic like " invalid topic " (with spaces), and select it from the list, then "Save" + const responseData = await response.json(); + lastErrorToast = showErrorToast(responseData.message, {duration: 5000}); + if (responseData.invalidTopics.length > 0) { + const {invalidTopics} = responseData; + const topicLabels = queryElemChildren(topicDropdown, 'a.ui.label'); + for (const [index, value] of topics.split(',').entries()) { + if (invalidTopics.includes(value)) { + topicLabels[index].classList.remove('green'); + topicLabels[index].classList.add('red'); + } + } + } + } + }); + + $(topicDropdown).dropdown({ + allowAdditions: true, + forceSelection: false, + fullTextSearch: 'exact', + fields: {name: 'description', value: 'data-value'}, + saveRemoteData: false, + label: { + transition: 'horizontal flip', + duration: 200, + variation: false, + }, + apiSettings: { + url: `${appSubUrl}/explore/topics/search?q={query}`, + throttle: 500, + cache: false, + onResponse(res) { + const formattedResponse = { + success: false, + results: [], + }; + const query = stripTags(this.urlData.query.trim()); + let found_query = false; + const current_topics = []; + for (const el of queryElemChildren(topicDropdown, 'a.ui.label.visible')) { + current_topics.push(el.getAttribute('data-value')); + } + + if (res.topics) { + let found = false; + for (let i = 0; i < res.topics.length; i++) { + // skip currently added tags + if (current_topics.includes(res.topics[i].topic_name)) { + continue; + } + + if (res.topics[i].topic_name.toLowerCase() === query.toLowerCase()) { + found_query = true; + } + formattedResponse.results.push({description: res.topics[i].topic_name, 'data-value': res.topics[i].topic_name}); + found = true; + } + formattedResponse.success = found; + } + + if (query.length > 0 && !found_query) { + formattedResponse.success = true; + formattedResponse.results.unshift({description: query, 'data-value': query}); + } else if (query.length > 0 && found_query) { + formattedResponse.results.sort((a, b) => { + if (a.description.toLowerCase() === query.toLowerCase()) return -1; + if (b.description.toLowerCase() === query.toLowerCase()) return 1; + if (a.description > b.description) return -1; + if (a.description < b.description) return 1; + return 0; + }); + } + + return formattedResponse; + }, + }, + onLabelCreate(value) { + value = value.toLowerCase().trim(); + this.attr('data-value', value).contents().first().replaceWith(value); + return $(this); + }, + onAdd(addedValue, _addedText, $addedChoice) { + addedValue = addedValue.toLowerCase().trim(); + $addedChoice[0].setAttribute('data-value', addedValue); + $addedChoice[0].setAttribute('data-text', addedValue); + }, + }); +} diff --git a/web_src/js/features/repo-issue-content.js b/web_src/js/features/repo-issue-content.js new file mode 100644 index 0000000..cef2f49 --- /dev/null +++ b/web_src/js/features/repo-issue-content.js @@ -0,0 +1,154 @@ +import $ from 'jquery'; +import {svg} from '../svg.js'; +import {showErrorToast} from '../modules/toast.js'; +import {GET, POST} from '../modules/fetch.js'; +import {showElem} from '../utils/dom.js'; + +const {appSubUrl} = window.config; +let i18nTextEdited; +let i18nTextOptions; +let i18nTextDeleteFromHistory; +let i18nTextDeleteFromHistoryConfirm; + +function showContentHistoryDetail(issueBaseUrl, commentId, historyId, itemTitleHtml) { + let $dialog = $('.content-history-detail-dialog'); + if ($dialog.length) return; + + $dialog = $(` +<div class="ui modal content-history-detail-dialog"> + ${svg('octicon-x', 16, 'close icon inside')} + <div class="header tw-flex tw-items-center tw-justify-between"> + <div>${itemTitleHtml}</div> + <div class="ui dropdown dialog-header-options tw-mr-8 tw-hidden"> + ${i18nTextOptions} + ${svg('octicon-triangle-down', 14, 'dropdown icon')} + <div class="menu"> + <div class="item red text" data-option-item="delete">${i18nTextDeleteFromHistory}</div> + </div> + </div> + </div> + <div class="comment-diff-data is-loading"></div> +</div>`); + $dialog.appendTo($('body')); + $dialog.find('.dialog-header-options').dropdown({ + showOnFocus: false, + allowReselection: true, + async onChange(_value, _text, $item) { + const optionItem = $item.data('option-item'); + if (optionItem === 'delete') { + if (window.confirm(i18nTextDeleteFromHistoryConfirm)) { + try { + const params = new URLSearchParams(); + params.append('comment_id', commentId); + params.append('history_id', historyId); + + const response = await POST(`${issueBaseUrl}/content-history/soft-delete?${params.toString()}`); + const resp = await response.json(); + + if (resp.ok) { + $dialog.modal('hide'); + } else { + showErrorToast(resp.message); + } + } catch (error) { + console.error('Error:', error); + showErrorToast('An error occurred while deleting the history.'); + } + } + } else { // required by eslint + showErrorToast(`unknown option item: ${optionItem}`); + } + }, + onHide() { + $(this).dropdown('clear', true); + }, + }); + $dialog.modal({ + async onShow() { + try { + const params = new URLSearchParams(); + params.append('comment_id', commentId); + params.append('history_id', historyId); + + const url = `${issueBaseUrl}/content-history/detail?${params.toString()}`; + const response = await GET(url); + const resp = await response.json(); + + const commentDiffData = $dialog.find('.comment-diff-data')[0]; + commentDiffData?.classList.remove('is-loading'); + commentDiffData.innerHTML = resp.diffHtml; + // there is only one option "item[data-option-item=delete]", so the dropdown can be entirely shown/hidden. + if (resp.canSoftDelete) { + showElem($dialog.find('.dialog-header-options')); + } + } catch (error) { + console.error('Error:', error); + } + }, + onHidden() { + $dialog.remove(); + }, + }).modal('show'); +} + +function showContentHistoryMenu(issueBaseUrl, $item, commentId) { + const $headerLeft = $item.find('.comment-header-left'); + const menuHtml = ` + <div class="ui dropdown interact-fg content-history-menu" data-comment-id="${commentId}"> + • ${i18nTextEdited}${svg('octicon-triangle-down', 14, 'dropdown icon')} + <div class="menu"> + </div> + </div>`; + + $headerLeft.find(`.content-history-menu`).remove(); + $headerLeft.append($(menuHtml)); + $headerLeft.find('.dropdown').dropdown({ + action: 'hide', + apiSettings: { + cache: false, + url: `${issueBaseUrl}/content-history/list?comment_id=${commentId}`, + }, + saveRemoteData: false, + onHide() { + $(this).dropdown('change values', null); + }, + onChange(value, itemHtml, $item) { + if (value && !$item.find('[data-history-is-deleted=1]').length) { + showContentHistoryDetail(issueBaseUrl, commentId, value, itemHtml); + } + }, + }); +} + +export async function initRepoIssueContentHistory() { + const issueIndex = $('#issueIndex').val(); + if (!issueIndex) return; + + const $itemIssue = $('.repository.issue .timeline-item.comment.first'); // issue(PR) main content + const $comments = $('.repository.issue .comment-list .comment'); // includes: issue(PR) comments, review comments, code comments + if (!$itemIssue.length && !$comments.length) return; + + const repoLink = $('#repolink').val(); + const issueBaseUrl = `${appSubUrl}/${repoLink}/issues/${issueIndex}`; + + try { + const response = await GET(`${issueBaseUrl}/content-history/overview`); + const resp = await response.json(); + + i18nTextEdited = resp.i18n.textEdited; + i18nTextDeleteFromHistory = resp.i18n.textDeleteFromHistory; + i18nTextDeleteFromHistoryConfirm = resp.i18n.textDeleteFromHistoryConfirm; + i18nTextOptions = resp.i18n.textOptions; + + if (resp.editedHistoryCountMap[0] && $itemIssue.length) { + showContentHistoryMenu(issueBaseUrl, $itemIssue, '0'); + } + for (const [commentId, _editedCount] of Object.entries(resp.editedHistoryCountMap)) { + if (commentId === '0') continue; + const $itemComment = $(`#issuecomment-${commentId}`); + showContentHistoryMenu(issueBaseUrl, $itemComment, commentId); + } + } catch (error) { + console.error('Error:', error); + } +} diff --git a/web_src/js/features/repo-issue-list.js b/web_src/js/features/repo-issue-list.js new file mode 100644 index 0000000..92f058c --- /dev/null +++ b/web_src/js/features/repo-issue-list.js @@ -0,0 +1,245 @@ +import $ from 'jquery'; +import {updateIssuesMeta} from './repo-issue.js'; +import {toggleElem, hideElem, isElemHidden} from '../utils/dom.js'; +import {htmlEscape} from 'escape-goat'; +import {confirmModal} from './comp/ConfirmModal.js'; +import {showErrorToast} from '../modules/toast.js'; +import {createSortable} from '../modules/sortable.js'; +import {DELETE, POST} from '../modules/fetch.js'; +import {parseDom} from '../utils.js'; + +function initRepoIssueListCheckboxes() { + const issueSelectAll = document.querySelector('.issue-checkbox-all'); + if (!issueSelectAll) return; // logged out state + const issueCheckboxes = document.querySelectorAll('.issue-checkbox'); + + const syncIssueSelectionState = () => { + const checkedCheckboxes = Array.from(issueCheckboxes).filter((el) => el.checked); + const anyChecked = Boolean(checkedCheckboxes.length); + const allChecked = anyChecked && checkedCheckboxes.length === issueCheckboxes.length; + + if (allChecked) { + issueSelectAll.checked = true; + issueSelectAll.indeterminate = false; + } else if (anyChecked) { + issueSelectAll.checked = false; + issueSelectAll.indeterminate = true; + } else { + issueSelectAll.checked = false; + issueSelectAll.indeterminate = false; + } + // if any issue is selected, show the action panel, otherwise show the filter panel + toggleElem($('#issue-filters'), !anyChecked); + toggleElem($('#issue-actions'), anyChecked); + // there are two panels but only one select-all checkbox, so move the checkbox to the visible panel + const panels = document.querySelectorAll('#issue-filters, #issue-actions'); + const visiblePanel = Array.from(panels).find((el) => !isElemHidden(el)); + const toolbarLeft = visiblePanel.querySelector('.issue-list-toolbar-left'); + toolbarLeft.prepend(issueSelectAll); + }; + + for (const el of issueCheckboxes) { + el.addEventListener('change', syncIssueSelectionState); + } + + issueSelectAll.addEventListener('change', () => { + for (const el of issueCheckboxes) { + el.checked = issueSelectAll.checked; + } + syncIssueSelectionState(); + }); + + $('.issue-action').on('click', async function (e) { + e.preventDefault(); + + const url = this.getAttribute('data-url'); + let action = this.getAttribute('data-action'); + let elementId = this.getAttribute('data-element-id'); + let issueIDs = []; + for (const el of document.querySelectorAll('.issue-checkbox:checked')) { + issueIDs.push(el.getAttribute('data-issue-id')); + } + issueIDs = issueIDs.join(','); + if (!issueIDs) return; + + // for assignee + if (elementId === '0' && url.endsWith('/assignee')) { + elementId = ''; + action = 'clear'; + } + + // for toggle + if (action === 'toggle' && e.altKey) { + action = 'toggle-alt'; + } + + // for delete + if (action === 'delete') { + const confirmText = e.target.getAttribute('data-action-delete-confirm'); + if (!await confirmModal({content: confirmText, buttonColor: 'orange'})) { + return; + } + } + + try { + await updateIssuesMeta(url, action, issueIDs, elementId); + window.location.reload(); + } catch (err) { + showErrorToast(err.responseJSON?.error ?? err.message); + } + }); +} + +function initRepoIssueListAuthorDropdown() { + const $searchDropdown = $('.user-remote-search'); + if (!$searchDropdown.length) return; + + let searchUrl = $searchDropdown[0].getAttribute('data-search-url'); + const actionJumpUrl = $searchDropdown[0].getAttribute('data-action-jump-url'); + const selectedUserId = $searchDropdown[0].getAttribute('data-selected-user-id'); + if (!searchUrl.includes('?')) searchUrl += '?'; + + $searchDropdown.dropdown('setting', { + fullTextSearch: true, + selectOnKeydown: false, + apiSettings: { + cache: false, + url: `${searchUrl}&q={query}`, + onResponse(resp) { + // the content is provided by backend IssuePosters handler + const processedResults = []; // to be used by dropdown to generate menu items + for (const item of resp.results) { + let html = `<img class="ui avatar tw-align-middle" src="${htmlEscape(item.avatar_link)}" aria-hidden="true" alt="" width="20" height="20"><span class="gt-ellipsis">${htmlEscape(item.username)}</span>`; + if (item.full_name) html += `<span class="search-fullname tw-ml-2">${htmlEscape(item.full_name)}</span>`; + processedResults.push({value: item.user_id, name: html}); + } + resp.results = processedResults; + return resp; + }, + }, + action: (_text, value) => { + window.location.href = actionJumpUrl.replace('{user_id}', encodeURIComponent(value)); + }, + onShow: () => { + $searchDropdown.dropdown('filter', ' '); // trigger a search on first show + }, + }); + + // we want to generate the dropdown menu items by ourselves, replace its internal setup functions + const dropdownSetup = {...$searchDropdown.dropdown('internal', 'setup')}; + const dropdownTemplates = $searchDropdown.dropdown('setting', 'templates'); + $searchDropdown.dropdown('internal', 'setup', dropdownSetup); + dropdownSetup.menu = function (values) { + const menu = $searchDropdown.find('> .menu')[0]; + // remove old dynamic items + for (const el of menu.querySelectorAll(':scope > .dynamic-item')) { + el.remove(); + } + + const newMenuHtml = dropdownTemplates.menu(values, $searchDropdown.dropdown('setting', 'fields'), true /* html */, $searchDropdown.dropdown('setting', 'className')); + if (newMenuHtml) { + const newMenuItems = parseDom(newMenuHtml, 'text/html').querySelectorAll('body > div'); + for (const newMenuItem of newMenuItems) { + newMenuItem.classList.add('dynamic-item'); + } + const div = document.createElement('div'); + div.classList.add('divider', 'dynamic-item'); + menu.append(div, ...newMenuItems); + } + $searchDropdown.dropdown('refresh'); + // defer our selection to the next tick, because dropdown will set the selection item after this `menu` function + setTimeout(() => { + for (const el of menu.querySelectorAll('.item.active, .item.selected')) { + el.classList.remove('active', 'selected'); + } + menu.querySelector(`.item[data-value="${selectedUserId}"]`)?.classList.add('selected'); + }, 0); + }; +} + +function initPinRemoveButton() { + for (const button of document.getElementsByClassName('issue-card-unpin')) { + button.addEventListener('click', async (event) => { + const el = event.currentTarget; + const id = Number(el.getAttribute('data-issue-id')); + + // Send the unpin request + const response = await DELETE(el.getAttribute('data-unpin-url')); + if (response.ok) { + // Delete the tooltip + el._tippy.destroy(); + // Remove the Card + el.closest(`div.issue-card[data-issue-id="${id}"]`).remove(); + } + }); + } +} + +async function pinMoveEnd(e) { + const url = e.item.getAttribute('data-move-url'); + const id = Number(e.item.getAttribute('data-issue-id')); + await POST(url, {data: {id, position: e.newIndex + 1}}); +} + +async function initIssuePinSort() { + const pinDiv = document.getElementById('issue-pins'); + + if (pinDiv === null) return; + + // If the User is not a Repo Admin, we don't need to proceed + if (!pinDiv.hasAttribute('data-is-repo-admin')) return; + + initPinRemoveButton(); + + // If only one issue pinned, we don't need to make this Sortable + if (pinDiv.children.length < 2) return; + + createSortable(pinDiv, { + group: 'shared', + onEnd: pinMoveEnd, + }); +} + +function initArchivedLabelFilter() { + const archivedLabelEl = document.querySelector('#archived-filter-checkbox'); + if (!archivedLabelEl) { + return; + } + + const url = new URL(window.location.href); + const archivedLabels = document.querySelectorAll('[data-is-archived]'); + + if (!archivedLabels.length) { + hideElem('.archived-label-filter'); + return; + } + const selectedLabels = (url.searchParams.get('labels') || '') + .split(',') + .map((id) => id < 0 ? `${~id + 1}` : id); // selectedLabels contains -ve ids, which are excluded so convert any -ve value id to +ve + + const archivedElToggle = () => { + for (const label of archivedLabels) { + const id = label.getAttribute('data-label-id'); + toggleElem(label, archivedLabelEl.checked || selectedLabels.includes(id)); + } + }; + + archivedElToggle(); + archivedLabelEl.addEventListener('change', () => { + archivedElToggle(); + if (archivedLabelEl.checked) { + url.searchParams.set('archived', 'true'); + } else { + url.searchParams.delete('archived'); + } + window.location.href = url.href; + }); +} + +export function initRepoIssueList() { + if (!document.querySelectorAll('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list').length) return; + initRepoIssueListCheckboxes(); + initRepoIssueListAuthorDropdown(); + initIssuePinSort(); + initArchivedLabelFilter(); +} diff --git a/web_src/js/features/repo-issue-pr-form.js b/web_src/js/features/repo-issue-pr-form.js new file mode 100644 index 0000000..7b26e64 --- /dev/null +++ b/web_src/js/features/repo-issue-pr-form.js @@ -0,0 +1,10 @@ +import {createApp} from 'vue'; +import PullRequestMergeForm from '../components/PullRequestMergeForm.vue'; + +export function initRepoPullRequestMergeForm() { + const el = document.getElementById('pull-request-merge-form'); + if (!el) return; + + const view = createApp(PullRequestMergeForm); + view.mount(el); +} diff --git a/web_src/js/features/repo-issue-pr-status.js b/web_src/js/features/repo-issue-pr-status.js new file mode 100644 index 0000000..7890b9c --- /dev/null +++ b/web_src/js/features/repo-issue-pr-status.js @@ -0,0 +1,10 @@ +export function initRepoPullRequestCommitStatus() { + for (const btn of document.querySelectorAll('.commit-status-hide-checks')) { + const panel = btn.closest('.commit-status-panel'); + const list = panel.querySelector('.commit-status-list'); + btn.addEventListener('click', () => { + list.style.maxHeight = list.style.maxHeight ? '' : '0px'; // toggle + btn.textContent = btn.getAttribute(list.style.maxHeight ? 'data-show-all' : 'data-hide-all'); + }); + } +} diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js new file mode 100644 index 0000000..c4bd70b --- /dev/null +++ b/web_src/js/features/repo-issue.js @@ -0,0 +1,794 @@ +import $ from 'jquery'; +import {htmlEscape} from 'escape-goat'; +import {showTemporaryTooltip, createTippy} from '../modules/tippy.js'; +import {hideElem, showElem, toggleElem} from '../utils/dom.js'; +import {setFileFolding} from './file-fold.js'; +import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js'; +import {toAbsoluteUrl} from '../utils.js'; +import {initDropzone} from './common-global.js'; +import {POST, GET} from '../modules/fetch.js'; +import {showErrorToast} from '../modules/toast.js'; +import {emojiHTML} from './emoji.js'; + +const {appSubUrl} = window.config; + +// if there are draft comments, confirm before reloading, to avoid losing comments +export function reloadConfirmDraftComment() { + const commentTextareas = [ + document.querySelector('.edit-content-zone:not(.tw-hidden) textarea'), + document.querySelector('#comment-form textarea'), + ]; + for (const textarea of commentTextareas) { + // Most users won't feel too sad if they lose a comment with 10 chars, they can re-type these in seconds. + // But if they have typed more (like 50) chars and the comment is lost, they will be very unhappy. + if (textarea && textarea.value.trim().length > 10) { + textarea.parentElement.scrollIntoView(); + if (!window.confirm('Page will be reloaded, but there are draft comments. Continuing to reload will discard the comments. Continue?')) { + return; + } + break; + } + } + window.location.reload(); +} + +export function initRepoIssueTimeTracking() { + $(document).on('click', '.issue-add-time', () => { + $('.issue-start-time-modal').modal({ + duration: 200, + onApprove() { + $('#add_time_manual_form').trigger('submit'); + }, + }).modal('show'); + $('.issue-start-time-modal input').on('keydown', (e) => { + if ((e.keyCode || e.key) === 13) { + $('#add_time_manual_form').trigger('submit'); + } + }); + }); + $(document).on('click', '.issue-start-time, .issue-stop-time', () => { + $('#toggle_stopwatch_form').trigger('submit'); + }); + $(document).on('click', '.issue-cancel-time', () => { + $('#cancel_stopwatch_form').trigger('submit'); + }); + $(document).on('click', 'button.issue-delete-time', function () { + const sel = `.issue-delete-time-modal[data-id="${$(this).data('id')}"]`; + $(sel).modal({ + duration: 200, + onApprove() { + $(`${sel} form`).trigger('submit'); + }, + }).modal('show'); + }); +} + +async function updateDeadline(deadlineString) { + hideElem('#deadline-err-invalid-date'); + document.getElementById('deadline-loader')?.classList.add('is-loading'); + + let realDeadline = null; + if (deadlineString !== '') { + const newDate = Date.parse(deadlineString); + + if (Number.isNaN(newDate)) { + document.getElementById('deadline-loader')?.classList.remove('is-loading'); + showElem('#deadline-err-invalid-date'); + return false; + } + realDeadline = new Date(newDate); + } + + try { + const response = await POST(document.getElementById('update-issue-deadline-form').getAttribute('action'), { + data: {due_date: realDeadline}, + }); + + if (response.ok) { + window.location.reload(); + } else { + throw new Error('Invalid response'); + } + } catch (error) { + console.error(error); + document.getElementById('deadline-loader').classList.remove('is-loading'); + showElem('#deadline-err-invalid-date'); + } +} + +export function initRepoIssueDue() { + $(document).on('click', '.issue-due-edit', () => { + toggleElem('#deadlineForm'); + }); + $(document).on('click', '.issue-due-remove', () => { + updateDeadline(''); + }); + $(document).on('submit', '.issue-due-form', () => { + updateDeadline($('#deadlineDate').val()); + return false; + }); +} + +/** + * @param {HTMLElement} item + */ +function excludeLabel(item) { + const href = item.getAttribute('href'); + const id = item.getAttribute('data-label-id'); + + const regStr = `labels=((?:-?[0-9]+%2c)*)(${id})((?:%2c-?[0-9]+)*)&`; + const newStr = 'labels=$1-$2$3&'; + + window.location = href.replace(new RegExp(regStr), newStr); +} + +export function initRepoIssueSidebarList() { + const repolink = $('#repolink').val(); + const repoId = $('#repoId').val(); + const crossRepoSearch = $('#crossRepoSearch').val(); + const tp = $('#type').val(); + let issueSearchUrl = `${appSubUrl}/${repolink}/issues/search?q={query}&type=${tp}`; + if (crossRepoSearch === 'true') { + issueSearchUrl = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${repoId}&type=${tp}`; + } + $('#new-dependency-drop-list') + .dropdown({ + apiSettings: { + url: issueSearchUrl, + onResponse(response) { + const filteredResponse = {success: true, results: []}; + const currIssueId = $('#new-dependency-drop-list').data('issue-id'); + // Parse the response from the api to work with our dropdown + $.each(response, (_i, issue) => { + // Don't list current issue in the dependency list. + if (issue.id === currIssueId) { + return; + } + filteredResponse.results.push({ + name: `#${issue.number} ${issueTitleHTML(htmlEscape(issue.title)) + }<div class="text small tw-break-anywhere">${htmlEscape(issue.repository.full_name)}</div>`, + value: issue.id, + }); + }); + return filteredResponse; + }, + cache: false, + }, + + fullTextSearch: true, + }); + + $('.menu a.label-filter-item').each(function () { + $(this).on('click', function (e) { + if (e.altKey) { + e.preventDefault(); + excludeLabel(this); + } + }); + }); + + $('.menu .ui.dropdown.label-filter').on('keydown', (e) => { + if (e.altKey && e.keyCode === 13) { + const selectedItem = document.querySelector('.menu .ui.dropdown.label-filter .menu .item.selected'); + if (selectedItem) { + excludeLabel(selectedItem); + } + } + }); + $('.ui.dropdown.label-filter, .ui.dropdown.select-label').dropdown('setting', {'hideDividers': 'empty'}).dropdown('refreshItems'); +} + +export function initRepoIssueCommentDelete() { + // Delete comment + document.addEventListener('click', async (e) => { + if (!e.target.matches('.delete-comment')) return; + e.preventDefault(); + + const deleteButton = e.target; + if (window.confirm(deleteButton.getAttribute('data-locale'))) { + try { + const response = await POST(deleteButton.getAttribute('data-url')); + if (!response.ok) throw new Error('Failed to delete comment'); + + const conversationHolder = deleteButton.closest('.conversation-holder'); + const parentTimelineItem = deleteButton.closest('.timeline-item'); + const parentTimelineGroup = deleteButton.closest('.timeline-item-group'); + + // Check if this was a pending comment. + if (conversationHolder?.querySelector('.pending-label')) { + const counter = document.querySelector('#review-box .review-comments-counter'); + let num = parseInt(counter?.getAttribute('data-pending-comment-number')) - 1 || 0; + num = Math.max(num, 0); + counter.setAttribute('data-pending-comment-number', num); + counter.textContent = String(num); + } + + document.getElementById(deleteButton.getAttribute('data-comment-id'))?.remove(); + + if (conversationHolder && !conversationHolder.querySelector('.comment')) { + const path = conversationHolder.getAttribute('data-path'); + const side = conversationHolder.getAttribute('data-side'); + const idx = conversationHolder.getAttribute('data-idx'); + const lineType = conversationHolder.closest('tr').getAttribute('data-line-type'); + + if (lineType === 'same') { + document.querySelector(`[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`).classList.remove('tw-invisible'); + } else { + document.querySelector(`[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`).classList.remove('tw-invisible'); + } + + conversationHolder.remove(); + } + + // Check if there is no review content, move the time avatar upward to avoid overlapping the content below. + if (!parentTimelineGroup?.querySelector('.timeline-item.comment') && !parentTimelineItem?.querySelector('.conversation-holder')) { + const timelineAvatar = parentTimelineGroup?.querySelector('.timeline-avatar'); + timelineAvatar?.classList.remove('timeline-avatar-offset'); + } + } catch (error) { + console.error(error); + } + } + }); +} + +export function initRepoIssueDependencyDelete() { + // Delete Issue dependency + $(document).on('click', '.delete-dependency-button', (e) => { + const id = e.currentTarget.getAttribute('data-id'); + const type = e.currentTarget.getAttribute('data-type'); + + $('.remove-dependency').modal({ + closable: false, + duration: 200, + onApprove: () => { + $('#removeDependencyID').val(id); + $('#dependencyType').val(type); + $('#removeDependencyForm').trigger('submit'); + }, + }).modal('show'); + }); +} + +export function initRepoIssueCodeCommentCancel() { + // Cancel inline code comment + document.addEventListener('click', (e) => { + if (!e.target.matches('.cancel-code-comment')) return; + + const form = e.target.closest('form'); + if (form?.classList.contains('comment-form')) { + hideElem(form); + showElem(form.closest('.comment-code-cloud')?.querySelectorAll('button.comment-form-reply')); + } else { + form.closest('.comment-code-cloud')?.remove(); + } + }); +} + +export function initRepoPullRequestUpdate() { + // Pull Request update button + const pullUpdateButton = document.querySelector('.update-button > button'); + if (!pullUpdateButton) return; + + pullUpdateButton.addEventListener('click', async function (e) { + e.preventDefault(); + const redirect = this.getAttribute('data-redirect'); + this.classList.add('is-loading'); + let response; + try { + response = await POST(this.getAttribute('data-do')); + } catch (error) { + console.error(error); + } finally { + this.classList.remove('is-loading'); + } + let data; + try { + data = await response?.json(); // the response is probably not a JSON + } catch (error) { + console.error(error); + } + if (data?.redirect) { + window.location.href = data.redirect; + } else if (redirect) { + window.location.href = redirect; + } else { + window.location.reload(); + } + }); + + $('.update-button > .dropdown').dropdown({ + onChange(_text, _value, $choice) { + const url = $choice[0].getAttribute('data-do'); + if (url) { + const buttonText = pullUpdateButton.querySelector('.button-text'); + if (buttonText) { + buttonText.textContent = $choice.text(); + } + pullUpdateButton.setAttribute('data-do', url); + } + }, + }); +} + +export function initRepoPullRequestAllowMaintainerEdit() { + const wrapper = document.getElementById('allow-edits-from-maintainers'); + if (!wrapper) return; + const checkbox = wrapper.querySelector('input[type="checkbox"]'); + checkbox.addEventListener('input', async () => { + const url = `${wrapper.getAttribute('data-url')}/set_allow_maintainer_edit`; + wrapper.classList.add('is-loading'); + try { + const resp = await POST(url, {data: new URLSearchParams({allow_maintainer_edit: checkbox.checked})}); + if (!resp.ok) { + throw new Error('Failed to update maintainer edit permission'); + } + const data = await resp.json(); + checkbox.checked = data.allow_maintainer_edit; + } catch (error) { + checkbox.checked = !checkbox.checked; + console.error(error); + showTemporaryTooltip(wrapper, wrapper.getAttribute('data-prompt-error')); + } finally { + wrapper.classList.remove('is-loading'); + } + }); +} + +export function initRepoIssueReferenceRepositorySearch() { + $('.issue_reference_repository_search') + .dropdown({ + apiSettings: { + url: `${appSubUrl}/repo/search?q={query}&limit=20`, + onResponse(response) { + const filteredResponse = {success: true, results: []}; + $.each(response.data, (_r, repo) => { + filteredResponse.results.push({ + name: htmlEscape(repo.repository.full_name), + value: repo.repository.full_name, + }); + }); + return filteredResponse; + }, + cache: false, + }, + onChange(_value, _text, $choice) { + const $form = $choice.closest('form'); + if (!$form.length) return; + + $form[0].setAttribute('action', `${appSubUrl}/${_text}/issues/new`); + }, + fullTextSearch: true, + }); +} + +export function initRepoIssueWipTitle() { + $('.title_wip_desc > a').on('click', (e) => { + e.preventDefault(); + + const $issueTitle = $('#issue_title'); + $issueTitle.trigger('focus'); + const value = $issueTitle.val().trim().toUpperCase(); + + const wipPrefixes = $('.title_wip_desc').data('wip-prefixes'); + for (const prefix of wipPrefixes) { + if (value.startsWith(prefix.toUpperCase())) { + return; + } + } + + $issueTitle.val(`${wipPrefixes[0]} ${$issueTitle.val()}`); + }); +} + +export async function updateIssuesMeta(url, action, issue_ids, id) { + try { + const response = await POST(url, {data: new URLSearchParams({action, issue_ids, id})}); + if (!response.ok) { + throw new Error('Failed to update issues meta'); + } + } catch (error) { + console.error(error); + } +} + +export function initRepoIssueComments() { + if (!$('.repository.view.issue .timeline').length) return; + + $('.re-request-review').on('click', async function (e) { + e.preventDefault(); + const url = this.getAttribute('data-update-url'); + const issueId = this.getAttribute('data-issue-id'); + const id = this.getAttribute('data-id'); + const isChecked = this.classList.contains('checked'); + + await updateIssuesMeta(url, isChecked ? 'detach' : 'attach', issueId, id); + window.location.reload(); + }); + + document.addEventListener('click', (e) => { + const urlTarget = document.querySelector(':target'); + if (!urlTarget) return; + + const urlTargetId = urlTarget.id; + if (!urlTargetId) return; + + if (!/^(issue|pull)(comment)?-\d+$/.test(urlTargetId)) return; + + if (!e.target.closest(`#${urlTargetId}`)) { + const scrollPosition = $(window).scrollTop(); + window.location.hash = ''; + $(window).scrollTop(scrollPosition); + window.history.pushState(null, null, ' '); + } + }); +} + +export async function handleReply($el) { + hideElem($el); + const $form = $el.closest('.comment-code-cloud').find('.comment-form'); + showElem($form); + + const $textarea = $form.find('textarea'); + let editor = getComboMarkdownEditor($textarea); + if (!editor) { + // FIXME: the initialization of the dropzone is not consistent. + // When the page is loaded, the dropzone is initialized by initGlobalDropzone, but the editor is not initialized. + // When the form is submitted and partially reload, none of them is initialized. + const dropzone = $form.find('.dropzone')[0]; + if (!dropzone.dropzone) initDropzone(dropzone); + editor = await initComboMarkdownEditor($form.find('.combo-markdown-editor')); + } + editor.focus(); + return editor; +} + +export function initRepoPullRequestReview() { + if (window.location.hash && window.location.hash.startsWith('#issuecomment-')) { + // set scrollRestoration to 'manual' when there is a hash in url, so that the scroll position will not be remembered after refreshing + if (window.history.scrollRestoration !== 'manual') { + window.history.scrollRestoration = 'manual'; + } + const commentDiv = document.querySelector(window.location.hash); + if (commentDiv) { + // get the name of the parent id + const groupID = commentDiv.closest('div[id^="code-comments-"]')?.getAttribute('id'); + if (groupID && groupID.startsWith('code-comments-')) { + const id = groupID.slice(14); + const ancestorDiffBox = commentDiv.closest('.diff-file-box'); + // on pages like conversation, there is no diff header + const diffHeader = ancestorDiffBox?.querySelector('.diff-file-header'); + + // offset is for scrolling + let offset = 30; + if (diffHeader) { + offset += $('.diff-detail-box').outerHeight() + $(diffHeader).outerHeight(); + } + + hideElem(`#show-outdated-${id}`); + showElem(`#code-comments-${id}, #code-preview-${id}, #hide-outdated-${id}`); + // if the comment box is folded, expand it + if (ancestorDiffBox?.getAttribute('data-folded') === 'true') { + setFileFolding(ancestorDiffBox, ancestorDiffBox.querySelector('.fold-file'), false); + } + + window.scrollTo({ + top: $(commentDiv).offset().top - offset, + behavior: 'instant', + }); + } + } + } + + $(document).on('click', '.show-outdated', function (e) { + e.preventDefault(); + const id = this.getAttribute('data-comment'); + hideElem(this); + showElem(`#code-comments-${id}`); + showElem(`#code-preview-${id}`); + showElem(`#hide-outdated-${id}`); + }); + + $(document).on('click', '.hide-outdated', function (e) { + e.preventDefault(); + const id = this.getAttribute('data-comment'); + hideElem(this); + hideElem(`#code-comments-${id}`); + hideElem(`#code-preview-${id}`); + showElem(`#show-outdated-${id}`); + }); + + $(document).on('click', 'button.comment-form-reply', async function (e) { + e.preventDefault(); + await handleReply($(this)); + }); + + const $reviewBox = $('.review-box-panel'); + if ($reviewBox.length === 1) { + const _promise = initComboMarkdownEditor($reviewBox.find('.combo-markdown-editor')); + } + + // The following part is only for diff views + if (!$('.repository.pull.diff').length) return; + + const $reviewBtn = $('.js-btn-review'); + const $panel = $reviewBtn.parent().find('.review-box-panel'); + const $closeBtn = $panel.find('.close'); + + if ($reviewBtn.length && $panel.length) { + const tippy = createTippy($reviewBtn[0], { + content: $panel[0], + placement: 'bottom', + trigger: 'click', + maxWidth: 'none', + interactive: true, + hideOnClick: true, + }); + + $closeBtn.on('click', (e) => { + e.preventDefault(); + tippy.hide(); + }); + } + + $(document).on('click', '.add-code-comment', async function (e) { + if (e.target.classList.contains('btn-add-single')) return; // https://github.com/go-gitea/gitea/issues/4745 + e.preventDefault(); + + const isSplit = this.closest('.code-diff')?.classList.contains('code-diff-split'); + const side = this.getAttribute('data-side'); + const idx = this.getAttribute('data-idx'); + const path = this.closest('[data-path]')?.getAttribute('data-path'); + const tr = this.closest('tr'); + const lineType = tr.getAttribute('data-line-type'); + + const ntr = tr.nextElementSibling; + let $ntr = $(ntr); + if (!ntr?.classList.contains('add-comment')) { + $ntr = $(` + <tr class="add-comment" data-line-type="${lineType}"> + ${isSplit ? ` + <td class="add-comment-left" colspan="4"></td> + <td class="add-comment-right" colspan="4"></td> + ` : ` + <td class="add-comment-left add-comment-right" colspan="5"></td> + `} + </tr>`); + $(tr).after($ntr); + } + + const $td = $ntr.find(`.add-comment-${side}`); + const $commentCloud = $td.find('.comment-code-cloud'); + if (!$commentCloud.length && !$ntr.find('button[name="pending_review"]').length) { + try { + const response = await GET(this.closest('[data-new-comment-url]')?.getAttribute('data-new-comment-url')); + const html = await response.text(); + $td.html(html); + $td.find("input[name='line']").val(idx); + $td.find("input[name='side']").val(side === 'left' ? 'previous' : 'proposed'); + $td.find("input[name='path']").val(path); + + initDropzone($td.find('.dropzone')[0]); + const editor = await initComboMarkdownEditor($td.find('.combo-markdown-editor')); + editor.focus(); + } catch (error) { + console.error(error); + } + } + }); +} + +export function initRepoIssueReferenceIssue() { + // Reference issue + $(document).on('click', '.reference-issue', function (event) { + const $this = $(this); + const content = $(`#${$this.data('target')}`).text(); + const poster = $this.data('poster-username'); + const reference = toAbsoluteUrl($this.data('reference')); + const $modal = $($this.data('modal')); + $modal.find('textarea[name="content"]').val(`${content}\n\n_Originally posted by @${poster} in ${reference}_`); + $modal.modal('show'); + + event.preventDefault(); + }); +} + +export function initRepoIssueWipToggle() { + // Toggle WIP + $('.toggle-wip a, .toggle-wip button').on('click', async (e) => { + e.preventDefault(); + const toggleWip = e.currentTarget.closest('.toggle-wip'); + const title = toggleWip.getAttribute('data-title'); + const wipPrefixes = JSON.parse(toggleWip.getAttribute('data-wip-prefixes')); + const updateUrl = toggleWip.getAttribute('data-update-url'); + const prefix = wipPrefixes.find((prefix) => title.startsWith(prefix)); + + try { + const params = new URLSearchParams(); + params.append('title', prefix !== undefined ? title.slice(prefix.length).trim() : `${wipPrefixes[0].trim()} ${title}`); + + const response = await POST(updateUrl, {data: params}); + if (!response.ok) { + throw new Error('Failed to toggle WIP status'); + } + window.location.reload(); + } catch (error) { + console.error(error); + } + }); +} + +export function initRepoIssueTitleEdit() { + const issueTitleDisplay = document.querySelector('#issue-title-display'); + const issueTitleEditor = document.querySelector('#issue-title-editor'); + if (!issueTitleEditor) return; + + const issueTitleInput = issueTitleEditor.querySelector('input'); + const oldTitle = issueTitleInput.getAttribute('data-old-title'); + issueTitleDisplay.querySelector('#issue-title-edit-show').addEventListener('click', () => { + hideElem(issueTitleDisplay); + hideElem('#pull-desc-display'); + showElem(issueTitleEditor); + showElem('#pull-desc-editor'); + if (!issueTitleInput.value.trim()) { + issueTitleInput.value = oldTitle; + } + issueTitleInput.focus(); + }); + issueTitleEditor.querySelector('.ui.cancel.button').addEventListener('click', () => { + hideElem(issueTitleEditor); + hideElem('#pull-desc-editor'); + showElem(issueTitleDisplay); + showElem('#pull-desc-display'); + }); + + const pullDescEditor = document.querySelector('#pull-desc-editor'); // it may not exist for a merged PR + const prTargetUpdateUrl = pullDescEditor?.getAttribute('data-target-update-url'); + + const editSaveButton = issueTitleEditor.querySelector('.ui.primary.button'); + const saveAndRefresh = async () => { + const newTitle = issueTitleInput.value.trim(); + try { + if (newTitle && newTitle !== oldTitle) { + const resp = await POST(editSaveButton.getAttribute('data-update-url'), {data: new URLSearchParams({title: newTitle})}); + if (!resp.ok) { + throw new Error(`Failed to update issue title: ${resp.statusText}`); + } + } + if (prTargetUpdateUrl) { + const newTargetBranch = document.querySelector('#pull-target-branch').getAttribute('data-branch'); + const oldTargetBranch = document.querySelector('#branch_target').textContent; + if (newTargetBranch !== oldTargetBranch) { + const resp = await POST(prTargetUpdateUrl, {data: new URLSearchParams({target_branch: newTargetBranch})}); + if (!resp.ok) { + throw new Error(`Failed to update PR target branch: ${resp.statusText}`); + } + } + } + window.location.reload(); + } catch (error) { + console.error(error); + showErrorToast(error.message); + } + }; + editSaveButton.addEventListener('click', saveAndRefresh); + issueTitleEditor.querySelector('input').addEventListener('ce-quick-submit', saveAndRefresh); +} + +export function initRepoIssueBranchSelect() { + document.querySelector('#branch-select')?.addEventListener('click', (e) => { + const el = e.target.closest('.item[data-branch]'); + if (!el) return; + const pullTargetBranch = document.querySelector('#pull-target-branch'); + const baseName = pullTargetBranch.getAttribute('data-basename'); + const branchNameNew = el.getAttribute('data-branch'); + const branchNameOld = pullTargetBranch.getAttribute('data-branch'); + pullTargetBranch.textContent = pullTargetBranch.textContent.replace(`${baseName}:${branchNameOld}`, `${baseName}:${branchNameNew}`); + pullTargetBranch.setAttribute('data-branch', branchNameNew); + }); +} + +export function initRepoIssueAssignMe() { + // Assign to me button + document.querySelector('.ui.assignees.list .item.no-select .select-assign-me') + ?.addEventListener('click', (e) => { + e.preventDefault(); + const selectMe = e.target; + const noSelect = selectMe.parentElement; + const selectorList = document.querySelector('.ui.select-assignees .menu'); + + if (selectMe.getAttribute('data-action') === 'update') { + (async () => { + await updateIssuesMeta( + selectMe.getAttribute('data-update-url'), + selectMe.getAttribute('data-action'), + selectMe.getAttribute('data-issue-id'), + selectMe.getAttribute('data-id'), + ); + reloadConfirmDraftComment(); + })(); + } else { + for (const item of selectorList.querySelectorAll('.item')) { + if (item.getAttribute('data-id') === selectMe.getAttribute('data-id')) { + item.classList.add('checked'); + item.querySelector('.octicon-check').classList.remove('tw-invisible'); + } + } + document.querySelector(selectMe.getAttribute('data-id-selector')).classList.remove('tw-hidden'); + noSelect.classList.add('tw-hidden'); + document.querySelector(selectorList.getAttribute('data-id')).value = selectMe.getAttribute('data-id'); + return false; + } + }); +} + +export function initSingleCommentEditor($commentForm) { + // pages: + // * normal new issue/pr page, no status-button + // * issue/pr view page, with comment form, has status-button + const opts = {}; + const statusButton = document.getElementById('status-button'); + if (statusButton) { + opts.onContentChanged = (editor) => { + const statusText = statusButton.getAttribute(editor.value().trim() ? 'data-status-and-comment' : 'data-status'); + statusButton.textContent = statusText; + }; + } + initComboMarkdownEditor($commentForm.find('.combo-markdown-editor'), opts); +} + +export function initIssueTemplateCommentEditors($commentForm) { + // pages: + // * new issue with issue template + const $comboFields = $commentForm.find('.combo-editor-dropzone'); + + const initCombo = async ($combo) => { + const $dropzoneContainer = $combo.find('.form-field-dropzone'); + const $formField = $combo.find('.form-field-real'); + const $markdownEditor = $combo.find('.combo-markdown-editor'); + + const editor = await initComboMarkdownEditor($markdownEditor, { + onContentChanged: (editor) => { + $formField.val(editor.value()); + }, + }); + + $formField.on('focus', async () => { + // deactivate all markdown editors + showElem($commentForm.find('.combo-editor-dropzone .form-field-real')); + hideElem($commentForm.find('.combo-editor-dropzone .combo-markdown-editor')); + hideElem($commentForm.find('.combo-editor-dropzone .form-field-dropzone')); + + // activate this markdown editor + hideElem($formField); + showElem($markdownEditor); + showElem($dropzoneContainer); + + await editor.switchToUserPreference(); + editor.focus(); + }); + }; + + for (const el of $comboFields) { + initCombo($(el)); + } +} + +// This function used to show and hide archived label on issue/pr +// page in the sidebar where we select the labels +// If we have any archived label tagged to issue and pr. We will show that +// archived label with checked classed otherwise we will hide it +// with the help of this function. +// This function runs globally. +export function initArchivedLabelHandler() { + if (!document.querySelector('.archived-label-hint')) return; + for (const label of document.querySelectorAll('[data-is-archived]')) { + toggleElem(label, label.classList.contains('checked')); + } +} + +// Render the issue's title. It converts emojis and code blocks syntax into their respective HTML equivalent. +export function issueTitleHTML(title) { + return title.replaceAll(/:[-+\w]+:/g, (emoji) => emojiHTML(emoji.substring(1, emoji.length - 1))) + .replaceAll(/`[^`]+`/g, (code) => `<code class="inline-code-block">${code.substring(1, code.length - 1)}</code>`); +} diff --git a/web_src/js/features/repo-issue.test.js b/web_src/js/features/repo-issue.test.js new file mode 100644 index 0000000..8c9734b --- /dev/null +++ b/web_src/js/features/repo-issue.test.js @@ -0,0 +1,24 @@ +import {vi} from 'vitest'; + +import {issueTitleHTML} from './repo-issue.js'; + +// monaco-editor does not have any exports fields, which trips up vitest +vi.mock('./comp/ComboMarkdownEditor.js', () => ({})); +// jQuery is missing +vi.mock('./common-global.js', () => ({})); + +test('Convert issue title to html', () => { + expect(issueTitleHTML('')).toEqual(''); + expect(issueTitleHTML('issue title')).toEqual('issue title'); + + const expected_thumbs_up = `<span class="emoji" title=":+1:">👍</span>`; + expect(issueTitleHTML(':+1:')).toEqual(expected_thumbs_up); + expect(issueTitleHTML(':invalid emoji:')).toEqual(':invalid emoji:'); + + const expected_code_block = `<code class="inline-code-block">code</code>`; + expect(issueTitleHTML('`code`')).toEqual(expected_code_block); + expect(issueTitleHTML('`invalid code')).toEqual('`invalid code'); + expect(issueTitleHTML('invalid code`')).toEqual('invalid code`'); + + expect(issueTitleHTML('issue title :+1: `code`')).toEqual(`issue title ${expected_thumbs_up} ${expected_code_block}`); +}); diff --git a/web_src/js/features/repo-legacy.js b/web_src/js/features/repo-legacy.js new file mode 100644 index 0000000..73aaa45 --- /dev/null +++ b/web_src/js/features/repo-legacy.js @@ -0,0 +1,610 @@ +import $ from 'jquery'; +import { + initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete, + initRepoIssueComments, initRepoIssueDependencyDelete, initRepoIssueReferenceIssue, + initRepoIssueTitleEdit, initRepoIssueWipToggle, + initRepoPullRequestUpdate, updateIssuesMeta, handleReply, initIssueTemplateCommentEditors, initSingleCommentEditor, + initRepoIssueAssignMe, reloadConfirmDraftComment, +} from './repo-issue.js'; +import {initUnicodeEscapeButton} from './repo-unicode-escape.js'; +import {svg} from '../svg.js'; +import {htmlEscape} from 'escape-goat'; +import {initRepoBranchTagSelector} from '../components/RepoBranchTagSelector.vue'; +import { + initRepoCloneLink, initRepoCommonBranchOrTagDropdown, initRepoCommonFilterSearchDropdown, +} from './repo-common.js'; +import {initCitationFileCopyContent} from './citation.js'; +import {initCompLabelEdit} from './comp/LabelEdit.js'; +import {initRepoDiffConversationNav} from './repo-diff.js'; +import {createDropzone} from './dropzone.js'; +import {showErrorToast} from '../modules/toast.js'; +import {initCommentContent, initMarkupContent} from '../markup/content.js'; +import {initCompReactionSelector} from './comp/ReactionSelector.js'; +import {initRepoSettingBranches} from './repo-settings.js'; +import {initRepoPullRequestMergeForm} from './repo-issue-pr-form.js'; +import {initRepoPullRequestCommitStatus} from './repo-issue-pr-status.js'; +import {hideElem, showElem} from '../utils/dom.js'; +import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js'; +import {attachRefIssueContextPopup} from './contextpopup.js'; +import {POST, GET} from '../modules/fetch.js'; + +const {csrfToken} = window.config; + +export function initRepoCommentForm() { + const $commentForm = $('.comment.form'); + if (!$commentForm.length) return; + + if ($commentForm.find('.field.combo-editor-dropzone').length) { + // at the moment, if a form has multiple combo-markdown-editors, it must be an issue template form + initIssueTemplateCommentEditors($commentForm); + } else if ($commentForm.find('.combo-markdown-editor').length) { + // it's quite unclear about the "comment form" elements, sometimes it's for issue comment, sometimes it's for file editor/uploader message + initSingleCommentEditor($commentForm); + } + + function initBranchSelector() { + const $selectBranch = $('.ui.select-branch'); + const $branchMenu = $selectBranch.find('.reference-list-menu'); + const $isNewIssue = $branchMenu[0]?.classList.contains('new-issue'); + $branchMenu.find('.item:not(.no-select)').on('click', async function () { + const selectedValue = $(this).data('id'); + const editMode = $('#editing_mode').val(); + $($(this).data('id-selector')).val(selectedValue); + if ($isNewIssue) { + $selectBranch.find('.ui .branch-name').text($(this).data('name')); + return; + } + + if (editMode === 'true') { + const form = document.getElementById('update_issueref_form'); + const params = new URLSearchParams(); + params.append('ref', selectedValue); + try { + await POST(form.getAttribute('action'), {data: params}); + window.location.reload(); + } catch (error) { + console.error(error); + } + } else if (editMode === '') { + $selectBranch.find('.ui .branch-name').text(selectedValue); + } + }); + $selectBranch.find('.reference.column').on('click', function () { + hideElem($selectBranch.find('.scrolling.reference-list-menu')); + $selectBranch.find('.reference .text').removeClass('black'); + showElem($($(this).data('target'))); + $(this).find('.text').addClass('black'); + return false; + }); + } + + initBranchSelector(); + + // List submits + function initListSubmits(selector, outerSelector) { + const $list = $(`.ui.${outerSelector}.list`); + const $noSelect = $list.find('.no-select'); + const $listMenu = $(`.${selector} .menu`); + let hasUpdateAction = $listMenu.data('action') === 'update'; + const items = {}; + + $(`.${selector}`).dropdown({ + 'action': 'nothing', // do not hide the menu if user presses Enter + fullTextSearch: 'exact', + async onHide() { + hasUpdateAction = $listMenu.data('action') === 'update'; // Update the var + if (hasUpdateAction) { + // TODO: Add batch functionality and make this 1 network request. + const itemEntries = Object.entries(items); + for (const [elementId, item] of itemEntries) { + await updateIssuesMeta( + item['update-url'], + item.action, + item['issue-id'], + elementId, + ); + } + if (itemEntries.length) { + reloadConfirmDraftComment(); + } + } + }, + }); + + $listMenu.find('.item:not(.no-select)').on('click', function (e) { + e.preventDefault(); + if (this.classList.contains('ban-change')) { + return false; + } + + hasUpdateAction = $listMenu.data('action') === 'update'; // Update the var + + const clickedItem = this; // eslint-disable-line unicorn/no-this-assignment + const scope = this.getAttribute('data-scope'); + + $(this).parent().find('.item').each(function () { + if (scope) { + // Enable only clicked item for scoped labels + if (this.getAttribute('data-scope') !== scope) { + return true; + } + if (this !== clickedItem && !this.classList.contains('checked')) { + return true; + } + } else if (this !== clickedItem) { + // Toggle for other labels + return true; + } + + if (this.classList.contains('checked')) { + $(this).removeClass('checked'); + $(this).find('.octicon-check').addClass('tw-invisible'); + if (hasUpdateAction) { + if (!($(this).data('id') in items)) { + items[$(this).data('id')] = { + 'update-url': $listMenu.data('update-url'), + action: 'detach', + 'issue-id': $listMenu.data('issue-id'), + }; + } else { + delete items[$(this).data('id')]; + } + } + } else { + $(this).addClass('checked'); + $(this).find('.octicon-check').removeClass('tw-invisible'); + if (hasUpdateAction) { + if (!($(this).data('id') in items)) { + items[$(this).data('id')] = { + 'update-url': $listMenu.data('update-url'), + action: 'attach', + 'issue-id': $listMenu.data('issue-id'), + }; + } else { + delete items[$(this).data('id')]; + } + } + } + }); + + // TODO: Which thing should be done for choosing review requests + // to make chosen items be shown on time here? + if (selector === 'select-reviewers-modify' || selector === 'select-assignees-modify') { + return false; + } + + const listIds = []; + $(this).parent().find('.item').each(function () { + if (this.classList.contains('checked')) { + listIds.push($(this).data('id')); + $($(this).data('id-selector')).removeClass('tw-hidden'); + } else { + $($(this).data('id-selector')).addClass('tw-hidden'); + } + }); + if (!listIds.length) { + $noSelect.removeClass('tw-hidden'); + } else { + $noSelect.addClass('tw-hidden'); + } + $($(this).parent().data('id')).val(listIds.join(',')); + return false; + }); + $listMenu.find('.no-select.item').on('click', function (e) { + e.preventDefault(); + if (hasUpdateAction) { + (async () => { + await updateIssuesMeta( + $listMenu.data('update-url'), + 'clear', + $listMenu.data('issue-id'), + '', + ); + reloadConfirmDraftComment(); + })(); + } + + $(this).parent().find('.item').each(function () { + $(this).removeClass('checked'); + $(this).find('.octicon-check').addClass('tw-invisible'); + }); + + if (selector === 'select-reviewers-modify' || selector === 'select-assignees-modify') { + return false; + } + + $list.find('.item').each(function () { + $(this).addClass('tw-hidden'); + }); + $noSelect.removeClass('tw-hidden'); + $($(this).parent().data('id')).val(''); + }); + } + + // Init labels and assignees + initListSubmits('select-label', 'labels'); + initListSubmits('select-assignees', 'assignees'); + initRepoIssueAssignMe(); + initListSubmits('select-assignees-modify', 'assignees'); + initListSubmits('select-reviewers-modify', 'assignees'); + + function selectItem(select_id, input_id) { + const $menu = $(`${select_id} .menu`); + const $list = $(`.ui${select_id}.list`); + const hasUpdateAction = $menu.data('action') === 'update'; + + $menu.find('.item:not(.no-select)').on('click', function () { + $(this).parent().find('.item').each(function () { + $(this).removeClass('selected active'); + }); + + $(this).addClass('selected active'); + if (hasUpdateAction) { + (async () => { + await updateIssuesMeta( + $menu.data('update-url'), + '', + $menu.data('issue-id'), + $(this).data('id'), + ); + reloadConfirmDraftComment(); + })(); + } + + let icon = ''; + if (input_id === '#milestone_id') { + icon = svg('octicon-milestone', 18, 'tw-mr-2'); + } else if (input_id === '#project_id') { + icon = svg('octicon-project', 18, 'tw-mr-2'); + } else if (input_id === '#assignee_ids') { + icon = `<img class="ui avatar image tw-mr-2" alt="avatar" src=${$(this).data('avatar')}>`; + } + + $list.find('.selected').html(` + <a class="item muted sidebar-item-link" href=${$(this).data('href')}> + ${icon} + ${htmlEscape($(this).text())} + </a> + `); + + $(`.ui${select_id}.list .no-select`).addClass('tw-hidden'); + $(input_id).val($(this).data('id')); + }); + $menu.find('.no-select.item').on('click', function () { + $(this).parent().find('.item:not(.no-select)').each(function () { + $(this).removeClass('selected active'); + }); + + if (hasUpdateAction) { + (async () => { + await updateIssuesMeta( + $menu.data('update-url'), + '', + $menu.data('issue-id'), + $(this).data('id'), + ); + reloadConfirmDraftComment(); + })(); + } + + $list.find('.selected').html(''); + $list.find('.no-select').removeClass('tw-hidden'); + $(input_id).val(''); + }); + } + + // Milestone, Assignee, Project + selectItem('.select-project', '#project_id'); + selectItem('.select-milestone', '#milestone_id'); + selectItem('.select-assignee', '#assignee_ids'); +} + +async function onEditContent(event) { + event.preventDefault(); + + const segment = this.closest('.header').nextElementSibling; + const editContentZone = segment.querySelector('.edit-content-zone'); + const renderContent = segment.querySelector('.render-content'); + const rawContent = segment.querySelector('.raw-content'); + + let comboMarkdownEditor; + + /** + * @param {HTMLElement} dropzone + */ + const setupDropzone = async (dropzone) => { + if (!dropzone) return null; + + let disableRemovedfileEvent = false; // when resetting the dropzone (removeAllFiles), disable the "removedfile" event + let fileUuidDict = {}; // to record: if a comment has been saved, then the uploaded files won't be deleted from server when clicking the Remove in the dropzone + const dz = await createDropzone(dropzone, { + url: dropzone.getAttribute('data-upload-url'), + headers: {'X-Csrf-Token': csrfToken}, + maxFiles: dropzone.getAttribute('data-max-file'), + maxFilesize: dropzone.getAttribute('data-max-size'), + acceptedFiles: ['*/*', ''].includes(dropzone.getAttribute('data-accepts')) ? null : dropzone.getAttribute('data-accepts'), + addRemoveLinks: true, + dictDefaultMessage: dropzone.getAttribute('data-default-message'), + dictInvalidFileType: dropzone.getAttribute('data-invalid-input-type'), + dictFileTooBig: dropzone.getAttribute('data-file-too-big'), + dictRemoveFile: dropzone.getAttribute('data-remove-file'), + timeout: 0, + thumbnailMethod: 'contain', + thumbnailWidth: 480, + thumbnailHeight: 480, + init() { + this.on('success', (file, data) => { + file.uuid = data.uuid; + fileUuidDict[file.uuid] = {submitted: false}; + const input = document.createElement('input'); + input.id = data.uuid; + input.name = 'files'; + input.type = 'hidden'; + input.value = data.uuid; + dropzone.querySelector('.files').append(input); + }); + this.on('removedfile', async (file) => { + document.getElementById(file.uuid)?.remove(); + if (disableRemovedfileEvent) return; + if (dropzone.getAttribute('data-remove-url') && !fileUuidDict[file.uuid].submitted) { + try { + await POST(dropzone.getAttribute('data-remove-url'), {data: new URLSearchParams({file: file.uuid})}); + } catch (error) { + console.error(error); + } + } + }); + this.on('submit', () => { + for (const fileUuid of Object.keys(fileUuidDict)) { + fileUuidDict[fileUuid].submitted = true; + } + }); + this.on('reload', async () => { + try { + const response = await GET(editContentZone.getAttribute('data-attachment-url')); + const data = await response.json(); + // do not trigger the "removedfile" event, otherwise the attachments would be deleted from server + disableRemovedfileEvent = true; + dz.removeAllFiles(true); + dropzone.querySelector('.files').innerHTML = ''; + for (const el of dropzone.querySelectorAll('.dz-preview')) el.remove(); + fileUuidDict = {}; + disableRemovedfileEvent = false; + + for (const attachment of data) { + const imgSrc = `${dropzone.getAttribute('data-link-url')}/${attachment.uuid}`; + dz.emit('addedfile', attachment); + dz.emit('thumbnail', attachment, imgSrc); + dz.emit('complete', attachment); + fileUuidDict[attachment.uuid] = {submitted: true}; + dropzone.querySelector(`img[src='${imgSrc}']`).style.maxWidth = '100%'; + const input = document.createElement('input'); + input.id = attachment.uuid; + input.name = 'files'; + input.type = 'hidden'; + input.value = attachment.uuid; + dropzone.querySelector('.files').append(input); + } + if (!dropzone.querySelector('.dz-preview')) { + dropzone.classList.remove('dz-started'); + } + } catch (error) { + console.error(error); + } + }); + }, + }); + dz.emit('reload'); + return dz; + }; + + const cancelAndReset = (e) => { + e.preventDefault(); + showElem(renderContent); + hideElem(editContentZone); + comboMarkdownEditor.attachedDropzoneInst?.emit('reload'); + }; + + const saveAndRefresh = async (e) => { + e.preventDefault(); + showElem(renderContent); + hideElem(editContentZone); + const dropzoneInst = comboMarkdownEditor.attachedDropzoneInst; + try { + const params = new URLSearchParams({ + content: comboMarkdownEditor.value(), + context: editContentZone.getAttribute('data-context'), + content_version: editContentZone.getAttribute('data-content-version'), + }); + for (const fileInput of dropzoneInst?.element.querySelectorAll('.files [name=files]')) params.append('files[]', fileInput.value); + + const response = await POST(editContentZone.getAttribute('data-update-url'), {data: params}); + const data = await response.json(); + if (response.status === 400) { + showErrorToast(data.errorMessage); + return; + } + editContentZone.setAttribute('data-content-version', data.contentVersion); + if (!data.content) { + renderContent.innerHTML = document.getElementById('no-content').innerHTML; + rawContent.textContent = ''; + } else { + renderContent.innerHTML = data.content; + rawContent.textContent = comboMarkdownEditor.value(); + const refIssues = renderContent.querySelectorAll('p .ref-issue'); + attachRefIssueContextPopup(refIssues); + } + const content = segment; + if (!content.querySelector('.dropzone-attachments')) { + if (data.attachments !== '') { + content.insertAdjacentHTML('beforeend', data.attachments); + } + } else if (data.attachments === '') { + content.querySelector('.dropzone-attachments').remove(); + } else { + content.querySelector('.dropzone-attachments').outerHTML = data.attachments; + } + dropzoneInst?.emit('submit'); + dropzoneInst?.emit('reload'); + initMarkupContent(); + initCommentContent(); + } catch (error) { + console.error(error); + } + }; + + comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor')); + if (!comboMarkdownEditor) { + editContentZone.innerHTML = document.getElementById('issue-comment-editor-template').innerHTML; + comboMarkdownEditor = await initComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor')); + comboMarkdownEditor.attachedDropzoneInst = await setupDropzone(editContentZone.querySelector('.dropzone')); + editContentZone.addEventListener('ce-quick-submit', saveAndRefresh); + editContentZone.querySelector('.cancel.button').addEventListener('click', cancelAndReset); + editContentZone.querySelector('.save.button').addEventListener('click', saveAndRefresh); + } else { + const tabEditor = editContentZone.querySelector('.combo-markdown-editor').querySelector('.tabular.menu > a[data-tab-for=markdown-writer]'); + tabEditor?.click(); + } + + // Show write/preview tab and copy raw content as needed + showElem(editContentZone); + hideElem(renderContent); + if (!comboMarkdownEditor.value()) { + comboMarkdownEditor.value(rawContent.textContent); + } + comboMarkdownEditor.focus(); +} + +export function initRepository() { + if (!$('.page-content.repository').length) return; + + initRepoBranchTagSelector('.js-branch-tag-selector'); + + // Options + if ($('.repository.settings.options').length > 0) { + // Enable or select internal/external wiki system and issue tracker. + $('.enable-system').on('change', function () { + if (this.checked) { + $($(this).data('target')).removeClass('disabled'); + if (!$(this).data('context')) $($(this).data('context')).addClass('disabled'); + } else { + $($(this).data('target')).addClass('disabled'); + if (!$(this).data('context')) $($(this).data('context')).removeClass('disabled'); + } + }); + $('.enable-system-radio').on('change', function () { + if (this.value === 'false') { + $($(this).data('target')).addClass('disabled'); + if ($(this).data('context') !== undefined) $($(this).data('context')).removeClass('disabled'); + } else if (this.value === 'true') { + $($(this).data('target')).removeClass('disabled'); + if ($(this).data('context') !== undefined) $($(this).data('context')).addClass('disabled'); + } + }); + const $trackerIssueStyleRadios = $('.js-tracker-issue-style'); + $trackerIssueStyleRadios.on('change input', () => { + const checkedVal = $trackerIssueStyleRadios.filter(':checked').val(); + $('#tracker-issue-style-regex-box').toggleClass('disabled', checkedVal !== 'regexp'); + }); + } + + // Labels + initCompLabelEdit('.repository.labels'); + + // Milestones + if ($('.repository.new.milestone').length > 0) { + $('#clear-date').on('click', () => { + $('#deadline').val(''); + return false; + }); + } + + // Repo Creation + if ($('.repository.new.repo').length > 0) { + $('input[name="gitignores"], input[name="license"]').on('change', () => { + const gitignores = $('input[name="gitignores"]').val(); + const license = $('input[name="license"]').val(); + if (gitignores || license) { + document.querySelector('input[name="auto_init"]').checked = true; + } + }); + } + + // Compare or pull request + const $repoDiff = $('.repository.diff'); + if ($repoDiff.length) { + initRepoCommonBranchOrTagDropdown('.choose.branch .dropdown'); + initRepoCommonFilterSearchDropdown('.choose.branch .dropdown'); + } + + initRepoCloneLink(); + initCitationFileCopyContent(); + initRepoSettingBranches(); + + // Issues + if ($('.repository.view.issue').length > 0) { + initRepoIssueCommentEdit(); + + initRepoIssueBranchSelect(); + initRepoIssueTitleEdit(); + initRepoIssueWipToggle(); + initRepoIssueComments(); + + initRepoDiffConversationNav(); + initRepoIssueReferenceIssue(); + + initRepoIssueCommentDelete(); + initRepoIssueDependencyDelete(); + initRepoIssueCodeCommentCancel(); + initRepoPullRequestUpdate(); + initCompReactionSelector($(document)); + + initRepoPullRequestMergeForm(); + initRepoPullRequestCommitStatus(); + } + + // Pull request + const $repoComparePull = $('.repository.compare.pull'); + if ($repoComparePull.length > 0) { + // show pull request form + $repoComparePull.find('button.show-form').on('click', function (e) { + e.preventDefault(); + hideElem($(this).parent()); + + const $form = $repoComparePull.find('.pullrequest-form'); + showElem($form); + }); + } + + initUnicodeEscapeButton(); +} + +function initRepoIssueCommentEdit() { + // Edit issue or comment content + $(document).on('click', '.edit-content', onEditContent); + + // Quote reply + $(document).on('click', '.quote-reply', async function (event) { + event.preventDefault(); + const target = $(this).data('target'); + const quote = $(`#${target}`).text().replace(/\n/g, '\n> '); + const content = `> ${quote}\n\n`; + let editor; + if (this.classList.contains('quote-reply-diff')) { + const $replyBtn = $(this).closest('.comment-code-cloud').find('button.comment-form-reply'); + editor = await handleReply($replyBtn); + } else { + // for normal issue/comment page + editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor')); + } + if (editor) { + if (editor.value()) { + editor.value(`${editor.value()}\n\n${content}`); + } else { + editor.value(content); + } + editor.focus(); + editor.moveCursorToEnd(); + } + }); +} diff --git a/web_src/js/features/repo-migrate.js b/web_src/js/features/repo-migrate.js new file mode 100644 index 0000000..fc42ce8 --- /dev/null +++ b/web_src/js/features/repo-migrate.js @@ -0,0 +1,64 @@ +import {hideElem, showElem} from '../utils/dom.js'; +import {GET, POST} from '../modules/fetch.js'; + +const {appSubUrl} = window.config; + +export function initRepoMigrationStatusChecker() { + const repoMigrating = document.getElementById('repo_migrating'); + if (!repoMigrating) return; + + document.getElementById('repo_migrating_retry')?.addEventListener('click', doMigrationRetry); + + const task = repoMigrating.getAttribute('data-migrating-task-id'); + + // returns true if the refresh still needs to be called after a while + const refresh = async () => { + const res = await GET(`${appSubUrl}/user/task/${task}`); + if (res.url.endsWith('/login')) return false; // stop refreshing if redirected to login + if (res.status !== 200) return true; // continue to refresh if network error occurs + + const data = await res.json(); + + // for all status + if (data.message) { + document.getElementById('repo_migrating_progress_message').textContent = data.message; + } + + // TaskStatusFinished + if (data.status === 4) { + window.location.reload(); + return false; + } + + // TaskStatusFailed + if (data.status === 3) { + hideElem('#repo_migrating_progress'); + hideElem('#repo_migrating'); + showElem('#repo_migrating_retry'); + showElem('#repo_migrating_failed'); + showElem('#repo_migrating_failed_image'); + document.getElementById('repo_migrating_failed_error').textContent = data.message; + return false; + } + + return true; // continue to refresh + }; + + const syncTaskStatus = async () => { + let doNextRefresh = true; + try { + doNextRefresh = await refresh(); + } finally { + if (doNextRefresh) { + setTimeout(syncTaskStatus, 2000); + } + } + }; + + syncTaskStatus(); // no await +} + +async function doMigrationRetry(e) { + await POST(e.target.getAttribute('data-migrating-task-retry-url')); + window.location.reload(); +} diff --git a/web_src/js/features/repo-migration.js b/web_src/js/features/repo-migration.js new file mode 100644 index 0000000..59e282e --- /dev/null +++ b/web_src/js/features/repo-migration.js @@ -0,0 +1,69 @@ +import {hideElem, showElem, toggleElem} from '../utils/dom.js'; + +const service = document.getElementById('service_type'); +const user = document.getElementById('auth_username'); +const pass = document.getElementById('auth_password'); +const token = document.getElementById('auth_token'); +const mirror = document.getElementById('mirror'); +const lfs = document.getElementById('lfs'); +const lfsSettings = document.getElementById('lfs_settings'); +const lfsEndpoint = document.getElementById('lfs_endpoint'); +const items = document.querySelectorAll('#migrate_items input[type=checkbox]'); + +export function initRepoMigration() { + checkAuth(); + setLFSSettingsVisibility(); + + user?.addEventListener('input', () => {checkItems(false)}); + pass?.addEventListener('input', () => {checkItems(false)}); + token?.addEventListener('input', () => {checkItems(true)}); + mirror?.addEventListener('change', () => {checkItems(true)}); + document.getElementById('lfs_settings_show')?.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + showElem(lfsEndpoint); + }); + lfs?.addEventListener('change', setLFSSettingsVisibility); + + const cloneAddr = document.getElementById('clone_addr'); + cloneAddr?.addEventListener('change', () => { + const repoName = document.getElementById('repo_name'); + if (cloneAddr.value && !repoName?.value) { // Only modify if repo_name input is blank + repoName.value = cloneAddr.value.match(/^(.*\/)?((.+?)(\.git)?)$/)[3]; + } + }); +} + +function checkAuth() { + if (!service) return; + const serviceType = Number(service.value); + + checkItems(serviceType !== 1); +} + +function checkItems(tokenAuth) { + let enableItems; + if (tokenAuth) { + enableItems = token?.value !== ''; + } else { + enableItems = user?.value !== '' || pass?.value !== ''; + } + if (enableItems && Number(service?.value) > 1) { + if (mirror?.checked) { + for (const item of items) { + item.disabled = item.name !== 'wiki'; + } + return; + } + for (const item of items) item.disabled = false; + } else { + for (const item of items) item.disabled = true; + } +} + +function setLFSSettingsVisibility() { + if (!lfs) return; + const visible = lfs.checked; + toggleElem(lfsSettings, visible); + hideElem(lfsEndpoint); +} diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js new file mode 100644 index 0000000..a1cc4b3 --- /dev/null +++ b/web_src/js/features/repo-projects.js @@ -0,0 +1,188 @@ +import $ from 'jquery'; +import {contrastColor} from '../utils/color.js'; +import {createSortable} from '../modules/sortable.js'; +import {POST, DELETE, PUT} from '../modules/fetch.js'; + +function updateIssueCount(cards) { + const parent = cards.parentElement; + const cnt = parent.getElementsByClassName('issue-card').length; + parent.getElementsByClassName('project-column-issue-count')[0].textContent = cnt; +} + +async function createNewColumn(url, columnTitle, projectColorInput) { + try { + await POST(url, { + data: { + title: columnTitle.val(), + color: projectColorInput.val(), + }, + }); + } catch (error) { + console.error(error); + } finally { + columnTitle.closest('form').removeClass('dirty'); + window.location.reload(); + } +} + +async function moveIssue({item, from, to, oldIndex}) { + const columnCards = to.getElementsByClassName('issue-card'); + updateIssueCount(from); + updateIssueCount(to); + + const columnSorting = { + issues: Array.from(columnCards, (card, i) => ({ + issueID: parseInt(card.getAttribute('data-issue')), + sorting: i, + })), + }; + + try { + await POST(`${to.getAttribute('data-url')}/move`, { + data: columnSorting, + }); + } catch (error) { + console.error(error); + from.insertBefore(item, from.children[oldIndex]); + } +} + +async function initRepoProjectSortable() { + const els = document.querySelectorAll('#project-board > .board.sortable'); + if (!els.length) return; + + // the HTML layout is: #project-board > .board > .project-column .cards > .issue-card + const mainBoard = els[0]; + let boardColumns = mainBoard.getElementsByClassName('project-column'); + createSortable(mainBoard, { + group: 'project-column', + draggable: '.project-column', + handle: '.project-column-header', + delayOnTouchOnly: true, + delay: 500, + onSort: async () => { + boardColumns = mainBoard.getElementsByClassName('project-column'); + + const columnSorting = { + columns: Array.from(boardColumns, (column, i) => ({ + columnID: parseInt(column.getAttribute('data-id')), + sorting: i, + })), + }; + + try { + await POST(mainBoard.getAttribute('data-url'), { + data: columnSorting, + }); + } catch (error) { + console.error(error); + } + }, + }); + + for (const boardColumn of boardColumns) { + const boardCardList = boardColumn.getElementsByClassName('cards')[0]; + createSortable(boardCardList, { + group: 'shared', + onAdd: moveIssue, + onUpdate: moveIssue, + delayOnTouchOnly: true, + delay: 500, + }); + } +} + +export function initRepoProject() { + if (!document.querySelector('.repository.projects')) { + return; + } + + const _promise = initRepoProjectSortable(); + + for (const modal of document.getElementsByClassName('edit-project-column-modal')) { + const projectHeader = modal.closest('.project-column-header'); + const projectTitleLabel = projectHeader?.querySelector('.project-column-title-label'); + const projectTitleInput = modal.querySelector('.project-column-title-input'); + const projectColorInput = modal.querySelector('#new_project_column_color'); + const boardColumn = modal.closest('.project-column'); + modal.querySelector('.edit-project-column-button')?.addEventListener('click', async function (e) { + e.preventDefault(); + try { + await PUT(this.getAttribute('data-url'), { + data: { + title: projectTitleInput?.value, + color: projectColorInput?.value, + }, + }); + } catch (error) { + console.error(error); + } finally { + projectTitleLabel.textContent = projectTitleInput?.value; + projectTitleInput.closest('form')?.classList.remove('dirty'); + const dividers = boardColumn.querySelectorAll(':scope > .divider'); + if (projectColorInput.value) { + const color = contrastColor(projectColorInput.value); + boardColumn.style.setProperty('background', projectColorInput.value, 'important'); + boardColumn.style.setProperty('color', color, 'important'); + for (const divider of dividers) { + divider.style.setProperty('color', color); + } + } else { + boardColumn.style.removeProperty('background'); + boardColumn.style.removeProperty('color'); + for (const divider of dividers) { + divider.style.removeProperty('color'); + } + } + $('.ui.modal').modal('hide'); + } + }); + } + + $('.default-project-column-modal').each(function () { + const $boardColumn = $(this).closest('.project-column'); + const $showButton = $($boardColumn).find('.default-project-column-show'); + const $commitButton = $(this).find('.actions > .ok.button'); + + $($commitButton).on('click', async (e) => { + e.preventDefault(); + + try { + await POST($($showButton).data('url')); + } catch (error) { + console.error(error); + } finally { + window.location.reload(); + } + }); + }); + + $('.show-delete-project-column-modal').each(function () { + const $deleteColumnModal = $(`${this.getAttribute('data-modal')}`); + const $deleteColumnButton = $deleteColumnModal.find('.actions > .ok.button'); + const deleteUrl = this.getAttribute('data-url'); + + $deleteColumnButton.on('click', async (e) => { + e.preventDefault(); + + try { + await DELETE(deleteUrl); + } catch (error) { + console.error(error); + } finally { + window.location.reload(); + } + }); + }); + + $('#new_project_column_submit').on('click', (e) => { + e.preventDefault(); + const $columnTitle = $('#new_project_column'); + const $projectColorInput = $('#new_project_column_color_picker'); + if (!$columnTitle.val()) { + return; + } + const url = e.target.getAttribute('data-url'); + createNewColumn(url, $columnTitle, $projectColorInput); + }); +} diff --git a/web_src/js/features/repo-release.js b/web_src/js/features/repo-release.js new file mode 100644 index 0000000..0db9b8a --- /dev/null +++ b/web_src/js/features/repo-release.js @@ -0,0 +1,95 @@ +import {hideElem, showElem} from '../utils/dom.js'; +import {initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js'; + +export function initRepoRelease() { + for (const el of document.querySelectorAll('.remove-rel-attach')) { + el.addEventListener('click', (e) => { + const uuid = e.target.getAttribute('data-uuid'); + const id = e.target.getAttribute('data-id'); + document.querySelector(`input[name='attachment-del-${uuid}']`).value = + 'true'; + hideElem(`#attachment-${id}`); + }); + } +} + +export function initRepoReleaseNew() { + if (!document.querySelector('.repository.new.release')) return; + + initTagNameEditor(); + initRepoReleaseEditor(); + initAddExternalLinkButton(); +} + +function initTagNameEditor() { + const el = document.getElementById('tag-name-editor'); + if (!el) return; + + const existingTags = JSON.parse(el.getAttribute('data-existing-tags')); + if (!Array.isArray(existingTags)) return; + + const defaultTagHelperText = el.getAttribute('data-tag-helper'); + const newTagHelperText = el.getAttribute('data-tag-helper-new'); + const existingTagHelperText = el.getAttribute('data-tag-helper-existing'); + + document.getElementById('tag-name').addEventListener('keyup', (e) => { + const value = e.target.value; + const tagHelper = document.getElementById('tag-helper'); + if (existingTags.includes(value)) { + // If the tag already exists, hide the target branch selector. + hideElem('#tag-target-selector'); + tagHelper.textContent = existingTagHelperText; + } else { + showElem('#tag-target-selector'); + tagHelper.textContent = value ? newTagHelperText : defaultTagHelperText; + } + }); +} + +function initRepoReleaseEditor() { + const editor = document.querySelector( + '.repository.new.release .combo-markdown-editor', + ); + if (!editor) { + return; + } + initComboMarkdownEditor(editor); +} + +let newAttachmentCount = 0; + +function initAddExternalLinkButton() { + const addExternalLinkButton = document.getElementById('add-external-link'); + if (!addExternalLinkButton) return; + + addExternalLinkButton.addEventListener('click', () => { + newAttachmentCount += 1; + const attachmentTemplate = document.getElementById('attachment-template'); + + const newAttachment = attachmentTemplate.cloneNode(true); + newAttachment.id = `attachment-N${newAttachmentCount}`; + newAttachment.classList.remove('tw-hidden'); + + const attachmentName = newAttachment.querySelector( + 'input[name="attachment-template-new-name"]', + ); + attachmentName.name = `attachment-new-name-${newAttachmentCount}`; + attachmentName.required = true; + + const attachmentExtUrl = newAttachment.querySelector( + 'input[name="attachment-template-new-exturl"]', + ); + attachmentExtUrl.name = `attachment-new-exturl-${newAttachmentCount}`; + attachmentExtUrl.required = true; + + const attachmentDel = newAttachment.querySelector('.remove-rel-attach'); + attachmentDel.addEventListener('click', () => { + newAttachment.remove(); + }); + + attachmentTemplate.parentNode.insertBefore( + newAttachment, + attachmentTemplate, + ); + }); +} diff --git a/web_src/js/features/repo-search.js b/web_src/js/features/repo-search.js new file mode 100644 index 0000000..185f611 --- /dev/null +++ b/web_src/js/features/repo-search.js @@ -0,0 +1,22 @@ +export function initRepositorySearch() { + const repositorySearchForm = document.querySelector('#repo-search-form'); + if (!repositorySearchForm) return; + + repositorySearchForm.addEventListener('change', (e) => { + e.preventDefault(); + + const formData = new FormData(repositorySearchForm); + const params = new URLSearchParams(formData); + + if (e.target.name === 'clear-filter') { + params.delete('archived'); + params.delete('fork'); + params.delete('mirror'); + params.delete('template'); + params.delete('private'); + } + + params.delete('clear-filter'); + window.location.search = params.toString(); + }); +} diff --git a/web_src/js/features/repo-settings.js b/web_src/js/features/repo-settings.js new file mode 100644 index 0000000..52c5de2 --- /dev/null +++ b/web_src/js/features/repo-settings.js @@ -0,0 +1,120 @@ +import $ from 'jquery'; +import {minimatch} from 'minimatch'; +import {createMonaco} from './codeeditor.js'; +import {onInputDebounce, toggleElem} from '../utils/dom.js'; +import {POST} from '../modules/fetch.js'; + +const {appSubUrl, csrfToken} = window.config; + +export function initRepoSettingsCollaboration() { + // Change collaborator access mode + $('.page-content.repository .ui.dropdown.access-mode').each((_, el) => { + const $dropdown = $(el); + const $text = $dropdown.find('> .text'); + $dropdown.dropdown({ + async action(_text, value) { + const lastValue = el.getAttribute('data-last-value'); + try { + el.setAttribute('data-last-value', value); + $dropdown.dropdown('hide'); + const data = new FormData(); + data.append('uid', el.getAttribute('data-uid')); + data.append('mode', value); + await POST(el.getAttribute('data-url'), {data}); + } catch { + $text.text('(error)'); // prevent from misleading users when error occurs + el.setAttribute('data-last-value', lastValue); + } + }, + onChange(_value, text, _$choice) { + $text.text(text); // update the text when using keyboard navigating + }, + onHide() { + // set to the really selected value, defer to next tick to make sure `action` has finished its work because the calling order might be onHide -> action + setTimeout(() => { + const $item = $dropdown.dropdown('get item', el.getAttribute('data-last-value')); + if ($item) { + $dropdown.dropdown('set selected', el.getAttribute('data-last-value')); + } else { + $text.text('(none)'); // prevent from misleading users when the access mode is undefined + } + }, 0); + }, + }); + }); +} + +export function initRepoSettingSearchTeamBox() { + const searchTeamBox = document.getElementById('search-team-box'); + if (!searchTeamBox) return; + + $(searchTeamBox).search({ + minCharacters: 2, + apiSettings: { + url: `${appSubUrl}/org/${searchTeamBox.getAttribute('data-org-name')}/teams/-/search?q={query}`, + headers: {'X-Csrf-Token': csrfToken}, + onResponse(response) { + const items = []; + $.each(response.data, (_i, item) => { + items.push({ + title: item.name, + description: `${item.permission} access`, // TODO: translate this string + }); + }); + + return {results: items}; + }, + }, + searchFields: ['name', 'description'], + showNoResults: false, + }); +} + +export function initRepoSettingGitHook() { + if (!$('.edit.githook').length) return; + const filename = document.querySelector('.hook-filename').textContent; + const _promise = createMonaco($('#content')[0], filename, {language: 'shell'}); +} + +export function initRepoSettingBranches() { + if (!document.querySelector('.repository.settings.branches')) return; + + for (const el of document.getElementsByClassName('toggle-target-enabled')) { + el.addEventListener('change', function () { + const target = document.querySelector(this.getAttribute('data-target')); + target?.classList.toggle('disabled', !this.checked); + }); + } + + for (const el of document.getElementsByClassName('toggle-target-disabled')) { + el.addEventListener('change', function () { + const target = document.querySelector(this.getAttribute('data-target')); + if (this.checked) target?.classList.add('disabled'); // only disable, do not auto enable + }); + } + + document.getElementById('dismiss_stale_approvals')?.addEventListener('change', function () { + document.getElementById('ignore_stale_approvals_box')?.classList.toggle('disabled', this.checked); + }); + + // show the `Matched` mark for the status checks that match the pattern + const markMatchedStatusChecks = () => { + const patterns = (document.getElementById('status_check_contexts').value || '').split(/[\r\n]+/); + const validPatterns = patterns.map((item) => item.trim()).filter(Boolean); + const marks = document.getElementsByClassName('status-check-matched-mark'); + + for (const el of marks) { + let matched = false; + const statusCheck = el.getAttribute('data-status-check'); + for (const pattern of validPatterns) { + if (minimatch(statusCheck, pattern)) { + matched = true; + break; + } + } + toggleElem(el, matched); + } + }; + markMatchedStatusChecks(); + document.getElementById('status_check_contexts').addEventListener('input', onInputDebounce(markMatchedStatusChecks)); +} diff --git a/web_src/js/features/repo-template.js b/web_src/js/features/repo-template.js new file mode 100644 index 0000000..5f63e8b --- /dev/null +++ b/web_src/js/features/repo-template.js @@ -0,0 +1,51 @@ +import $ from 'jquery'; +import {htmlEscape} from 'escape-goat'; +import {hideElem, showElem} from '../utils/dom.js'; + +const {appSubUrl} = window.config; + +export function initRepoTemplateSearch() { + const $repoTemplate = $('#repo_template'); + const checkTemplate = function () { + const $templateUnits = $('#template_units'); + const $nonTemplate = $('#non_template'); + if ($repoTemplate.val() !== '' && $repoTemplate.val() !== '0') { + showElem($templateUnits); + hideElem($nonTemplate); + } else { + hideElem($templateUnits); + showElem($nonTemplate); + } + }; + $repoTemplate.on('change', checkTemplate); + checkTemplate(); + + const changeOwner = function () { + $('#repo_template_search') + .dropdown({ + apiSettings: { + url: `${appSubUrl}/repo/search?q={query}&template=true&priority_owner_id=${$('#uid').val()}`, + onResponse(response) { + const filteredResponse = {success: true, results: []}; + filteredResponse.results.push({ + name: '', + value: '', + }); + // Parse the response from the api to work with our dropdown + $.each(response.data, (_r, repo) => { + filteredResponse.results.push({ + name: htmlEscape(repo.repository.full_name), + value: repo.repository.id, + }); + }); + return filteredResponse; + }, + cache: false, + }, + + fullTextSearch: true, + }); + }; + $('#uid').on('change', changeOwner); + changeOwner(); +} diff --git a/web_src/js/features/repo-unicode-escape.js b/web_src/js/features/repo-unicode-escape.js new file mode 100644 index 0000000..9f0c745 --- /dev/null +++ b/web_src/js/features/repo-unicode-escape.js @@ -0,0 +1,27 @@ +import {hideElem, queryElemSiblings, showElem, toggleElem} from '../utils/dom.js'; + +export function initUnicodeEscapeButton() { + document.addEventListener('click', (e) => { + const btn = e.target.closest('.escape-button, .unescape-button, .toggle-escape-button'); + if (!btn) return; + + e.preventDefault(); + + const fileContent = btn.closest('.file-content, .non-diff-file-content, .file-preview-box'); + const fileView = fileContent?.querySelectorAll('.file-code, .file-view, .file-preview'); + if (btn.matches('.escape-button')) { + for (const el of fileView) el.classList.add('unicode-escaped'); + hideElem(btn); + showElem(queryElemSiblings(btn, '.unescape-button')); + } else if (btn.matches('.unescape-button')) { + for (const el of fileView) el.classList.remove('unicode-escaped'); + hideElem(btn); + showElem(queryElemSiblings(btn, '.escape-button')); + } else if (btn.matches('.toggle-escape-button')) { + const isEscaped = fileView[0]?.classList.contains('unicode-escaped'); + for (const el of fileView) el.classList.toggle('unicode-escaped', !isEscaped); + toggleElem(fileContent.querySelectorAll('.unescape-button'), !isEscaped); + toggleElem(fileContent.querySelectorAll('.escape-button'), isEscaped); + } + }); +} diff --git a/web_src/js/features/repo-wiki.js b/web_src/js/features/repo-wiki.js new file mode 100644 index 0000000..03a2c68 --- /dev/null +++ b/web_src/js/features/repo-wiki.js @@ -0,0 +1,89 @@ +import {initMarkupContent} from '../markup/content.js'; +import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js'; +import {fomanticMobileScreen} from '../modules/fomantic.js'; +import {POST} from '../modules/fetch.js'; + +async function initRepoWikiFormEditor() { + const editArea = document.querySelector('.repository.wiki .combo-markdown-editor textarea'); + if (!editArea) return; + + const form = document.querySelector('.repository.wiki.new .ui.form'); + const editorContainer = form.querySelector('.combo-markdown-editor'); + let editor; + + let renderRequesting = false; + let lastContent; + const renderEasyMDEPreview = async function () { + if (renderRequesting) return; + + const previewFull = editorContainer.querySelector('.EasyMDEContainer .editor-preview-active'); + const previewSide = editorContainer.querySelector('.EasyMDEContainer .editor-preview-active-side'); + const previewTarget = previewSide || previewFull; + const newContent = editArea.value; + if (editor && previewTarget && lastContent !== newContent) { + renderRequesting = true; + const formData = new FormData(); + formData.append('mode', editor.previewMode); + formData.append('context', editor.previewContext); + formData.append('text', newContent); + formData.append('wiki', editor.previewWiki); + try { + const response = await POST(editor.previewUrl, {data: formData}); + const data = await response.text(); + lastContent = newContent; + previewTarget.innerHTML = `<div class="markup ui segment">${data}</div>`; + initMarkupContent(); + } catch (error) { + console.error('Error rendering preview:', error); + } finally { + renderRequesting = false; + setTimeout(renderEasyMDEPreview, 1000); + } + } else { + setTimeout(renderEasyMDEPreview, 1000); + } + }; + renderEasyMDEPreview(); + + editor = await initComboMarkdownEditor(editorContainer, { + useScene: 'wiki', + // EasyMDE has some problems of height definition, it has inline style height 300px by default, so we also use inline styles to override it. + // And another benefit is that we only need to write the style once for both editors. + // TODO: Move height style to CSS after EasyMDE removal. + editorHeights: {minHeight: '300px', height: 'calc(100vh - 600px)'}, + previewMode: 'gfm', + previewWiki: true, + easyMDEOptions: { + previewRender: (_content, previewTarget) => previewTarget.innerHTML, // disable builtin preview render + toolbar: ['bold', 'italic', 'strikethrough', '|', + 'heading-1', 'heading-2', 'heading-3', 'heading-bigger', 'heading-smaller', '|', + 'gitea-code-inline', 'code', 'quote', '|', 'gitea-checkbox-empty', 'gitea-checkbox-checked', '|', + 'unordered-list', 'ordered-list', '|', + 'link', 'image', 'table', 'horizontal-rule', '|', + 'preview', 'fullscreen', 'side-by-side', '|', 'gitea-switch-to-textarea', + ], + }, + }); + + form.addEventListener('submit', (e) => { + if (!validateTextareaNonEmpty(editArea)) { + e.preventDefault(); + e.stopPropagation(); + } + }); +} + +function collapseWikiTocForMobile(collapse) { + if (collapse) { + document.querySelector('.wiki-content-toc details')?.removeAttribute('open'); + } +} + +export function initRepoWikiForm() { + if (!document.querySelector('.page-content.repository.wiki')) return; + + fomanticMobileScreen.addEventListener('change', (e) => collapseWikiTocForMobile(e.matches)); + collapseWikiTocForMobile(fomanticMobileScreen.matches); + + initRepoWikiFormEditor(); +} diff --git a/web_src/js/features/sshkey-helper.js b/web_src/js/features/sshkey-helper.js new file mode 100644 index 0000000..3960eef --- /dev/null +++ b/web_src/js/features/sshkey-helper.js @@ -0,0 +1,10 @@ +export function initSshKeyFormParser() { + // Parse SSH Key + document.getElementById('ssh-key-content')?.addEventListener('input', function () { + const arrays = this.value.split(' '); + const title = document.getElementById('ssh-key-title'); + if (!title.value && arrays.length === 3 && arrays[2] !== '') { + title.value = arrays[2]; + } + }); +} diff --git a/web_src/js/features/stopwatch.js b/web_src/js/features/stopwatch.js new file mode 100644 index 0000000..d070f52 --- /dev/null +++ b/web_src/js/features/stopwatch.js @@ -0,0 +1,167 @@ +import prettyMilliseconds from 'pretty-ms'; +import {createTippy} from '../modules/tippy.js'; +import {GET} from '../modules/fetch.js'; +import {hideElem, showElem} from '../utils/dom.js'; + +const {appSubUrl, notificationSettings, enableTimeTracking, assetVersionEncoded} = window.config; + +export function initStopwatch() { + if (!enableTimeTracking) { + return; + } + + const stopwatchEl = document.querySelector('.active-stopwatch-trigger'); + const stopwatchPopup = document.querySelector('.active-stopwatch-popup'); + + if (!stopwatchEl || !stopwatchPopup) { + return; + } + + stopwatchEl.removeAttribute('href'); // intended for noscript mode only + + createTippy(stopwatchEl, { + content: stopwatchPopup, + placement: 'bottom-end', + trigger: 'click', + maxWidth: 'none', + interactive: true, + hideOnClick: true, + }); + + // global stop watch (in the head_navbar), it should always work in any case either the EventSource or the PeriodicPoller is used. + const currSeconds = document.querySelector('.stopwatch-time')?.getAttribute('data-seconds'); + if (currSeconds) { + updateStopwatchTime(currSeconds); + } + + let usingPeriodicPoller = false; + const startPeriodicPoller = (timeout) => { + if (timeout <= 0 || !Number.isFinite(timeout)) return; + usingPeriodicPoller = true; + setTimeout(() => updateStopwatchWithCallback(startPeriodicPoller, timeout), timeout); + }; + + // if the browser supports EventSource and SharedWorker, use it instead of the periodic poller + if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) { + // Try to connect to the event source via the shared worker first + const worker = new SharedWorker(`${__webpack_public_path__}js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, 'notification-worker'); + worker.addEventListener('error', (event) => { + console.error('worker error', event); + }); + worker.port.addEventListener('messageerror', () => { + console.error('unable to deserialize message'); + }); + worker.port.postMessage({ + type: 'start', + url: `${window.location.origin}${appSubUrl}/user/events`, + }); + worker.port.addEventListener('message', (event) => { + if (!event.data || !event.data.type) { + console.error('unknown worker message event', event); + return; + } + if (event.data.type === 'stopwatches') { + updateStopwatchData(JSON.parse(event.data.data)); + } else if (event.data.type === 'no-event-source') { + // browser doesn't support EventSource, falling back to periodic poller + if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout); + } else if (event.data.type === 'error') { + console.error('worker port event error', event.data); + } else if (event.data.type === 'logout') { + if (event.data.data !== 'here') { + return; + } + worker.port.postMessage({ + type: 'close', + }); + worker.port.close(); + window.location.href = `${window.location.origin}${appSubUrl}/`; + } else if (event.data.type === 'close') { + worker.port.postMessage({ + type: 'close', + }); + worker.port.close(); + } + }); + worker.port.addEventListener('error', (e) => { + console.error('worker port error', e); + }); + worker.port.start(); + window.addEventListener('beforeunload', () => { + worker.port.postMessage({ + type: 'close', + }); + worker.port.close(); + }); + + return; + } + + startPeriodicPoller(notificationSettings.MinTimeout); +} + +async function updateStopwatchWithCallback(callback, timeout) { + const isSet = await updateStopwatch(); + + if (!isSet) { + timeout = notificationSettings.MinTimeout; + } else if (timeout < notificationSettings.MaxTimeout) { + timeout += notificationSettings.TimeoutStep; + } + + callback(timeout); +} + +async function updateStopwatch() { + const response = await GET(`${appSubUrl}/user/stopwatches`); + if (!response.ok) { + console.error('Failed to fetch stopwatch data'); + return false; + } + const data = await response.json(); + return updateStopwatchData(data); +} + +function updateStopwatchData(data) { + const watch = data[0]; + const btnEl = document.querySelector('.active-stopwatch-trigger'); + if (!watch) { + clearStopwatchTimer(); + hideElem(btnEl); + } else { + const {repo_owner_name, repo_name, issue_index, seconds} = watch; + const issueUrl = `${appSubUrl}/${repo_owner_name}/${repo_name}/issues/${issue_index}`; + document.querySelector('.stopwatch-link')?.setAttribute('href', issueUrl); + document.querySelector('.stopwatch-commit')?.setAttribute('action', `${issueUrl}/times/stopwatch/toggle`); + document.querySelector('.stopwatch-cancel')?.setAttribute('action', `${issueUrl}/times/stopwatch/cancel`); + const stopwatchIssue = document.querySelector('.stopwatch-issue'); + if (stopwatchIssue) stopwatchIssue.textContent = `${repo_owner_name}/${repo_name}#${issue_index}`; + updateStopwatchTime(seconds); + showElem(btnEl); + } + return Boolean(data.length); +} + +let updateTimeIntervalId = null; // holds setInterval id when active +function clearStopwatchTimer() { + if (updateTimeIntervalId !== null) { + clearInterval(updateTimeIntervalId); + updateTimeIntervalId = null; + } +} +function updateStopwatchTime(seconds) { + const secs = parseInt(seconds); + if (!Number.isFinite(secs)) return; + + clearStopwatchTimer(); + const stopwatch = document.querySelector('.stopwatch-time'); + // TODO: replace with <relative-time> similar to how system status up time is shown + const start = Date.now(); + const updateUi = () => { + const delta = Date.now() - start; + const dur = prettyMilliseconds(secs * 1000 + delta, {compact: true}); + if (stopwatch) stopwatch.textContent = dur; + }; + updateUi(); + updateTimeIntervalId = setInterval(updateUi, 1000); +} diff --git a/web_src/js/features/tablesort.js b/web_src/js/features/tablesort.js new file mode 100644 index 0000000..436fe0a --- /dev/null +++ b/web_src/js/features/tablesort.js @@ -0,0 +1,22 @@ +export function initTableSort() { + for (const header of document.querySelectorAll('th[data-sortt-asc]') || []) { + const sorttAsc = header.getAttribute('data-sortt-asc'); + const sorttDesc = header.getAttribute('data-sortt-desc'); + const sorttDefault = header.getAttribute('data-sortt-default'); + header.addEventListener('click', () => { + tableSort(sorttAsc, sorttDesc, sorttDefault); + }); + } +} + +function tableSort(normSort, revSort, isDefault) { + if (!normSort) return false; + if (!revSort) revSort = ''; + + const url = new URL(window.location); + let urlSort = url.searchParams.get('sort'); + if (!urlSort && isDefault) urlSort = normSort; + + url.searchParams.set('sort', urlSort !== normSort ? normSort : revSort); + window.location.replace(url.href); +} diff --git a/web_src/js/features/tribute.js b/web_src/js/features/tribute.js new file mode 100644 index 0000000..02cd484 --- /dev/null +++ b/web_src/js/features/tribute.js @@ -0,0 +1,57 @@ +import {emojiKeys, emojiHTML, emojiString} from './emoji.js'; +import {htmlEscape} from 'escape-goat'; + +function makeCollections({mentions, emoji}) { + const collections = []; + + if (emoji) { + collections.push({ + trigger: ':', + requireLeadingSpace: true, + values: (query, cb) => { + const matches = []; + for (const name of emojiKeys) { + if (name.includes(query)) { + matches.push(name); + if (matches.length > 5) break; + } + } + cb(matches); + }, + lookup: (item) => item, + selectTemplate: (item) => { + if (item === undefined) return null; + return emojiString(item.original); + }, + menuItemTemplate: (item) => { + return `<div class="tribute-item">${emojiHTML(item.original)}<span>${htmlEscape(item.original)}</span></div>`; + }, + }); + } + + if (mentions) { + collections.push({ + values: window.config.mentionValues ?? [], + requireLeadingSpace: true, + menuItemTemplate: (item) => { + return ` + <div class="tribute-item"> + <img src="${htmlEscape(item.original.avatar)}" class="tw-mr-2"/> + <span class="name">${htmlEscape(item.original.name)}</span> + ${item.original.fullname && item.original.fullname !== '' ? `<span class="fullname">${htmlEscape(item.original.fullname)}</span>` : ''} + </div> + `; + }, + }); + } + + return collections; +} + +export async function attachTribute(element, {mentions, emoji} = {}) { + const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs'); + const collections = makeCollections({mentions, emoji}); + const tribute = new Tribute({collection: collections, noMatchTemplate: ''}); + tribute.attach(element); + return tribute; +} diff --git a/web_src/js/features/user-auth-webauthn.js b/web_src/js/features/user-auth-webauthn.js new file mode 100644 index 0000000..6dfbb4d --- /dev/null +++ b/web_src/js/features/user-auth-webauthn.js @@ -0,0 +1,194 @@ +import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.js'; +import {showElem} from '../utils/dom.js'; +import {GET, POST} from '../modules/fetch.js'; + +const {appSubUrl} = window.config; + +export async function initUserAuthWebAuthn() { + const elPrompt = document.querySelector('.user.signin.webauthn-prompt'); + if (!elPrompt) { + return; + } + + if (!detectWebAuthnSupport()) { + return; + } + + const res = await GET(`${appSubUrl}/user/webauthn/assertion`); + if (res.status !== 200) { + webAuthnError('unknown'); + return; + } + const options = await res.json(); + options.publicKey.challenge = decodeURLEncodedBase64(options.publicKey.challenge); + for (const cred of options.publicKey.allowCredentials) { + cred.id = decodeURLEncodedBase64(cred.id); + } + try { + const credential = await navigator.credentials.get({ + publicKey: options.publicKey, + }); + await verifyAssertion(credential); + } catch (err) { + if (!options.publicKey.extensions?.appid) { + webAuthnError('general', err.message); + return; + } + delete options.publicKey.extensions.appid; + try { + const credential = await navigator.credentials.get({ + publicKey: options.publicKey, + }); + await verifyAssertion(credential); + } catch (err) { + webAuthnError('general', err.message); + } + } +} + +async function verifyAssertion(assertedCredential) { + // Move data into Arrays in case it is super long + const authData = new Uint8Array(assertedCredential.response.authenticatorData); + const clientDataJSON = new Uint8Array(assertedCredential.response.clientDataJSON); + const rawId = new Uint8Array(assertedCredential.rawId); + const sig = new Uint8Array(assertedCredential.response.signature); + const userHandle = new Uint8Array(assertedCredential.response.userHandle); + + const res = await POST(`${appSubUrl}/user/webauthn/assertion`, { + data: { + id: assertedCredential.id, + rawId: encodeURLEncodedBase64(rawId), + type: assertedCredential.type, + clientExtensionResults: assertedCredential.getClientExtensionResults(), + response: { + authenticatorData: encodeURLEncodedBase64(authData), + clientDataJSON: encodeURLEncodedBase64(clientDataJSON), + signature: encodeURLEncodedBase64(sig), + userHandle: encodeURLEncodedBase64(userHandle), + }, + }, + }); + if (res.status === 500) { + webAuthnError('unknown'); + return; + } else if (res.status !== 200) { + webAuthnError('unable-to-process'); + return; + } + const reply = await res.json(); + + window.location.href = reply?.redirect ?? `${appSubUrl}/`; +} + +async function webauthnRegistered(newCredential) { + const attestationObject = new Uint8Array(newCredential.response.attestationObject); + const clientDataJSON = new Uint8Array(newCredential.response.clientDataJSON); + const rawId = new Uint8Array(newCredential.rawId); + + const res = await POST(`${appSubUrl}/user/settings/security/webauthn/register`, { + data: { + id: newCredential.id, + rawId: encodeURLEncodedBase64(rawId), + type: newCredential.type, + response: { + attestationObject: encodeURLEncodedBase64(attestationObject), + clientDataJSON: encodeURLEncodedBase64(clientDataJSON), + }, + }, + }); + + if (res.status === 409) { + webAuthnError('duplicated'); + return; + } else if (res.status !== 201) { + webAuthnError('unknown'); + return; + } + + window.location.reload(); +} + +function webAuthnError(errorType, message) { + const elErrorMsg = document.getElementById(`webauthn-error-msg`); + + if (errorType === 'general') { + elErrorMsg.textContent = message || 'unknown error'; + } else { + const elTypedError = document.querySelector(`#webauthn-error [data-webauthn-error-msg=${errorType}]`); + if (elTypedError) { + elErrorMsg.textContent = `${elTypedError.textContent}${message ? ` ${message}` : ''}`; + } else { + elErrorMsg.textContent = `unknown error type: ${errorType}${message ? ` ${message}` : ''}`; + } + } + + showElem('#webauthn-error'); +} + +function detectWebAuthnSupport() { + if (!window.isSecureContext) { + webAuthnError('insecure'); + return false; + } + + if (typeof window.PublicKeyCredential !== 'function') { + webAuthnError('browser'); + return false; + } + + return true; +} + +export function initUserAuthWebAuthnRegister() { + const elRegister = document.getElementById('register-webauthn'); + if (!elRegister) { + return; + } + if (!detectWebAuthnSupport()) { + elRegister.disabled = true; + return; + } + elRegister.addEventListener('click', async (e) => { + e.preventDefault(); + await webAuthnRegisterRequest(); + }); +} + +async function webAuthnRegisterRequest() { + const elNickname = document.getElementById('nickname'); + + const formData = new FormData(); + formData.append('name', elNickname.value); + + const res = await POST(`${appSubUrl}/user/settings/security/webauthn/request_register`, { + data: formData, + }); + + if (res.status === 409) { + webAuthnError('duplicated'); + return; + } else if (res.status !== 200) { + webAuthnError('unknown'); + return; + } + + const options = await res.json(); + elNickname.closest('div.field').classList.remove('error'); + + options.publicKey.challenge = decodeURLEncodedBase64(options.publicKey.challenge); + options.publicKey.user.id = decodeURLEncodedBase64(options.publicKey.user.id); + if (options.publicKey.excludeCredentials) { + for (const cred of options.publicKey.excludeCredentials) { + cred.id = decodeURLEncodedBase64(cred.id); + } + } + + try { + const credential = await navigator.credentials.create({ + publicKey: options.publicKey, + }); + await webauthnRegistered(credential); + } catch (err) { + webAuthnError('unknown', err); + } +} diff --git a/web_src/js/features/user-auth.js b/web_src/js/features/user-auth.js new file mode 100644 index 0000000..a871ac4 --- /dev/null +++ b/web_src/js/features/user-auth.js @@ -0,0 +1,22 @@ +import {checkAppUrl} from './common-global.js'; + +export function initUserAuthOauth2() { + const outer = document.getElementById('oauth2-login-navigator'); + if (!outer) return; + const inner = document.getElementById('oauth2-login-navigator-inner'); + + checkAppUrl(); + + for (const link of outer.querySelectorAll('.oauth-login-link')) { + link.addEventListener('click', () => { + inner.classList.add('tw-invisible'); + outer.classList.add('is-loading'); + setTimeout(() => { + // recover previous content to let user try again + // usually redirection will be performed before this action + outer.classList.remove('is-loading'); + inner.classList.remove('tw-invisible'); + }, 5000); + }); + } +} diff --git a/web_src/js/features/user-settings.js b/web_src/js/features/user-settings.js new file mode 100644 index 0000000..717ef94 --- /dev/null +++ b/web_src/js/features/user-settings.js @@ -0,0 +1,63 @@ +import {hideElem, showElem} from '../utils/dom.js'; + +function onPronounsDropdownUpdate() { + const pronounsCustom = document.getElementById('pronouns-custom'); + const pronounsDropdown = document.getElementById('pronouns-dropdown'); + const pronounsInput = pronounsDropdown.querySelector('input'); + // must be kept in sync with `routers/web/user/setting/profile.go` + const isCustom = !( + pronounsInput.value === '' || + pronounsInput.value === 'he/him' || + pronounsInput.value === 'she/her' || + pronounsInput.value === 'they/them' || + pronounsInput.value === 'it/its' || + pronounsInput.value === 'any pronouns' + ); + if (isCustom) { + if (pronounsInput.value === '!') { + pronounsCustom.value = ''; + } else { + pronounsCustom.value = pronounsInput.value; + } + pronounsCustom.style.display = ''; + } else { + pronounsCustom.style.display = 'none'; + } +} +function onPronounsCustomUpdate() { + const pronounsCustom = document.getElementById('pronouns-custom'); + const pronounsInput = document.querySelector('#pronouns-dropdown input'); + pronounsInput.value = pronounsCustom.value; +} + +export function initUserSettings() { + if (!document.querySelectorAll('.user.settings.profile').length) return; + + const usernameInput = document.getElementById('username'); + if (!usernameInput) return; + usernameInput.addEventListener('input', function () { + const prompt = document.getElementById('name-change-prompt'); + const promptRedirect = document.getElementById('name-change-redirect-prompt'); + if (this.value.toLowerCase() !== this.getAttribute('data-name').toLowerCase()) { + showElem(prompt); + showElem(promptRedirect); + } else { + hideElem(prompt); + hideElem(promptRedirect); + } + }); + + const pronounsDropdown = document.getElementById('pronouns-dropdown'); + const pronounsCustom = document.getElementById('pronouns-custom'); + const pronounsInput = pronounsDropdown.querySelector('input'); + + // If JS is disabled, the page will show the custom input, as the dropdown requires JS to work. + // JS progressively enhances the input by adding a dropdown, but it works regardless. + pronounsCustom.removeAttribute('name'); + pronounsInput.setAttribute('name', 'pronouns'); + pronounsDropdown.style.display = ''; + + onPronounsDropdownUpdate(); + pronounsInput.addEventListener('change', onPronounsDropdownUpdate); + pronounsCustom.addEventListener('input', onPronounsCustomUpdate); +} |