初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
import {checkAppUrl} from '../common-page.ts';
|
||||
import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts';
|
||||
import {POST} from '../../modules/fetch.ts';
|
||||
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
|
||||
import {pathEscape} from '../../utils/url.ts';
|
||||
import {registerGlobalInitFunc} from '../../modules/observer.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
function onSecurityProtocolChange(): void {
|
||||
if (Number(document.querySelector<HTMLInputElement>('#security_protocol')?.value) > 0) {
|
||||
showElem('.has-tls');
|
||||
} else {
|
||||
hideElem('.has-tls');
|
||||
}
|
||||
}
|
||||
|
||||
export function initAdminCommon(): void {
|
||||
if (!document.querySelector('.page-content.admin')) return;
|
||||
|
||||
// check whether appUrl(ROOT_URL) is correct, if not, show an error message
|
||||
checkAppUrl();
|
||||
|
||||
initAdminUser();
|
||||
initAdminAuthentication();
|
||||
initAdminNotice();
|
||||
registerGlobalInitFunc('initRunnerBulkToolbar', initAdminRunnerBulk);
|
||||
}
|
||||
|
||||
function initAdminRunnerBulk(toolbar: HTMLElement) {
|
||||
const actionButtons = toolbar.querySelectorAll<HTMLButtonElement>('.runner-bulk-action');
|
||||
const formRunnerIds = toolbar.querySelector<HTMLInputElement>('form input[name="ids"]')!;
|
||||
const rowCheckboxes = document.querySelectorAll<HTMLInputElement>('.runner-bulk-select');
|
||||
const selectAll = document.querySelector<HTMLInputElement>('.runner-bulk-select-all');
|
||||
if (!selectAll) return;
|
||||
|
||||
const refresh = () => {
|
||||
const checked = Array.from(rowCheckboxes).filter((c) => c.checked);
|
||||
toggleElem(toolbar, checked.length > 0);
|
||||
for (const btn of actionButtons) {
|
||||
btn.querySelector<HTMLElement>('.runner-bulk-count')!.textContent = `(${checked.length})`;
|
||||
}
|
||||
selectAll.checked = checked.length > 0 && checked.length === rowCheckboxes.length;
|
||||
selectAll.indeterminate = checked.length > 0 && checked.length < rowCheckboxes.length;
|
||||
};
|
||||
|
||||
selectAll.addEventListener('change', () => {
|
||||
for (const cb of rowCheckboxes) cb.checked = selectAll.checked;
|
||||
refresh();
|
||||
});
|
||||
for (const cb of rowCheckboxes) cb.addEventListener('change', refresh);
|
||||
refresh();
|
||||
|
||||
const collectSelectedIds = () => {
|
||||
const ids = [];
|
||||
for (const cb of rowCheckboxes) {
|
||||
if (cb.checked) ids.push(cb.getAttribute('data-runner-id')!);
|
||||
}
|
||||
return ids.join(',');
|
||||
};
|
||||
formRunnerIds.value = collectSelectedIds();
|
||||
}
|
||||
|
||||
function initAdminUser() {
|
||||
const pageContent = document.querySelector('.page-content.admin.edit.user, .page-content.admin.new.user');
|
||||
if (!pageContent) return;
|
||||
|
||||
document.querySelector<HTMLInputElement>('#login_type')?.addEventListener('change', function () {
|
||||
if (this.value?.startsWith('0')) {
|
||||
document.querySelector<HTMLInputElement>('#user_name')?.removeAttribute('disabled');
|
||||
document.querySelector<HTMLInputElement>('#login_name')?.removeAttribute('required');
|
||||
hideElem('.non-local');
|
||||
showElem('.local');
|
||||
document.querySelector<HTMLInputElement>('#user_name')?.focus();
|
||||
|
||||
if (this.getAttribute('data-password') === 'required') {
|
||||
document.querySelector('#password')?.setAttribute('required', 'required');
|
||||
}
|
||||
} else {
|
||||
if (document.querySelector<HTMLDivElement>('.admin.edit.user')) {
|
||||
document.querySelector<HTMLInputElement>('#user_name')?.setAttribute('disabled', 'disabled');
|
||||
}
|
||||
document.querySelector<HTMLInputElement>('#login_name')?.setAttribute('required', 'required');
|
||||
showElem('.non-local');
|
||||
hideElem('.local');
|
||||
document.querySelector<HTMLInputElement>('#login_name')?.focus();
|
||||
|
||||
document.querySelector<HTMLInputElement>('#password')?.removeAttribute('required');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initAdminAuthentication() {
|
||||
const pageContent = document.querySelector('.page-content.admin.authentication');
|
||||
if (!pageContent) return;
|
||||
|
||||
const isNewPage = pageContent.classList.contains('new');
|
||||
const isEditPage = pageContent.classList.contains('edit');
|
||||
if (!isNewPage && !isEditPage) return;
|
||||
|
||||
function onUsePagedSearchChange() {
|
||||
const searchPageSizeElements = document.querySelectorAll<HTMLDivElement>('.search-page-size');
|
||||
if (document.querySelector<HTMLInputElement>('#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: boolean) {
|
||||
hideElem('.open_id_connect_auto_discovery_url, .open_id_connect_external_id_claim, .oauth2_use_custom_url');
|
||||
for (const input of document.querySelectorAll<HTMLInputElement>('.open_id_connect_auto_discovery_url input[required]')) {
|
||||
input.removeAttribute('required');
|
||||
}
|
||||
|
||||
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider')!.value;
|
||||
switch (provider) {
|
||||
case 'openidConnect':
|
||||
case 'aws-cognito':
|
||||
document.querySelector<HTMLInputElement>('.open_id_connect_auto_discovery_url input')!.setAttribute('required', 'required');
|
||||
showElem('.open_id_connect_auto_discovery_url');
|
||||
showElem('.open_id_connect_external_id_claim');
|
||||
break;
|
||||
default: {
|
||||
const elProviderCustomUrlSettings = document.querySelector<HTMLInputElement>(`#${provider}_customURLSettings`);
|
||||
if (!elProviderCustomUrlSettings) break; // some providers do not have custom URL settings
|
||||
const couldChangeCustomURLs = elProviderCustomUrlSettings.getAttribute('data-available') === 'true';
|
||||
const mustProvideCustomURLs = elProviderCustomUrlSettings.getAttribute('data-required') === 'true';
|
||||
if (couldChangeCustomURLs) {
|
||||
showElem('.oauth2_use_custom_url'); // show the checkbox
|
||||
}
|
||||
if (mustProvideCustomURLs) {
|
||||
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')!.checked = true; // make the checkbox checked
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const supportSshPublicKey = document.querySelector<HTMLInputElement>(`#${provider}_SupportSSHPublicKey`)?.value === 'true';
|
||||
toggleElem('.field.oauth2_ssh_public_key_claim_name', supportSshPublicKey);
|
||||
onOAuth2UseCustomURLChange(applyDefaultValues);
|
||||
}
|
||||
|
||||
function onOAuth2UseCustomURLChange(applyDefaultValues: boolean) {
|
||||
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider')!.value;
|
||||
hideElem('.oauth2_use_custom_url_field');
|
||||
for (const input of document.querySelectorAll<HTMLInputElement>('.oauth2_use_custom_url_field input[required]')) {
|
||||
input.removeAttribute('required');
|
||||
}
|
||||
|
||||
const elProviderCustomUrlSettings = document.querySelector(`#${provider}_customURLSettings`);
|
||||
if (elProviderCustomUrlSettings && document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')!.checked) {
|
||||
for (const custom of ['token_url', 'auth_url', 'profile_url', 'email_url', 'tenant']) {
|
||||
if (applyDefaultValues) {
|
||||
document.querySelector<HTMLInputElement>(`#oauth2_${custom}`)!.value = document.querySelector<HTMLInputElement>(`#${provider}_${custom}`)!.value;
|
||||
}
|
||||
const customInput = document.querySelector(`#${provider}_${custom}`);
|
||||
if (customInput?.getAttribute('data-available') === 'true') {
|
||||
for (const input of document.querySelectorAll(`.oauth2_${custom} input`)) {
|
||||
input.setAttribute('required', 'required');
|
||||
}
|
||||
showElem(`.oauth2_${custom}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onEnableLdapGroupsChange() {
|
||||
const checked = document.querySelector<HTMLInputElement>('.js-ldap-group-toggle')?.checked;
|
||||
toggleElem(document.querySelector('#ldap-group-options')!, checked);
|
||||
}
|
||||
|
||||
const elAuthType = document.querySelector<HTMLInputElement>('#auth_type')!;
|
||||
|
||||
// New authentication
|
||||
if (isNewPage) {
|
||||
const onAuthTypeChange = function () {
|
||||
hideElem('.ldap, .dldap, .smtp, .pam, .oauth2, .has-tls, .search-page-size, .sspi');
|
||||
|
||||
for (const input of document.querySelectorAll<HTMLInputElement>('.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<HTMLDivElement>('.binddnrequired')?.classList.remove('required');
|
||||
|
||||
const authType = elAuthType.value;
|
||||
switch (authType) {
|
||||
case '2': // LDAP
|
||||
showElem('.ldap');
|
||||
for (const input of document.querySelectorAll<HTMLInputElement>('.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<HTMLInputElement>('.smtp div.required input, .has-tls')) {
|
||||
input.setAttribute('required', 'required');
|
||||
}
|
||||
break;
|
||||
case '4': // PAM
|
||||
showElem('.pam');
|
||||
for (const input of document.querySelectorAll<HTMLInputElement>('.pam input')) {
|
||||
input.setAttribute('required', 'required');
|
||||
}
|
||||
break;
|
||||
case '5': // LDAP
|
||||
showElem('.dldap');
|
||||
for (const input of document.querySelectorAll<HTMLInputElement>('.dldap div.required:not(.ldap) input')) {
|
||||
input.setAttribute('required', 'required');
|
||||
}
|
||||
break;
|
||||
case '6': // OAuth2
|
||||
showElem('.oauth2');
|
||||
for (const input of document.querySelectorAll<HTMLInputElement>('.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<HTMLInputElement>('.sspi div.required input')) {
|
||||
input.setAttribute('required', 'required');
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (authType === '2' || authType === '5') {
|
||||
onSecurityProtocolChange();
|
||||
onEnableLdapGroupsChange();
|
||||
}
|
||||
if (authType === '2') {
|
||||
onUsePagedSearchChange();
|
||||
}
|
||||
};
|
||||
elAuthType.addEventListener('change', onAuthTypeChange);
|
||||
onAuthTypeChange();
|
||||
|
||||
document.querySelector<HTMLInputElement>('#security_protocol')?.addEventListener('change', onSecurityProtocolChange);
|
||||
document.querySelector<HTMLInputElement>('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
|
||||
document.querySelector<HTMLInputElement>('#oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true));
|
||||
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(true));
|
||||
|
||||
document.querySelector('.js-ldap-group-toggle')!.addEventListener('change', onEnableLdapGroupsChange);
|
||||
}
|
||||
// Edit authentication
|
||||
if (isEditPage) {
|
||||
const authType = elAuthType.value;
|
||||
if (authType === '2' || authType === '5') {
|
||||
document.querySelector<HTMLInputElement>('#security_protocol')?.addEventListener('change', onSecurityProtocolChange);
|
||||
document.querySelector('.js-ldap-group-toggle')!.addEventListener('change', onEnableLdapGroupsChange);
|
||||
onEnableLdapGroupsChange();
|
||||
if (authType === '2') {
|
||||
document.querySelector<HTMLInputElement>('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
|
||||
}
|
||||
} else if (authType === '6') {
|
||||
document.querySelector<HTMLInputElement>('#oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true));
|
||||
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(false));
|
||||
onOAuth2Change(false);
|
||||
}
|
||||
}
|
||||
|
||||
const elAuthName = document.querySelector<HTMLInputElement>('#auth_name')!;
|
||||
const onAuthNameChange = function () {
|
||||
// appSubUrl is either empty or is a path that starts with `/` and doesn't have a trailing slash.
|
||||
document.querySelector('#oauth2-callback-url')!.textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${pathEscape(elAuthName.value)}/callback`;
|
||||
};
|
||||
elAuthName.addEventListener('input', onAuthNameChange);
|
||||
onAuthNameChange();
|
||||
}
|
||||
|
||||
function initAdminNotice() {
|
||||
const pageContent = document.querySelector('.page-content.admin.notice');
|
||||
if (!pageContent) return;
|
||||
|
||||
const detailModal = document.querySelector<HTMLDivElement>('#detail-modal')!;
|
||||
|
||||
// Attach view detail modals
|
||||
queryElems(pageContent, '.view-detail', (el) => el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const elNoticeDesc = el.closest('tr')!.querySelector('.notice-description')!;
|
||||
const elModalDesc = detailModal.querySelector('.content pre')!;
|
||||
elModalDesc.textContent = elNoticeDesc.textContent;
|
||||
showFomanticModal(detailModal);
|
||||
}));
|
||||
|
||||
// Select actions
|
||||
const checkboxes = document.querySelectorAll<HTMLInputElement>('.select.table .ui.checkbox input');
|
||||
|
||||
queryElems(pageContent, '.select.action', (el) => el.addEventListener('click', () => {
|
||||
switch (el.getAttribute('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.querySelector<HTMLButtonElement>('#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.reload();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {ConfigFormValueMapper} from './config.ts';
|
||||
|
||||
test('ConfigFormValueMapper', () => {
|
||||
document.body.innerHTML = `
|
||||
<form>
|
||||
<input id="checkbox-unrelated" type="checkbox" value="v-unrelated" checked>
|
||||
|
||||
<!-- top-level key -->
|
||||
<input name="k1" type="checkbox" value="v-key-only" data-config-dyn-key="k1" data-config-value-json="true" data-config-value-type="boolean">
|
||||
|
||||
<input type="hidden" data-config-dyn-key="k2" data-config-value-json='"k2-val"'>
|
||||
<input name="k2">
|
||||
|
||||
<textarea name="repository.open-with.editor-apps"> a = b\n</textarea>
|
||||
|
||||
<input name="k-flipped-true" type="checkbox" data-config-value-type="flipped">
|
||||
<input name="k-flipped-false" type="checkbox" checked data-config-value-type="flipped">
|
||||
|
||||
<!-- sub key -->
|
||||
<input type="hidden" data-config-dyn-key="struct" data-config-value-json='{"SubBoolean": true, "SubTimestamp": 123456789, "OtherKey": "other-value"}'>
|
||||
<input name="struct.SubBoolean" type="checkbox" data-config-value-type="boolean">
|
||||
<input name="struct.SubTimestamp" type="datetime-local" data-config-value-type="timestamp">
|
||||
<textarea name="struct.NewKey">new-value</textarea>
|
||||
</form>
|
||||
`;
|
||||
|
||||
const form = document.querySelector('form')!;
|
||||
const mapper = new ConfigFormValueMapper(form);
|
||||
mapper.fillFromSystemConfig();
|
||||
const formData = mapper.collectToFormData();
|
||||
const result: Record<string, string> = {};
|
||||
const keys: string[] = [], values: string[] = [];
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key === 'key') keys.push(value as string);
|
||||
if (key === 'value') values.push(value as string);
|
||||
}
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
result[keys[i]] = values[i];
|
||||
}
|
||||
expect(result).toEqual({
|
||||
'k1': 'true',
|
||||
'k2': '"k2-val"',
|
||||
'k-flipped-false': 'false',
|
||||
'k-flipped-true': 'true',
|
||||
'repository.open-with.editor-apps': '[{"DisplayName":"a","OpenURL":"b"}]', // TODO: OPEN-WITH-EDITOR-APP-JSON: it must match backend
|
||||
'struct': '{"SubBoolean":true,"SubTimestamp":123456780,"OtherKey":"other-value","NewKey":"new-value"}',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import {showTemporaryTooltip} from '../../modules/tippy.ts';
|
||||
import {POST} from '../../modules/fetch.ts';
|
||||
import {registerGlobalInitFunc} from '../../modules/observer.ts';
|
||||
import {queryElems} from '../../utils/dom.ts';
|
||||
import {errorMessage} from '../../modules/errors.ts';
|
||||
import {submitFormFetchAction} from '../common-fetch-action.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
function collectCheckboxBooleanValue(el: HTMLInputElement): boolean {
|
||||
const valType = el.getAttribute('data-config-value-type') as ConfigValueType;
|
||||
if (valType === 'boolean') return el.checked;
|
||||
if (valType === 'flipped') return !el.checked;
|
||||
requireExplicitValueType(el);
|
||||
}
|
||||
|
||||
function initSystemConfigAutoCheckbox(el: HTMLInputElement) {
|
||||
el.addEventListener('change', async () => {
|
||||
// if the checkbox is inside a form, we assume it's handled by the form submit and do not send an individual request
|
||||
if (el.closest('form')) return;
|
||||
try {
|
||||
const data = new URLSearchParams({
|
||||
key: el.getAttribute('data-config-dyn-key')!,
|
||||
value: String(collectCheckboxBooleanValue(el)),
|
||||
});
|
||||
const resp = await POST(`${appSubUrl}/-/admin/config`, {data});
|
||||
const json: Record<string, any> = await resp.json();
|
||||
if (json.errorMessage) throw new Error(json.errorMessage);
|
||||
} catch (ex) {
|
||||
showTemporaryTooltip(el, errorMessage(ex));
|
||||
el.checked = !el.checked;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type GeneralFormFieldElement = HTMLInputElement;
|
||||
|
||||
function unsupportedElement(el: Element): never {
|
||||
// HINT: for future developers: if you need to handle a config that cannot be directly mapped to a form element, you should either:
|
||||
// * Add a "hidden" input to store the value (not configurable)
|
||||
// * Design a new "component" to handle the config
|
||||
throw new Error(`Unsupported config form value mapping for ${el.nodeName} (name=${(el as HTMLInputElement).name},type=${(el as HTMLInputElement).type}), please add more and design carefully`);
|
||||
}
|
||||
|
||||
function requireExplicitValueType(el: Element): never {
|
||||
throw new Error(`Unsupported config form value type for ${el.nodeName} (name=${(el as HTMLInputElement).name},type=${(el as HTMLInputElement).type}), please add explicit value type with "data-config-value-type" attribute`);
|
||||
}
|
||||
|
||||
// try to extract the subKey for the config value from the element name
|
||||
// * return '' if the element name exactly matches the config key, which means the value is directly stored in the element
|
||||
// * return null if the config key not match
|
||||
function extractElemConfigSubKey(el: GeneralFormFieldElement, dynKey: string): string | null {
|
||||
if (el.name === dynKey) return '';
|
||||
if (el.name.startsWith(`${dynKey}.`)) return el.name.slice(dynKey.length + 1); // +1 for the dot
|
||||
return null;
|
||||
}
|
||||
|
||||
// Due to the different design between HTML form elements and the JSON struct of the config values, we need to explicitly define some types.
|
||||
// * checkbox can be used for boolean value, it can also be used for multiple values (array)
|
||||
type ConfigValueType = 'boolean' | 'flipped' | 'string' | 'number' | 'timestamp'; // TODO: support more types like array, not used at the moment.
|
||||
|
||||
function toDatetimeLocalValue(unixSeconds: number) {
|
||||
const d = new Date(unixSeconds * 1000);
|
||||
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
export class ConfigFormValueMapper {
|
||||
form: HTMLFormElement;
|
||||
presetJsonValues: Record<string, any> = {};
|
||||
presetValueTypes: Record<string, ConfigValueType> = {};
|
||||
|
||||
constructor(form: HTMLFormElement) {
|
||||
this.form = form;
|
||||
for (const el of queryElems<HTMLInputElement>(form, '[data-config-value-json]')) {
|
||||
const dynKey = el.getAttribute('data-config-dyn-key')!;
|
||||
const jsonStr = el.getAttribute('data-config-value-json');
|
||||
try {
|
||||
this.presetJsonValues[dynKey] = JSON.parse(jsonStr || '{}'); // empty string also is valid, default to an empty object
|
||||
} catch (error) {
|
||||
this.presetJsonValues[dynKey] = {}; // in case the value in database is corrupted, don't break the whole form
|
||||
console.error(`Error parsing JSON for config ${dynKey}:`, error);
|
||||
}
|
||||
}
|
||||
for (const el of queryElems<HTMLInputElement>(form, '[data-config-value-type]')) {
|
||||
const valKey = el.getAttribute('data-config-dyn-key') || el.name;
|
||||
this.presetValueTypes[valKey] = el.getAttribute('data-config-value-type')! as ConfigValueType;
|
||||
}
|
||||
}
|
||||
|
||||
// try to assign the config value to the form element, return true if assigned successfully,
|
||||
// otherwise return false (e.g. the element is not related to the config key)
|
||||
assignConfigValueToFormElement(el: GeneralFormFieldElement, dynKey: string, cfgVal: any) {
|
||||
const subKey = extractElemConfigSubKey(el, dynKey);
|
||||
if (subKey === null) return false; // if not match, skip
|
||||
|
||||
const val = subKey ? cfgVal![subKey] : cfgVal;
|
||||
if (val === null) return true; // if name matches, but no value to assign, also succeed because the form element does exist
|
||||
const valType = this.presetValueTypes[el.name];
|
||||
if (el.matches('[type="checkbox"]')) {
|
||||
if (valType !== 'boolean') requireExplicitValueType(el);
|
||||
el.checked = Boolean(val ?? el.checked);
|
||||
} else if (el.matches('[type="datetime-local"]')) {
|
||||
if (valType !== 'timestamp') requireExplicitValueType(el);
|
||||
if (val) el.value = toDatetimeLocalValue(val);
|
||||
} else if (el.matches('textarea')) {
|
||||
el.value = String(val ?? el.value);
|
||||
} else if (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text') {
|
||||
el.value = String(val ?? el.value);
|
||||
} else {
|
||||
unsupportedElement(el);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
collectConfigValueFromElement(el: GeneralFormFieldElement) {
|
||||
let val: any;
|
||||
const valType = this.presetValueTypes[el.name];
|
||||
if (el.matches('[type="checkbox"]')) {
|
||||
// TODO: if it needs to support array values in the future,
|
||||
// it needs to iterate the "namedElems" to find all the checkboxes with the same name and collect values accordingly,
|
||||
// and set the namedElems[matchedIdx] to null to avoid duplicate processing.
|
||||
val = collectCheckboxBooleanValue(el);
|
||||
} else if (el.matches('[type="datetime-local"]')) {
|
||||
if (valType !== 'timestamp') requireExplicitValueType(el);
|
||||
val = Math.floor(new Date(el.value).getTime() / 1000) ?? 0; // NaN is fine to JSON.stringify, it becomes null.
|
||||
} else if (el.matches('textarea')) {
|
||||
val = el.value;
|
||||
} else if (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text') {
|
||||
val = el.value;
|
||||
} else {
|
||||
unsupportedElement(el);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
collectConfigSubValues(namedElems: Array<GeneralFormFieldElement | null>, dynKey: string, cfgVal: Record<string, any>) {
|
||||
for (let idx = 0; idx < namedElems.length; idx++) {
|
||||
const el = namedElems[idx];
|
||||
if (!el) continue;
|
||||
const subKey = extractElemConfigSubKey(el, dynKey);
|
||||
if (!subKey) continue; // if not match, skip
|
||||
cfgVal[subKey] = this.collectConfigValueFromElement(el);
|
||||
namedElems[idx] = null;
|
||||
}
|
||||
}
|
||||
|
||||
fillFromSystemConfig() {
|
||||
for (const [dynKey, cfgVal] of Object.entries(this.presetJsonValues)) {
|
||||
const elems = this.form.querySelectorAll<GeneralFormFieldElement>(`[name^="${CSS.escape(dynKey)}"]`);
|
||||
let assigned = false;
|
||||
for (const el of elems) {
|
||||
if (this.assignConfigValueToFormElement(el, dynKey, cfgVal)) {
|
||||
assigned = true;
|
||||
}
|
||||
}
|
||||
if (!assigned) throw new Error(`Could not find form element for config ${dynKey}, please check the form design and json struct`);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: OPEN-WITH-EDITOR-APP-JSON: need to use the same logic as backend
|
||||
marshalConfigValueOpenWithEditorApps(cfgVal: string): string {
|
||||
const apps: Array<{DisplayName: string, OpenURL: string}> = [];
|
||||
const lines = cfgVal.split('\n');
|
||||
for (const line of lines) {
|
||||
let [displayName, openUrl] = line.split('=', 2);
|
||||
displayName = displayName.trim();
|
||||
openUrl = openUrl?.trim() ?? '';
|
||||
if (!displayName || !openUrl) continue;
|
||||
apps.push({DisplayName: displayName, OpenURL: openUrl});
|
||||
}
|
||||
return JSON.stringify(apps);
|
||||
}
|
||||
|
||||
marshalConfigValue(dynKey: string, cfgVal: any): string {
|
||||
if (dynKey === 'repository.open-with.editor-apps') return this.marshalConfigValueOpenWithEditorApps(cfgVal);
|
||||
return JSON.stringify(cfgVal);
|
||||
}
|
||||
|
||||
collectToFormData(): FormData {
|
||||
const namedElems: Array<GeneralFormFieldElement | null> = [];
|
||||
queryElems(this.form, '[name]', (el) => namedElems.push(el as GeneralFormFieldElement));
|
||||
|
||||
// first, process the config options with sub values, for example:
|
||||
// merge "foo.bar.Enabled", "foo.bar.Message" to "foo.bar"
|
||||
const formData = new FormData();
|
||||
for (const [dynKey, cfgVal] of Object.entries(this.presetJsonValues)) {
|
||||
this.collectConfigSubValues(namedElems, dynKey, cfgVal);
|
||||
formData.append('key', dynKey);
|
||||
formData.append('value', this.marshalConfigValue(dynKey, cfgVal));
|
||||
}
|
||||
|
||||
// now, the namedElems should only contain the config options without sub values,
|
||||
// directly store the value in formData with key as the element name, for example:
|
||||
// "foo.enabled" => "true"
|
||||
for (const el of namedElems) {
|
||||
if (!el) continue;
|
||||
const dynKey = el.name;
|
||||
const newVal = this.collectConfigValueFromElement(el);
|
||||
formData.append('key', dynKey);
|
||||
formData.append('value', this.marshalConfigValue(dynKey, newVal));
|
||||
}
|
||||
return formData;
|
||||
}
|
||||
}
|
||||
|
||||
function initSystemConfigForm(form: HTMLFormElement) {
|
||||
const formMapper = new ConfigFormValueMapper(form);
|
||||
formMapper.fillFromSystemConfig();
|
||||
form.addEventListener('submit', async (e) => {
|
||||
if (!form.reportValidity()) return;
|
||||
e.preventDefault();
|
||||
const formData = formMapper.collectToFormData();
|
||||
await submitFormFetchAction(form, {formData});
|
||||
});
|
||||
}
|
||||
|
||||
export function initAdminConfigs(): void {
|
||||
registerGlobalInitFunc('initAdminConfigSettings', (el) => {
|
||||
queryElems(el, 'input[type="checkbox"][data-config-dyn-key]', initSystemConfigAutoCheckbox);
|
||||
queryElems(el, 'form.system-config-form', initSystemConfigForm);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {toggleElem} from '../../utils/dom.ts';
|
||||
import {POST} from '../../modules/fetch.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
export async function initAdminSelfCheck() {
|
||||
const elCheckByFrontend = document.querySelector('#self-check-by-frontend');
|
||||
if (!elCheckByFrontend) return;
|
||||
|
||||
const elContent = document.querySelector<HTMLDivElement>('.page-content.admin .admin-setting-content')!;
|
||||
|
||||
// send frontend self-check request
|
||||
const resp = await POST(`${appSubUrl}/-/admin/self_check`, {
|
||||
data: new URLSearchParams({
|
||||
location_origin: window.location.origin,
|
||||
now: String(Date.now()), // TODO: check time difference between server and client
|
||||
}),
|
||||
});
|
||||
const json: Record<string, any> = await resp.json();
|
||||
toggleElem(elCheckByFrontend, Boolean(json.problems?.length));
|
||||
for (const problem of json.problems ?? []) {
|
||||
const elProblem = document.createElement('div');
|
||||
elProblem.classList.add('ui', 'warning', 'message');
|
||||
elProblem.textContent = problem;
|
||||
elCheckByFrontend.append(elProblem);
|
||||
}
|
||||
|
||||
// only show the "no problem" if there is no visible "self-check-problem"
|
||||
const hasProblem = Boolean(elContent.querySelectorAll('.self-check-problem:not(.tw-hidden)').length);
|
||||
toggleElem(elContent.querySelector('.self-check-no-problem')!, !hasProblem);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export function initAdminUserListSearchForm(): void {
|
||||
const searchForm = window.config.pageData.adminUserListSearchForm;
|
||||
if (!searchForm) return;
|
||||
|
||||
const form = document.querySelector<HTMLFormElement>('#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<HTMLInputElement>(`input[name="status_filter[${k}]"][value="${v}"]`)) {
|
||||
input.checked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const radio of form.querySelectorAll<HTMLInputElement>('input[type=radio]')) {
|
||||
radio.addEventListener('click', () => {
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
|
||||
const resetButtons = form.querySelectorAll<HTMLAnchorElement>('.j-reset-status-filter');
|
||||
for (const button of resetButtons) {
|
||||
button.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
for (const input of form.querySelectorAll<HTMLInputElement>('input[type=radio]')) {
|
||||
if (input.name.startsWith('status_filter[')) {
|
||||
input.checked = false;
|
||||
}
|
||||
}
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {isDarkTheme} from '../utils.ts';
|
||||
|
||||
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': {
|
||||
// ref: https://github.com/mCaptcha/glue/blob/master/packages/vanilla/README.md
|
||||
// sample: https://github.com/mCaptcha/glue/blob/master/packages/vanilla/static/embeded.html
|
||||
// @mcaptcha/vanilla-glue 0.1.0-rc2 auto-runs on module load, use the existing elements to render.
|
||||
await import('@mcaptcha/vanilla-glue');
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {getCurrentLocale} from '../utils.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {localUserSettings} from '../modules/user-settings.ts';
|
||||
|
||||
const {pageData} = window.config;
|
||||
|
||||
async function initInputCitationValue(citationCopyApa: HTMLButtonElement, citationCopyBibtex: HTMLButtonElement) {
|
||||
const [{Cite, plugins}] = await Promise.all([
|
||||
import('@citation-js/core'),
|
||||
import('@citation-js/plugin-software-formats'),
|
||||
import('@citation-js/plugin-bibtex'),
|
||||
import('@citation-js/plugin-csl'),
|
||||
]);
|
||||
const citationFileContent = pageData.citationFileContent!;
|
||||
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 apaOutput = citationFormatter.format('bibliography', {template: 'apa', lang});
|
||||
const bibtexOutput = citationFormatter.format('bibtex', {lang});
|
||||
citationCopyBibtex.setAttribute('data-text', bibtexOutput);
|
||||
citationCopyApa.setAttribute('data-text', apaOutput);
|
||||
}
|
||||
|
||||
export async function initCitationFileCopyContent() {
|
||||
const defaultCitationFormat = 'apa'; // apa or bibtex
|
||||
|
||||
if (!pageData.citationFileContent) return;
|
||||
|
||||
const citationCopyApa = document.querySelector<HTMLButtonElement>('#citation-copy-apa')!;
|
||||
const citationCopyBibtex = document.querySelector<HTMLButtonElement>('#citation-copy-bibtex')!;
|
||||
const inputContent = document.querySelector<HTMLInputElement>('#citation-copy-content');
|
||||
|
||||
if ((!citationCopyApa && !citationCopyBibtex) || !inputContent) return;
|
||||
|
||||
const updateUi = () => {
|
||||
const isBibtex = localUserSettings.getString('citation-copy-format', defaultCitationFormat) === 'bibtex';
|
||||
const copyContent = (isBibtex ? citationCopyBibtex : citationCopyApa).getAttribute('data-text')!;
|
||||
inputContent.value = copyContent;
|
||||
citationCopyBibtex.classList.toggle('primary', isBibtex);
|
||||
citationCopyApa.classList.toggle('primary', !isBibtex);
|
||||
};
|
||||
|
||||
document.querySelector('#cite-repo-button')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await initInputCitationValue(citationCopyApa, citationCopyBibtex);
|
||||
} catch (e) {
|
||||
console.error(`initCitationFileCopyContent error: ${errorMessage(e)}`, e);
|
||||
return;
|
||||
}
|
||||
updateUi();
|
||||
|
||||
citationCopyApa.addEventListener('click', () => {
|
||||
localUserSettings.setString('citation-copy-format', 'apa');
|
||||
updateUi();
|
||||
});
|
||||
|
||||
citationCopyBibtex.addEventListener('click', () => {
|
||||
localUserSettings.setString('citation-copy-format', 'bibtex');
|
||||
updateUi();
|
||||
});
|
||||
|
||||
inputContent.addEventListener('click', () => {
|
||||
inputContent.select();
|
||||
});
|
||||
|
||||
showFomanticModal(document.querySelector('#cite-repo-modal'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {createApp} from 'vue';
|
||||
|
||||
export async function initRepoCodeFrequency() {
|
||||
const el = document.querySelector('#repo-code-frequency-chart');
|
||||
if (!el) return;
|
||||
|
||||
const {default: RepoCodeFrequency} = await import('../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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
|
||||
export async function initColorPickers() {
|
||||
let imported = false;
|
||||
registerGlobalInitFunc('initColorPicker', async (el) => {
|
||||
if (!imported) {
|
||||
await Promise.all([
|
||||
import('vanilla-colorful/hex-color-picker.js'),
|
||||
import('../../css/features/colorpicker.css'),
|
||||
]);
|
||||
imported = true;
|
||||
}
|
||||
initPicker(el);
|
||||
});
|
||||
}
|
||||
|
||||
function updateSquare(el: HTMLElement, newValue: string): void {
|
||||
el.style.color = /#[0-9a-f]{6}/i.test(newValue) ? newValue : 'transparent';
|
||||
}
|
||||
|
||||
function updatePicker(el: HTMLElement, newValue: string): void {
|
||||
el.setAttribute('color', newValue);
|
||||
}
|
||||
|
||||
function initPicker(el: HTMLElement): void {
|
||||
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 as HTMLInputElement).value);
|
||||
updatePicker(picker, (e.target as HTMLInputElement).value);
|
||||
});
|
||||
|
||||
createTippy(input, {
|
||||
trigger: 'focus click',
|
||||
theme: 'bare',
|
||||
hideOnClick: true,
|
||||
content: picker,
|
||||
placement: 'bottom-start',
|
||||
interactive: true,
|
||||
onShow() {
|
||||
updatePicker(picker, input.value);
|
||||
},
|
||||
});
|
||||
|
||||
// init random color & precolors
|
||||
const setSelectedColor = (color: string) => {
|
||||
input.value = color;
|
||||
input.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
updateSquare(square, color);
|
||||
};
|
||||
el.querySelector('.generate-random-color')!.addEventListener('click', () => {
|
||||
const newValue = `#${Math.floor(Math.random() * 0xFFFFFF).toString(16).padStart(6, '0')}`;
|
||||
setSelectedColor(newValue);
|
||||
});
|
||||
for (const colorEl of el.querySelectorAll<HTMLElement>('.precolors .color')) {
|
||||
colorEl.addEventListener('click', (e) => {
|
||||
const newValue = (e.target as HTMLElement).getAttribute('data-color-hex')!;
|
||||
setSelectedColor(newValue);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {toggleElem, toggleElemClass} from '../utils/dom.ts';
|
||||
|
||||
export function initActionsPermissionsForm(): void {
|
||||
registerGlobalInitFunc('initRepoActionsPermissionsForm', initRepoActionsPermissionsForm);
|
||||
registerGlobalInitFunc('initOwnerActionsPermissionsForm', initOwnerActionsPermissionsForm);
|
||||
}
|
||||
|
||||
function initRepoActionsPermissionsForm(form: HTMLFormElement) {
|
||||
initActionsOverrideOwnerConfig(form);
|
||||
initActionsPermissionTable(form);
|
||||
}
|
||||
|
||||
function initOwnerActionsPermissionsForm(form: HTMLFormElement) {
|
||||
initActionsPermissionTable(form);
|
||||
}
|
||||
|
||||
function initActionsPermissionTable(form: HTMLFormElement) {
|
||||
// show or hide permissions table based on enable max permissions checkbox (aka: whether you use custom permissions or not)
|
||||
const permTable = form.querySelector<HTMLTableElement>('.js-permissions-table')!;
|
||||
const enableMaxCheckbox = form.querySelector<HTMLInputElement>('input[name=enable_max_permissions]')!;
|
||||
const onEnableMaxCheckboxChange = () => toggleElem(permTable, enableMaxCheckbox.checked);
|
||||
onEnableMaxCheckboxChange();
|
||||
enableMaxCheckbox.addEventListener('change', onEnableMaxCheckboxChange);
|
||||
}
|
||||
|
||||
function initActionsOverrideOwnerConfig(form: HTMLFormElement) {
|
||||
// enable or disable repo token permissions config section based on override owner config checkbox
|
||||
const overrideOwnerConfig = form.querySelector<HTMLInputElement>('input[name=override_owner_config]')!;
|
||||
const repoTokenPermConfigSection = form.querySelector('.js-repo-token-permissions-config')!;
|
||||
const onOverrideOwnerConfigChange = () => toggleElemClass(repoTokenPermConfigSection, 'container-disabled', !overrideOwnerConfig.checked);
|
||||
onOverrideOwnerConfigChange();
|
||||
overrideOwnerConfig.addEventListener('change', onOverrideOwnerConfigChange);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {assignElementProperty, type ElementWithAssignableProperties} from './common-button.ts';
|
||||
|
||||
test('assignElementProperty', () => {
|
||||
const elForm = document.createElement('form');
|
||||
assignElementProperty(elForm, 'action', '/test-link');
|
||||
expect(elForm.action).contains('/test-link'); // the DOM always returns absolute URL
|
||||
expect(elForm.getAttribute('action')).eq('/test-link');
|
||||
assignElementProperty(elForm, 'text-content', 'dummy');
|
||||
expect(elForm.textContent).toBe('dummy');
|
||||
|
||||
// mock a form with its property "action" overwritten by an input element
|
||||
const elFormWithAction = new class implements ElementWithAssignableProperties {
|
||||
action = document.createElement('input'); // now "form.action" is not string, but an input element
|
||||
_attrs: Record<string, string> = {};
|
||||
setAttribute(name: string, value: string) { this._attrs[name] = value }
|
||||
getAttribute(name: string): string | null { return this._attrs[name] }
|
||||
}();
|
||||
assignElementProperty(elFormWithAction, 'action', '/bar');
|
||||
expect(elFormWithAction.getAttribute('action')).eq('/bar');
|
||||
|
||||
const elInput = document.createElement('input');
|
||||
expect(elInput.readOnly).toBe(false);
|
||||
assignElementProperty(elInput, 'read-only', 'true');
|
||||
expect(elInput.readOnly).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {addDelegatedEventListener, hideElem, isElemVisible, showElem, toggleElem} from '../utils/dom.ts';
|
||||
import {showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {camelize} from 'vue';
|
||||
import {applyAutoFocus} from './common-page.ts';
|
||||
|
||||
export function initGlobalButtonClickOnEnter(): void {
|
||||
addDelegatedEventListener(document, 'keypress', 'div.ui.button, span.ui.button', (el, e: KeyboardEvent) => {
|
||||
if (e.code === 'Space' || e.code === 'Enter') {
|
||||
e.preventDefault();
|
||||
el.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function initGlobalDeleteButton(): void {
|
||||
// ".delete-button" shows a confirmation modal defined by `data-modal-id` attribute.
|
||||
// Some model/form elements will be filled by `data-id` / `data-name` / `data-data-xxx` attributes.
|
||||
// If there is a form defined by `data-form`, then the form will be submitted as-is (without any modification).
|
||||
// If there is no form, then the data will be posted to `data-url`.
|
||||
// TODO: do not use this method in new code. `show-modal` / `link-action(data-modal-confirm)` does far better than this.
|
||||
// FIXME: all legacy `delete-button` should be refactored to use `show-modal` or `link-action`
|
||||
for (const btn of document.querySelectorAll<HTMLElement>('.delete-button')) {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// eslint-disable-next-line github/no-dataset -- code depends on the camel-casing
|
||||
const dataObj = btn.dataset;
|
||||
|
||||
const modalId = btn.getAttribute('data-modal-id');
|
||||
const modal = document.querySelector(`.delete.modal${modalId ? `#${modalId}` : ''}`)!;
|
||||
|
||||
// set the modal "display name" by `data-name`
|
||||
const modalNameEl = modal.querySelector('.name');
|
||||
if (modalNameEl) modalNameEl.textContent = btn.getAttribute('data-name');
|
||||
|
||||
// fill the modal elements with data-xxx attributes: `data-data-organization-name="..."` => `<span class="dataOrganizationName">...</span>`
|
||||
for (const [key, value] of Object.entries(dataObj)) {
|
||||
if (key.startsWith('data')) {
|
||||
const textEl = modal.querySelector(`.${key}`);
|
||||
if (textEl) textEl.textContent = value ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
showFomanticModal(modal, {
|
||||
closable: false,
|
||||
onApprove: () => {
|
||||
// if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."`
|
||||
if (btn.getAttribute('data-type') === 'form') {
|
||||
const formSelector = btn.getAttribute('data-form')!;
|
||||
const form = document.querySelector<HTMLFormElement>(formSelector);
|
||||
if (!form) throw new Error(`no form named ${formSelector} found`);
|
||||
modal.classList.add('is-loading'); // the form is not in the modal, so also add loading indicator to the modal
|
||||
form.classList.add('is-loading');
|
||||
form.submit();
|
||||
return false; // prevent modal from closing automatically
|
||||
}
|
||||
|
||||
// prepare an AJAX form by data attributes
|
||||
const postData = new FormData();
|
||||
for (const [key, value] of Object.entries(dataObj)) {
|
||||
if (key.startsWith('data')) { // for data-data-xxx (HTML) -> dataXxx (form)
|
||||
postData.append(key.slice(4), String(value));
|
||||
}
|
||||
if (key === 'id') { // for data-id="..."
|
||||
postData.append('id', String(value));
|
||||
}
|
||||
}
|
||||
(async () => {
|
||||
const response = await POST(btn.getAttribute('data-url')!, {data: postData});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
window.location.href = data.redirect;
|
||||
}
|
||||
})();
|
||||
modal.classList.add('is-loading'); // the request is in progress, so also add loading indicator to the modal
|
||||
return false; // prevent modal from closing automatically
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onShowPanelClick(el: HTMLElement, e: MouseEvent) {
|
||||
// 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 = el.getAttribute('data-panel')!;
|
||||
const elems = el.classList.contains('toggle') ? toggleElem(sel) : showElem(sel);
|
||||
for (const elem of elems) {
|
||||
if (isElemVisible(elem as HTMLElement)) {
|
||||
applyAutoFocus(elem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onHidePanelClick(el: HTMLElement, e: MouseEvent) {
|
||||
// a `.hide-panel` element can hide a panel, by `data-panel="selector"` or `data-panel-closest="selector"`
|
||||
e.preventDefault();
|
||||
let sel = el.getAttribute('data-panel');
|
||||
if (sel) {
|
||||
hideElem(sel);
|
||||
return;
|
||||
}
|
||||
sel = el.getAttribute('data-panel-closest');
|
||||
if (sel) {
|
||||
hideElem((el.parentNode as HTMLElement).closest(sel)!);
|
||||
return;
|
||||
}
|
||||
throw new Error('no panel to hide'); // should never happen, otherwise there is a bug in code
|
||||
}
|
||||
|
||||
export type ElementWithAssignableProperties = {
|
||||
getAttribute: (name: string) => string | null;
|
||||
setAttribute: (name: string, value: string) => void;
|
||||
} & Record<string, any>;
|
||||
|
||||
export function assignElementProperty(el: ElementWithAssignableProperties, kebabName: string, val: string) {
|
||||
const camelizedName = camelize(kebabName);
|
||||
const old = el[camelizedName];
|
||||
if (typeof old === 'boolean') {
|
||||
el[camelizedName] = val === 'true';
|
||||
} else if (typeof old === 'number') {
|
||||
el[camelizedName] = parseFloat(val);
|
||||
} else if (typeof old === 'string') {
|
||||
el[camelizedName] = val;
|
||||
} else if (old?.nodeName) {
|
||||
// "form" has an edge case: its "<input name=action>" element overwrites the "action" property, we can only set attribute
|
||||
el.setAttribute(kebabName, val);
|
||||
} else {
|
||||
// in the future, we could introduce a better typing system like `data-modal-form.action:string="..."`
|
||||
throw new Error(`cannot assign element property "${camelizedName}" by value "${val}"`);
|
||||
}
|
||||
}
|
||||
|
||||
function onShowModalClick(el: HTMLElement, e: MouseEvent) {
|
||||
// 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 '[name=target]'
|
||||
// * Then, try to query '.target'
|
||||
// * Then, try to query 'target' as HTML tag
|
||||
// If there is a ".{prop-name}" part like "data-modal-form.action", the "form" element's "action" property will be set, the "prop-name" will be camel-cased to "propName".
|
||||
e.preventDefault();
|
||||
const modalSelector = el.getAttribute('data-modal')!;
|
||||
const elModal = document.querySelector(modalSelector);
|
||||
if (!elModal) throw new Error('no modal for this action');
|
||||
|
||||
const modalAttrPrefix = 'data-modal-';
|
||||
for (const attrib of el.attributes) {
|
||||
if (!attrib.name.startsWith(modalAttrPrefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length);
|
||||
const [attrTargetName, attrTargetProp] = attrTargetCombo.split('.');
|
||||
// try to find target by: "#target" -> "[name=target]" -> ".target" -> "<target> tag", and then try the modal itself
|
||||
const attrTarget = elModal.querySelector(`#${attrTargetName}`) ||
|
||||
elModal.querySelector(`[name=${attrTargetName}]`) ||
|
||||
elModal.querySelector(`.${attrTargetName}`) ||
|
||||
elModal.querySelector(`${attrTargetName}`) ||
|
||||
(elModal.matches(`${attrTargetName}`) || elModal.matches(`#${attrTargetName}`) || elModal.matches(`.${attrTargetName}`) ? elModal : null);
|
||||
if (!attrTarget) {
|
||||
if (!window.config.runModeIsProd) throw new Error(`attr target "${attrTargetCombo}" not found for modal`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attrTargetProp) {
|
||||
assignElementProperty(attrTarget, attrTargetProp, attrib.value);
|
||||
} else if (attrTarget.matches('input, textarea')) {
|
||||
(attrTarget as HTMLInputElement | HTMLTextAreaElement).value = attrib.value; // FIXME: add more supports like checkbox
|
||||
} else {
|
||||
attrTarget.textContent = attrib.value; // FIXME: it should be more strict here, only handle div/span/p
|
||||
}
|
||||
}
|
||||
|
||||
showFomanticModal(elModal);
|
||||
}
|
||||
|
||||
export function initGlobalButtons(): void {
|
||||
// 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")
|
||||
addDelegatedEventListener(document, 'click', 'form button.ui.cancel.button', (_ /* el */, e) => e.preventDefault());
|
||||
|
||||
// Ideally these "button" events should be handled by registerGlobalEventFunc
|
||||
// Refactoring would involve too many changes, so at the moment, just use the global event listener.
|
||||
addDelegatedEventListener(document, 'click', '.show-panel, .hide-panel, .show-modal', (el, e: MouseEvent) => {
|
||||
if (el.classList.contains('show-panel')) {
|
||||
onShowPanelClick(el, e);
|
||||
} else if (el.classList.contains('hide-panel')) {
|
||||
onHidePanelClick(el, e);
|
||||
} else if (el.classList.contains('show-modal')) {
|
||||
onShowModalClick(el, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {execPseudoSelectorCommands, handleFetchActionSuccessJson} from './common-fetch-action.ts';
|
||||
|
||||
test('execPseudoSelectorCommands', () => {
|
||||
window.document.body.innerHTML = `
|
||||
<div id="d1">
|
||||
<ul id="u1">
|
||||
<li class="x"></li>
|
||||
</ul>
|
||||
<ul id="u2">
|
||||
<li class="x"></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="d2">
|
||||
<ul id="u3">
|
||||
<li class="x"></li>
|
||||
</ul>
|
||||
</div>`;
|
||||
|
||||
let ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '');
|
||||
expect(ret.targets).toEqual([document.querySelector('#u1')]);
|
||||
|
||||
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$this');
|
||||
expect(ret.targets).toEqual([document.querySelector('#u1')]);
|
||||
expect(ret.cmdInnerHTML).toBeFalsy();
|
||||
expect(ret.cmdMorph).toBeFalsy();
|
||||
|
||||
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$body $morph $innerHTML');
|
||||
expect(ret.targets).toEqual([document.body]);
|
||||
expect(ret.cmdInnerHTML).toBeTruthy();
|
||||
expect(ret.cmdMorph).toBeTruthy();
|
||||
|
||||
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$body .x');
|
||||
expect(ret.targets.length).toEqual(3);
|
||||
expect(ret.targets).toEqual(Array.from(document.querySelectorAll('.x')));
|
||||
|
||||
ret = execPseudoSelectorCommands(document.querySelector('#u1 .x')!, '$closest(div) .x');
|
||||
expect(ret.targets.length).toEqual(2);
|
||||
expect(ret.targets).toEqual(Array.from(document.querySelectorAll('#d1 .x')));
|
||||
});
|
||||
|
||||
test('handleFetchActionSuccessJson', async () => {
|
||||
const spyAssign = vi.spyOn(window.location, 'assign').mockImplementation(() => {});
|
||||
const spyReload = vi.spyOn(window.location, 'reload').mockImplementation(() => {});
|
||||
|
||||
await handleFetchActionSuccessJson(document.body, {redirect: '/'});
|
||||
expect(spyAssign).toHaveBeenCalledTimes(1);
|
||||
expect(spyReload).toHaveBeenCalledTimes(0);
|
||||
vi.resetAllMocks();
|
||||
|
||||
await handleFetchActionSuccessJson(document.body, {redirect: ''});
|
||||
expect(spyAssign).toHaveBeenCalledTimes(0);
|
||||
expect(spyReload).toHaveBeenCalledTimes(1);
|
||||
vi.resetAllMocks();
|
||||
|
||||
await handleFetchActionSuccessJson(document.body, {});
|
||||
expect(spyAssign).toHaveBeenCalledTimes(0);
|
||||
expect(spyReload).toHaveBeenCalledTimes(1);
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
import {GET, request} from '../modules/fetch.ts';
|
||||
import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
|
||||
import {addDelegatedEventListener, createElementFromHTML} from '../utils/dom.ts';
|
||||
import {errorMessage, errorName} from '../modules/errors.ts';
|
||||
import {confirmModal, createConfirmModal} from './comp/ConfirmModal.ts';
|
||||
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
||||
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||
import {Idiomorph} from 'idiomorph';
|
||||
import {parseDom} from '../utils.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
|
||||
const {appSubUrl, runModeIsProd} = window.config;
|
||||
|
||||
type FetchActionOpts = {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: HeadersInit;
|
||||
body?: FormData;
|
||||
formSubmitter?: HTMLElement | null;
|
||||
|
||||
// pseudo selectors/commands to update the current page with the response text when the response is text (html)
|
||||
// e.g.: "$this", "$innerHTML", "$closest(tr) td .the-class", "$body #the-id"
|
||||
successSync: string;
|
||||
|
||||
// the loading indicator element selector, it uses the same syntax as "data-fetch-sync" to find the element(s)
|
||||
// empty means no loading indicator, "$this" means the element itself
|
||||
loadingIndicator: string;
|
||||
};
|
||||
|
||||
// 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: string) {
|
||||
// In production, if the link can be directly navigated by browser, we just do normal redirection, which is faster.
|
||||
// Otherwise, need to use backend to do redirection:
|
||||
// * Also do so in development, to make sure the redirection logic is always tested by real users
|
||||
const needBackendHelp = redirect.includes('#');
|
||||
if (runModeIsProd && !needBackendHelp) {
|
||||
window.location.assign(redirect);
|
||||
return;
|
||||
}
|
||||
|
||||
// use backend to do redirection, which can bypass the browser's limitations of "location"
|
||||
const form = createElementFromHTML<HTMLFormElement>(html`<form method="post"></form>`);
|
||||
form.action = `${appSubUrl}/-/fetch-redirect?redirect=${encodeURIComponent(redirect)}`;
|
||||
document.body.append(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function toggleLoadingIndicator(el: HTMLElement, opt: FetchActionOpts, isLoading: boolean) {
|
||||
const loadingIndicatorElems = opt.loadingIndicator ? execPseudoSelectorCommands(el, opt.loadingIndicator).targets : [];
|
||||
for (const indicatorEl of loadingIndicatorElems) {
|
||||
if (isLoading) {
|
||||
// for button or input element, we can directly disable it, it looks better than adding a loading spinner
|
||||
if ('disabled' in indicatorEl) {
|
||||
indicatorEl.disabled = true;
|
||||
} else {
|
||||
indicatorEl.classList.add('is-loading');
|
||||
if (indicatorEl.clientHeight < 50) indicatorEl.classList.add('loading-icon-2px');
|
||||
}
|
||||
} else {
|
||||
if ('disabled' in indicatorEl) {
|
||||
indicatorEl.disabled = false;
|
||||
} else {
|
||||
indicatorEl.classList.remove('is-loading', 'loading-icon-2px');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleFetchActionSuccessJson(el: HTMLElement, respJson: any) {
|
||||
ignoreAreYouSure(el); // ignore the areYouSure check before reloading
|
||||
const redirect = respJson?.redirect;
|
||||
if (typeof redirect === 'string' && redirect) {
|
||||
fetchActionDoRedirect(redirect);
|
||||
} else {
|
||||
// reserved behavior, in the future, there can be more fields to introduce more behaviors
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFetchActionSuccess(el: HTMLElement, opt: FetchActionOpts, resp: Response) {
|
||||
const isRespJson = resp.headers.get('content-type')?.includes('application/json');
|
||||
const respText = await resp.text();
|
||||
const respJson = isRespJson ? JSON.parse(respText) : null;
|
||||
if (isRespJson) {
|
||||
await handleFetchActionSuccessJson(el, respJson);
|
||||
} else if (opt.successSync) {
|
||||
await handleFetchActionSuccessSync(el, opt.successSync, respText);
|
||||
} else {
|
||||
showErrorToast(`Unsupported fetch action response, expected JSON but got: ${respText.substring(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFetchActionError(resp: Response) {
|
||||
const isRespJson = resp.headers.get('content-type')?.includes('application/json');
|
||||
const respText = await resp.text();
|
||||
const respJson = isRespJson ? JSON.parse(respText) : null;
|
||||
if (respJson?.errorMessage) {
|
||||
// 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.
|
||||
showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
|
||||
} else {
|
||||
showErrorToast(`Error ${resp.status} ${resp.statusText}. Response: ${respText.substring(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildFetchActionUrl(el: HTMLElement, opt: FetchActionOpts) {
|
||||
let url = opt.url;
|
||||
if ('name' in el && 'value' in el) {
|
||||
// ref: https://htmx.org/attributes/hx-get/
|
||||
// If the element with the hx-get attribute also has a value, this will be included as a parameter
|
||||
const name = (el as HTMLInputElement).name;
|
||||
const val = (el as HTMLInputElement).value;
|
||||
const u = new URL(url, window.location.href);
|
||||
if (name && !u.searchParams.has(name)) {
|
||||
u.searchParams.set(name, val);
|
||||
url = u.toString();
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function performActionRequest(el: HTMLElement, opt: FetchActionOpts) {
|
||||
const attrIsLoading = 'data-fetch-is-loading';
|
||||
if (el.getAttribute(attrIsLoading)) return;
|
||||
if (!await confirmFetchAction(opt.formSubmitter ?? el)) return;
|
||||
|
||||
el.setAttribute(attrIsLoading, 'true');
|
||||
toggleLoadingIndicator(el, opt, true);
|
||||
|
||||
try {
|
||||
const url = buildFetchActionUrl(el, opt);
|
||||
const headers = new Headers(opt.headers);
|
||||
headers.set('X-Gitea-Fetch-Action', '1');
|
||||
const resp = await request(url, {method: opt.method, body: opt.body, headers});
|
||||
if (resp.ok) {
|
||||
await handleFetchActionSuccess(el, opt, resp);
|
||||
return;
|
||||
}
|
||||
await handleFetchActionError(resp);
|
||||
} catch (err) {
|
||||
if (errorName(err) !== 'AbortError') {
|
||||
console.error(`Fetch action request error:`, err);
|
||||
showErrorToast(`Error: ${errorMessage(err)}`);
|
||||
}
|
||||
} finally {
|
||||
toggleLoadingIndicator(el, opt, false);
|
||||
el.removeAttribute(attrIsLoading);
|
||||
}
|
||||
}
|
||||
|
||||
type SubmitFormFetchActionOpts = {
|
||||
formSubmitter?: HTMLElement | null;
|
||||
formData?: FormData;
|
||||
};
|
||||
|
||||
function prepareFormFetchActionOpts(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}): FetchActionOpts {
|
||||
const formMethodUpper = formEl.getAttribute('method')?.toUpperCase() || 'GET';
|
||||
const formActionUrl = formEl.getAttribute('action') || window.location.href;
|
||||
const formData = opts.formData ?? new FormData(formEl);
|
||||
const [submitterName, submitterValue] = [opts.formSubmitter?.getAttribute('name'), opts.formSubmitter?.getAttribute('value')];
|
||||
if (submitterName) {
|
||||
formData.append(submitterName, submitterValue || '');
|
||||
}
|
||||
|
||||
let reqUrl = formActionUrl;
|
||||
let reqBody: FormData | undefined;
|
||||
if (formMethodUpper === 'GET') {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of formData) {
|
||||
params.append(key, value as string);
|
||||
}
|
||||
const pos = reqUrl.indexOf('?');
|
||||
if (pos !== -1) {
|
||||
reqUrl = reqUrl.slice(0, pos);
|
||||
}
|
||||
reqUrl += `?${params.toString()}`;
|
||||
} else {
|
||||
reqBody = formData;
|
||||
}
|
||||
return {
|
||||
method: formMethodUpper,
|
||||
url: reqUrl,
|
||||
body: reqBody,
|
||||
formSubmitter: opts.formSubmitter,
|
||||
loadingIndicator: '$this', // for form submit, by default, the loading indicator is the whole form
|
||||
successSync: formEl.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for form submit
|
||||
};
|
||||
}
|
||||
|
||||
export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) {
|
||||
hideToastsAll();
|
||||
await performActionRequest(formEl, prepareFormFetchActionOpts(formEl, opts));
|
||||
}
|
||||
|
||||
async function confirmFetchAction(el: HTMLElement) {
|
||||
let elModal: HTMLElement | null = null;
|
||||
const dataModalConfirm = el.getAttribute('data-modal-confirm') || '';
|
||||
if (dataModalConfirm.startsWith('#')) {
|
||||
// eslint-disable-next-line unicorn/prefer-query-selector
|
||||
elModal = document.getElementById(dataModalConfirm.substring(1));
|
||||
if (elModal) {
|
||||
elModal = createElementFromHTML(elModal.outerHTML);
|
||||
elModal.removeAttribute('id');
|
||||
}
|
||||
}
|
||||
if (!elModal) {
|
||||
const modalConfirmContent = dataModalConfirm || el.getAttribute('data-modal-confirm-content') || '';
|
||||
if (modalConfirmContent) {
|
||||
const isRisky = el.classList.contains('red') || el.classList.contains('negative');
|
||||
elModal = createConfirmModal({
|
||||
header: el.getAttribute('data-modal-confirm-header') || '',
|
||||
content: modalConfirmContent,
|
||||
confirmButtonColor: isRisky ? 'red' : 'primary',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!elModal) return true;
|
||||
return await confirmModal(elModal);
|
||||
}
|
||||
|
||||
async function performLinkFetchAction(el: HTMLElement) {
|
||||
hideToastsAll();
|
||||
await performActionRequest(el, {
|
||||
method: el.getAttribute('data-fetch-method') || 'POST', // by default, the method is POST for link-action
|
||||
url: el.getAttribute('data-url')!,
|
||||
loadingIndicator: el.getAttribute('data-fetch-indicator') ?? '$this', // by default, the link-action itself is the loading indicator
|
||||
successSync: el.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for link-action
|
||||
});
|
||||
}
|
||||
|
||||
type FetchActionTriggerType = 'click' | 'change' | 'every' | 'load' | 'fetch-reload';
|
||||
|
||||
export async function performFetchActionTrigger(el: HTMLElement, triggerType: FetchActionTriggerType) {
|
||||
const isUserInitiated = triggerType === 'click' || triggerType === 'change';
|
||||
// for user initiated action, by default, the loading indicator is the element itself, otherwise no loading indicator
|
||||
const defaultLoadingIndicator = isUserInitiated ? '$this' : '';
|
||||
|
||||
if (isUserInitiated) hideToastsAll();
|
||||
await performActionRequest(el, {
|
||||
method: el.getAttribute('data-fetch-method') || 'GET', // by default, the method is GET for fetch trigger action
|
||||
url: el.getAttribute('data-fetch-url')!,
|
||||
loadingIndicator: el.getAttribute('data-fetch-indicator') ?? defaultLoadingIndicator,
|
||||
successSync: el.getAttribute('data-fetch-sync') ?? '$this', // by default, the response will replace the current element
|
||||
});
|
||||
}
|
||||
|
||||
type PseudoSelectorCommandResult = {
|
||||
targets: Element[];
|
||||
cmdInnerHTML: boolean;
|
||||
cmdMorph: boolean;
|
||||
};
|
||||
|
||||
export function execPseudoSelectorCommands(el: Element, fullCommand: string): PseudoSelectorCommandResult {
|
||||
const cmds = fullCommand.split(' ').map((s) => s.trim()).filter(Boolean) || [];
|
||||
let targets = [el], cmdInnerHTML = false, cmdMorph = false;
|
||||
for (const cmd of cmds) {
|
||||
if (cmd === '$this') {
|
||||
targets = [el];
|
||||
} else if (cmd === '$body') {
|
||||
targets = [document.body];
|
||||
} else if (cmd === '$innerHTML') {
|
||||
cmdInnerHTML = true;
|
||||
} else if (cmd === '$morph') {
|
||||
cmdMorph = true;
|
||||
} else if (cmd.startsWith('$closest(') && cmd.endsWith(')')) {
|
||||
const selector = cmd.substring('$closest('.length, cmd.length - 1);
|
||||
const newTargets: Element[] = [];
|
||||
for (const target of targets) {
|
||||
const closest = target.closest(selector);
|
||||
if (closest) newTargets.push(closest);
|
||||
}
|
||||
targets = newTargets;
|
||||
} else {
|
||||
const newTargets: Element[] = [];
|
||||
for (const target of targets) {
|
||||
newTargets.push(...target.querySelectorAll(cmd));
|
||||
}
|
||||
targets = newTargets;
|
||||
}
|
||||
}
|
||||
return {targets, cmdInnerHTML, cmdMorph};
|
||||
}
|
||||
|
||||
async function handleFetchActionSuccessSync(el: Element, successSync: string, respText: string) {
|
||||
const res = execPseudoSelectorCommands(el, successSync);
|
||||
if (!res.targets.length) throw new Error(`Fetch-sync command "${successSync}" did not find any target element to update`);
|
||||
if (res.targets.length > 1) throw new Error(`Fetch-sync command "${successSync}" found multiple target elements, which is not supported`);
|
||||
const target = res.targets[0];
|
||||
if (res.cmdMorph) {
|
||||
Idiomorph.morph(target, respText, {morphStyle: res.cmdInnerHTML ? 'innerHTML' : 'outerHTML'});
|
||||
} else if (res.cmdInnerHTML) {
|
||||
target.innerHTML = respText;
|
||||
} else {
|
||||
target.outerHTML = respText;
|
||||
}
|
||||
await fetchActionReloadOutdatedElements();
|
||||
}
|
||||
|
||||
async function fetchActionReloadOutdatedElements() {
|
||||
const outdatedElems: HTMLElement[] = [];
|
||||
for (const outdated of document.querySelectorAll<HTMLElement>('[data-fetch-trigger~="fetch-reload"]')) {
|
||||
if (!outdated.id) throw new Error(`Elements with "fetch-reload" trigger must have an id to be reloaded after fetch sync: ${outdated.outerHTML.substring(0, 100)}`);
|
||||
outdatedElems.push(outdated);
|
||||
}
|
||||
if (!outdatedElems.length) return;
|
||||
|
||||
const resp = await GET(window.location.href);
|
||||
if (!resp.ok) {
|
||||
showErrorToast(`Failed to reload page content after fetch action: ${resp.status} ${resp.statusText}`);
|
||||
return;
|
||||
}
|
||||
const newPageHtml = await resp.text();
|
||||
const newPageDom = parseDom(newPageHtml, 'text/html');
|
||||
for (const oldEl of outdatedElems) {
|
||||
// eslint-disable-next-line unicorn/prefer-query-selector
|
||||
const newEl = newPageDom.getElementById(oldEl.id);
|
||||
if (newEl) {
|
||||
oldEl.replaceWith(newEl);
|
||||
} else {
|
||||
oldEl.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initFetchActionTriggerEvery(el: HTMLElement, trigger: string) {
|
||||
const interval = trigger.substring('every '.length);
|
||||
const match = /^(\d+)(ms|s)$/.exec(interval);
|
||||
if (!match) throw new Error(`Invalid interval format: ${interval}`);
|
||||
|
||||
const num = parseInt(match[1], 10), unit = match[2];
|
||||
const intervalMs = unit === 's' ? num * 1000 : num;
|
||||
const fn = async () => {
|
||||
try {
|
||||
await performFetchActionTrigger(el, 'every');
|
||||
} finally {
|
||||
// only continue if the element is still in the document
|
||||
if (document.contains(el)) {
|
||||
setTimeout(fn, intervalMs);
|
||||
}
|
||||
}
|
||||
};
|
||||
setTimeout(fn, intervalMs);
|
||||
}
|
||||
|
||||
function initFetchActionTrigger(el: HTMLElement) {
|
||||
const trigger = el.getAttribute('data-fetch-trigger');
|
||||
|
||||
// this trigger is managed internally, only triggered after fetch sync success, not triggered by event or timer
|
||||
if (trigger === 'fetch-reload') return;
|
||||
|
||||
if (trigger === 'load') {
|
||||
performFetchActionTrigger(el, trigger);
|
||||
} else if (trigger === 'change') {
|
||||
el.addEventListener('change', () => performFetchActionTrigger(el, trigger));
|
||||
} else if (trigger?.startsWith('every ')) {
|
||||
initFetchActionTriggerEvery(el, trigger);
|
||||
} else if (!trigger || trigger === 'click') {
|
||||
el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
performFetchActionTrigger(el, 'click');
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unsupported fetch trigger: ${trigger}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function initGlobalFetchAction() {
|
||||
// The "fetch-action" framework is a general approach for elements to trigger fetch requests:
|
||||
// show confirm dialog (if any), show loading indicators, send fetch request, and redirect or update UI after success.
|
||||
//
|
||||
// If you need more fine-grained control more details, sometimes it's clearer to write the logic in JavaScript, instead of using this generic framework.
|
||||
//
|
||||
// Attributes:
|
||||
//
|
||||
// * data-fetch-method: the HTTP method to use
|
||||
// * default to "GET" for "data-fetch-url" actions, "POST" for "link-action" elements
|
||||
// * this attribute is ignored, the method will be determined by the form's "method" attribute, and default to "GET"
|
||||
//
|
||||
// * data-fetch-url: the URL for the request
|
||||
//
|
||||
// * data-fetch-trigger: the event to trigger the fetch action, can be:
|
||||
// * "click", "change" (user-initiated events)
|
||||
// * "load" (triggered on page load)
|
||||
// * "every 5s" (also support "ms" unit)
|
||||
// * "fetch-reload" (only triggered by fetch sync success to reload outdated content)
|
||||
//
|
||||
// * data-fetch-indicator: the loading indicator element selector, it uses the same syntax as "data-fetch-sync" to find the element(s)
|
||||
//
|
||||
// * data-fetch-sync: when the response is text (html), the pseudo selectors/commands defined in "data-fetch-sync"
|
||||
// will be used to update the content in the current page. It only supports some simple syntaxes that we need.
|
||||
// "$" prefix means it is our private command (for special logic), the selectors are run one by one from current element.
|
||||
// * "$this": replace the current element with the response
|
||||
// * "$innerHTML": replace innerHTML of the current element with the response, instead of replacing the whole element (outerHTML)
|
||||
// * "$morph": use morph algorithm to update the target element
|
||||
// * "$body #the-id .the-class": query the selector one by one from body
|
||||
// * "$closest(tr) td": pseudo command can help to find the target element in a more flexible way
|
||||
//
|
||||
// * data-modal-confirm: a "confirm modal dialog" will be shown before taking action.
|
||||
// * it can be a string for the content of the modal dialog
|
||||
// * it has "-header" and "-content" variants to set the header and content of the "confirm modal"
|
||||
// * it can refer an existing modal element by "#the-modal-id"
|
||||
|
||||
addDelegatedEventListener<HTMLFormElement, SubmitEvent>(document, 'submit', '.form-fetch-action', async (el, e) => {
|
||||
// "fetch-action" will use the form's data to send the request
|
||||
e.preventDefault();
|
||||
await submitFormFetchAction(el, {formSubmitter: e.submitter});
|
||||
});
|
||||
|
||||
addDelegatedEventListener(document, 'click', '.link-action', async (el, e) => {
|
||||
// `<a class="link-action" data-url="...">` is a shorthand for
|
||||
// `<a data-fetch-trigger="click" data-fetch-method="post" data-fetch-url="..." data-fetch-indicator="$this">`
|
||||
e.preventDefault();
|
||||
await performLinkFetchAction(el);
|
||||
});
|
||||
|
||||
registerGlobalSelectorFunc('[data-fetch-url]', initFetchActionTrigger);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {applyAreYouSure, initAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
||||
import {handleGlobalEnterQuickSubmit} from './comp/QuickSubmit.ts';
|
||||
import {queryElems} from '../utils/dom.ts';
|
||||
import {initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
|
||||
|
||||
export function initGlobalFormDirtyLeaveConfirm() {
|
||||
initAreYouSure(window.jQuery);
|
||||
// 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 (!document.querySelector('.page-content.user.signin')) {
|
||||
applyAreYouSure('form:not(.ignore-dirty)');
|
||||
}
|
||||
}
|
||||
|
||||
export function initGlobalEnterQuickSubmit() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.isComposing) return;
|
||||
if (e.key !== 'Enter') return;
|
||||
const hasCtrlOrMeta = ((e.ctrlKey || e.metaKey) && !e.altKey);
|
||||
if (hasCtrlOrMeta && (e.target as HTMLElement).matches('textarea')) {
|
||||
if (handleGlobalEnterQuickSubmit(e.target as HTMLElement)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if ((e.target as HTMLElement).matches('input') && !(e.target as HTMLElement).closest('form')) {
|
||||
// input in a normal form could handle Enter key by default, so we only handle the input outside a form
|
||||
// eslint-disable-next-line unicorn/no-lonely-if
|
||||
if (handleGlobalEnterQuickSubmit(e.target as HTMLElement)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function initGlobalComboMarkdownEditor() {
|
||||
queryElems<HTMLElement>(document, '.combo-markdown-editor:not(.custom-init)', (el) => initComboMarkdownEditor(el));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {parseIssueListQuickGotoLink} from './common-issue-list.ts';
|
||||
|
||||
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('/owner/repo/issues/123');
|
||||
|
||||
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');
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import {onInputDebounce, toggleElem} from '../utils/dom.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
|
||||
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: string, searchText: string) {
|
||||
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.substring(1)}`;
|
||||
}
|
||||
}
|
||||
// try to parse it for a global search (eg: "owner/repo#123")
|
||||
const [_, owner, repo, index] = reIssueOwnerRepoIndex.exec(searchText) || [];
|
||||
if (owner) {
|
||||
targetUrl = `${appSubUrl}/${owner}/${repo}/issues/${index}`;
|
||||
}
|
||||
return targetUrl;
|
||||
}
|
||||
|
||||
export function initCommonIssueListQuickGoto() {
|
||||
const elGotoButton = document.querySelector<HTMLElement>('#issue-list-quick-goto');
|
||||
if (!elGotoButton) return;
|
||||
|
||||
const form = elGotoButton.closest('form')!;
|
||||
const input = form.querySelector<HTMLInputElement>('input[name=q]')!;
|
||||
const repoLink = elGotoButton.getAttribute('data-repo-link') || '';
|
||||
|
||||
elGotoButton.addEventListener('click', () => {
|
||||
window.location.href = elGotoButton.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`); // backend: GetIssueInfo, it only checks whether the issue exists by status code
|
||||
if (res.status !== 200) targetUrl = '';
|
||||
}
|
||||
// if the input value has changed, then ignore the result
|
||||
if (input.value !== searchText) return;
|
||||
|
||||
toggleElem(elGotoButton, Boolean(targetUrl));
|
||||
elGotoButton.setAttribute('data-issue-goto-link', targetUrl);
|
||||
};
|
||||
|
||||
input.addEventListener('input', onInputDebounce(onInput));
|
||||
onInput();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {initCompLabelEdit} from './comp/LabelEdit.ts';
|
||||
import {toggleElem} from '../utils/dom.ts';
|
||||
|
||||
export function initCommonOrganization() {
|
||||
if (!document.querySelectorAll('.organization').length) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelector<HTMLInputElement>('.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('.page-content.organization.settings.labels');
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {showGlobalErrorMessage} from '../modules/errors.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {initTabSwitcher} from '../modules/fomantic/tab.ts';
|
||||
import {addDelegatedEventListener, queryElems} from '../utils/dom.ts';
|
||||
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||
import {initAvatarUploaderWithCropper} from './comp/Cropper.ts';
|
||||
import {initCompSearchRepoBox} from './comp/SearchRepoBox.ts';
|
||||
|
||||
const {appUrl, appSubUrl} = window.config;
|
||||
|
||||
function initHeadNavbarContentToggle() {
|
||||
const navbar = document.querySelector('#navbar');
|
||||
const btn = document.querySelector('#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);
|
||||
});
|
||||
}
|
||||
|
||||
function initFooterLanguageMenu() {
|
||||
document.querySelector('.ui.dropdown .menu.language-menu')?.addEventListener('click', async (e) => {
|
||||
const item = (e.target as HTMLElement).closest('.item');
|
||||
if (!item) return;
|
||||
e.preventDefault();
|
||||
await GET(item.getAttribute('data-url')!);
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
function initFooterThemeSelector() {
|
||||
const elDropdown = document.querySelector('#footer-theme-selector');
|
||||
if (!elDropdown) return; // some pages don't have footer, for example: 500.tmpl
|
||||
const $dropdown = fomanticQuery(elDropdown);
|
||||
$dropdown.dropdown({
|
||||
direction: 'upward',
|
||||
apiSettings: {url: `${appSubUrl}/-/web-theme/list`, cache: false},
|
||||
});
|
||||
addDelegatedEventListener(elDropdown, 'click', '.menu > .item', async (el) => {
|
||||
const themeName = el.getAttribute('data-value')!;
|
||||
await POST(`${appSubUrl}/-/web-theme/apply?theme=${encodeURIComponent(themeName)}`);
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
export function initCommmPageComponents() {
|
||||
initHeadNavbarContentToggle();
|
||||
initFooterLanguageMenu();
|
||||
initFooterThemeSelector();
|
||||
}
|
||||
|
||||
export function initGlobalDropdown() {
|
||||
// do not init "custom" dropdowns, "custom" dropdowns are managed by their own code.
|
||||
registerGlobalSelectorFunc('.ui.dropdown:not(.custom)', (el) => {
|
||||
const $dropdown = fomanticQuery(el);
|
||||
if ($dropdown.data('module-dropdown')) return; // do not re-init if other code has already initialized it.
|
||||
|
||||
$dropdown.dropdown('setting', {hideDividers: 'empty'});
|
||||
|
||||
if (el.classList.contains('jump')) {
|
||||
// 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.
|
||||
$dropdown.dropdown('setting', {
|
||||
action: 'hide',
|
||||
onShow() {
|
||||
// hide associated tooltip while dropdown is open
|
||||
this._tippy?.hide();
|
||||
this._tippy?.disable();
|
||||
},
|
||||
onHide() {
|
||||
this._tippy?.enable();
|
||||
// eslint-disable-next-line unicorn/no-this-assignment
|
||||
const elDropdown = this;
|
||||
|
||||
// hide all tippy elements of items after a while. eg: use Enter to click "Copy Link" in the Issue Context Menu
|
||||
setTimeout(() => {
|
||||
const $dropdown = fomanticQuery(elDropdown);
|
||||
if ($dropdown.dropdown('is hidden')) {
|
||||
queryElems(elDropdown, '.menu > .item', (el) => el._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.
|
||||
if (el.classList.contains('upward')) $dropdown.dropdown('setting', 'direction', 'upward');
|
||||
if (el.classList.contains('downward')) $dropdown.dropdown('setting', 'direction', 'downward');
|
||||
});
|
||||
}
|
||||
|
||||
export function initGlobalComponent() {
|
||||
registerGlobalInitFunc('initTabSwitcher', initTabSwitcher);
|
||||
registerGlobalInitFunc('initAvatarUploader', initAvatarUploaderWithCropper);
|
||||
registerGlobalInitFunc('initSearchRepoBox', initCompSearchRepoBox);
|
||||
}
|
||||
|
||||
// for performance considerations, it only uses performant syntax
|
||||
function attachInputDirAuto(el: Partial<HTMLInputElement | HTMLTextAreaElement>) {
|
||||
if (el.type !== 'hidden' &&
|
||||
el.type !== 'checkbox' &&
|
||||
el.type !== 'radio' &&
|
||||
el.type !== 'range' &&
|
||||
el.type !== 'color') {
|
||||
el.dir = 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
function autoFocusEnd(el: HTMLInputElement | HTMLTextAreaElement) {
|
||||
el.focus();
|
||||
el.setSelectionRange(el.value.length, el.value.length);
|
||||
}
|
||||
|
||||
export function applyAutoFocus(container: Element) {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/autofocus
|
||||
// "autofocus" behavior is defined by the standard: when a container (e.g.: dialog) becomes visible, focus the element with "autofocus" attribute
|
||||
// Fomantic UI already supports it for its modal dialog, we need to cover more cases (e.g.: ".show-panel" button)
|
||||
// Here is just a simple support, we don't expect more than one element that need "autofocus" appearing in the same container
|
||||
container.querySelector<HTMLElement>('[autofocus]')?.focus();
|
||||
// Also, apply our autoFocusEnd behavior
|
||||
// TODO: GLOBAL-INIT-MULTIPLE-FUNCTIONS: use "~=" operator in case we would extend the "data-global-init" to support more functions in the future.
|
||||
const el = container.querySelector<HTMLInputElement>('[data-global-init~="autoFocusEnd"]');
|
||||
if (el) autoFocusEnd(el);
|
||||
}
|
||||
|
||||
export function initGlobalInput() {
|
||||
registerGlobalSelectorFunc('input, textarea', attachInputDirAuto);
|
||||
|
||||
// autoFocusEnd is used for autofocus an input/textarea and move the cursor to the end of the text.
|
||||
// It is useful for "New Issue"/"New PR" pages when the title is pre-filled with prefix text (e.g.: from template or commit message)
|
||||
// The native "autofocus" isn't used because there is a delay between "focused (DOM rendering)" and "move cursor to end (our JS)", it causes flickers.
|
||||
registerGlobalInitFunc('autoFocusEnd', autoFocusEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(`The detected web site URL is "${appUrl}", it's unlikely matching the site config.
|
||||
Mismatched app.ini ROOT_URL or reverse proxy "Host/X-Forwarded-Proto" config might cause wrong URL links for web UI/mail content/webhook notification/OAuth2 sign-in.`, 'warning');
|
||||
}
|
||||
|
||||
export function checkAppUrlScheme() {
|
||||
const curUrl = window.location.href;
|
||||
// some users visit "http://domain" while appUrl is "https://domain", COOKIE_SECURE makes it impossible to sign in
|
||||
if (curUrl.startsWith('http:') && appUrl.startsWith('https:')) {
|
||||
showGlobalErrorMessage(`This instance is configured to run under HTTPS (by ROOT_URL config), you are accessing by HTTP. Mismatched scheme might cause problems for sign-in/sign-up.`, 'warning');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import '@github/markdown-toolbar-element';
|
||||
import '@github/text-expander-element';
|
||||
import {attachTribute} from '../tribute.ts';
|
||||
import {hideElem, showElem, autosize, isElemVisible, generateElemId} from '../../utils/dom.ts';
|
||||
import {
|
||||
EventUploadStateChanged,
|
||||
initEasyMDEPaste,
|
||||
initTextareaEvents,
|
||||
triggerUploadStateChanged,
|
||||
} from './EditorUpload.ts';
|
||||
import {handleGlobalEnterQuickSubmit} from './QuickSubmit.ts';
|
||||
import {renderPreviewPanelContent} from '../repo-editor.ts';
|
||||
import {toggleTasklistCheckbox} from '../../markup/tasklist.ts';
|
||||
import {easyMDEToolbarActions} from './EasyMDEToolbarActions.ts';
|
||||
import {initTextExpander} from './TextExpander.ts';
|
||||
import {showErrorToast} from '../../modules/toast.ts';
|
||||
import {POST} from '../../modules/fetch.ts';
|
||||
import {
|
||||
EventEditorContentChanged,
|
||||
initTextareaMarkdown,
|
||||
replaceTextareaSelection,
|
||||
triggerEditorContentChanged,
|
||||
} from './EditorMarkdown.ts';
|
||||
import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts';
|
||||
import {createTippy} from '../../modules/tippy.ts';
|
||||
import {initTabSwitcher} from '../../modules/fomantic/tab.ts';
|
||||
import type EasyMDE from 'easymde';
|
||||
import {localUserSettings} from '../../modules/user-settings.ts';
|
||||
|
||||
/**
|
||||
* validate if the given textarea is non-empty.
|
||||
* @param {HTMLTextAreaElement} textarea - The textarea element to be validated.
|
||||
* @returns {boolean} returns true if validation succeeded.
|
||||
*/
|
||||
export function validateTextareaNonEmpty(textarea: HTMLTextAreaElement) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
type Heights = {
|
||||
minHeight?: string,
|
||||
height?: string,
|
||||
maxHeight?: string,
|
||||
};
|
||||
|
||||
type ComboMarkdownEditorOptions = {
|
||||
editorHeights?: Heights,
|
||||
easyMDEOptions?: EasyMDE.Options,
|
||||
};
|
||||
|
||||
type ComboMarkdownEditorTextarea = HTMLTextAreaElement & {_giteaComboMarkdownEditor: any};
|
||||
type ComboMarkdownEditorContainer = HTMLElement & {_giteaComboMarkdownEditor?: any};
|
||||
|
||||
export class ComboMarkdownEditor {
|
||||
static EventEditorContentChanged = EventEditorContentChanged;
|
||||
static EventUploadStateChanged = EventUploadStateChanged;
|
||||
|
||||
public container: HTMLElement;
|
||||
|
||||
options: ComboMarkdownEditorOptions;
|
||||
|
||||
tabEditor?: HTMLElement;
|
||||
tabPreviewer?: HTMLElement;
|
||||
|
||||
supportEasyMDE!: boolean;
|
||||
easyMDE: any;
|
||||
easyMDEToolbarActions: any;
|
||||
easyMDEToolbarDefault: any;
|
||||
|
||||
textarea!: ComboMarkdownEditorTextarea;
|
||||
textareaMarkdownToolbar!: HTMLElement;
|
||||
textareaAutosize: any;
|
||||
|
||||
buttonMonospace!: HTMLButtonElement;
|
||||
|
||||
dropzone: HTMLElement | null = null;
|
||||
attachedDropzoneInst: any;
|
||||
|
||||
previewMode!: string;
|
||||
previewUrl!: string;
|
||||
previewContext!: string;
|
||||
|
||||
constructor(container: ComboMarkdownEditorContainer, options:ComboMarkdownEditorOptions = {}) {
|
||||
if (container._giteaComboMarkdownEditor) throw new Error('ComboMarkdownEditor already initialized');
|
||||
container._giteaComboMarkdownEditor = this;
|
||||
this.options = options;
|
||||
this.container = container;
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.prepareEasyMDEToolbarActions();
|
||||
this.setupContainer();
|
||||
this.setupTab();
|
||||
await this.setupDropzone(); // textarea depends on dropzone
|
||||
this.setupTextarea();
|
||||
|
||||
await this.switchToUserPreference();
|
||||
}
|
||||
|
||||
applyEditorHeights(el: HTMLElement, heights: Heights | undefined) {
|
||||
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() {
|
||||
this.supportEasyMDE = this.container.getAttribute('data-support-easy-mde') === 'true';
|
||||
this.previewMode = this.container.getAttribute('data-content-mode')!;
|
||||
this.previewUrl = this.container.getAttribute('data-preview-url')!;
|
||||
this.previewContext = this.container.getAttribute('data-preview-context')!;
|
||||
initTextExpander(this.container.querySelector('text-expander')!);
|
||||
}
|
||||
|
||||
setupTextarea() {
|
||||
this.textarea = this.container.querySelector('.markdown-text-editor')!;
|
||||
this.textarea._giteaComboMarkdownEditor = this;
|
||||
this.textarea.id = generateElemId(`_combo_markdown_editor_`);
|
||||
this.textarea.addEventListener('input', () => triggerEditorContentChanged(this.container));
|
||||
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.buttonMonospace = this.container.querySelector('.markdown-switch-monospace')!;
|
||||
this.applyMonospace();
|
||||
this.buttonMonospace.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const enabled = !localUserSettings.getBoolean('markdown-editor-monospace');
|
||||
localUserSettings.setBoolean('markdown-editor-monospace', enabled);
|
||||
applyMonospaceToAllEditors();
|
||||
});
|
||||
|
||||
if (this.supportEasyMDE) {
|
||||
const easymdeButton = this.container.querySelector('.markdown-switch-easymde')!;
|
||||
easymdeButton.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
this.userPreferredEditor = 'easymde';
|
||||
await this.switchToEasyMDE();
|
||||
});
|
||||
}
|
||||
|
||||
this.initMarkdownButtonTableAdd();
|
||||
|
||||
initTextareaMarkdown(this.textarea);
|
||||
initTextareaEvents(this.textarea, this.dropzone);
|
||||
}
|
||||
|
||||
async setupDropzone() {
|
||||
const dropzoneParentContainer = this.container.getAttribute('data-dropzone-parent-container');
|
||||
if (!dropzoneParentContainer) return;
|
||||
this.dropzone = this.container.closest(this.container.getAttribute('data-dropzone-parent-container')!)?.querySelector('.dropzone') ?? null;
|
||||
if (!this.dropzone) return;
|
||||
|
||||
this.attachedDropzoneInst = await initDropzone(this.dropzone);
|
||||
// dropzone events
|
||||
// * "processing" means a file is being uploaded
|
||||
// * "queuecomplete" means all files have been uploaded
|
||||
this.attachedDropzoneInst.on('processing', () => triggerUploadStateChanged(this.container));
|
||||
this.attachedDropzoneInst.on('queuecomplete', () => triggerUploadStateChanged(this.container));
|
||||
}
|
||||
|
||||
dropzoneGetFiles() {
|
||||
if (!this.dropzone) return null;
|
||||
return Array.from(this.dropzone.querySelectorAll<HTMLInputElement>('.files [name=files]'), (el) => el.value);
|
||||
}
|
||||
|
||||
dropzoneReloadFiles() {
|
||||
if (!this.dropzone) return;
|
||||
this.attachedDropzoneInst.emit(DropzoneCustomEventReloadFiles);
|
||||
}
|
||||
|
||||
dropzoneSubmitReload() {
|
||||
if (!this.dropzone) return;
|
||||
this.attachedDropzoneInst.emit('submit');
|
||||
this.attachedDropzoneInst.emit(DropzoneCustomEventReloadFiles);
|
||||
}
|
||||
|
||||
isUploading() {
|
||||
if (!this.dropzone) return false;
|
||||
return this.attachedDropzoneInst.getQueuedFiles().length || this.attachedDropzoneInst.getUploadingFiles().length;
|
||||
}
|
||||
|
||||
setupTab() {
|
||||
const elTabular = this.container.querySelector('.ui.tabular');
|
||||
if (!elTabular) return;
|
||||
this.tabEditor = this.container.querySelector('[data-tab-for="markdown-writer"]')!;
|
||||
this.tabPreviewer = this.container.querySelector('[data-tab-for="markdown-previewer"]')!;
|
||||
const panelEditor = this.container.querySelector('.ui.tab[data-tab-panel="markdown-writer"]')!;
|
||||
const panelPreviewer = this.container.querySelector('.ui.tab[data-tab-panel="markdown-previewer"]')!;
|
||||
|
||||
// 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 tabIdSuffix = generateElemId();
|
||||
this.tabEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
|
||||
this.tabPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
|
||||
panelEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
|
||||
panelPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
|
||||
initTabSwitcher(elTabular);
|
||||
|
||||
this.tabEditor.addEventListener('click', () => {
|
||||
requestAnimationFrame(() => {
|
||||
this.focus();
|
||||
});
|
||||
});
|
||||
|
||||
this.tabPreviewer.addEventListener('click', async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('mode', this.previewMode);
|
||||
formData.append('context', this.previewContext);
|
||||
formData.append('text', this.value());
|
||||
const response = await POST(this.previewUrl, {data: formData});
|
||||
const data = await response.text();
|
||||
renderPreviewPanelContent(panelPreviewer, data);
|
||||
// enable task list checkboxes in preview and sync state back to the editor
|
||||
for (const checkbox of panelPreviewer.querySelectorAll<HTMLInputElement>('.task-list-item input[type=checkbox]')) {
|
||||
checkbox.disabled = false;
|
||||
checkbox.addEventListener('input', () => {
|
||||
const position = parseInt(checkbox.getAttribute('data-source-position')!) + 1;
|
||||
const newContent = toggleTasklistCheckbox(this.value(), position, checkbox.checked);
|
||||
if (newContent === null) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
return;
|
||||
}
|
||||
this.value(newContent);
|
||||
triggerEditorContentChanged(this.container);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
generateMarkdownTable(rows: number, cols: number): string {
|
||||
const tableLines = [];
|
||||
tableLines.push(
|
||||
`| ${'Header '.repeat(cols).trim().split(' ').join(' | ')} |`,
|
||||
`| ${'--- '.repeat(cols).trim().split(' ').join(' | ')} |`,
|
||||
);
|
||||
for (let i = 0; i < rows; i++) {
|
||||
tableLines.push(`| ${'Cell '.repeat(cols).trim().split(' ').join(' | ')} |`);
|
||||
}
|
||||
return tableLines.join('\n');
|
||||
}
|
||||
|
||||
initMarkdownButtonTableAdd() {
|
||||
const addTableButton = this.container.querySelector('.markdown-button-table-add')!;
|
||||
const addTablePanel = this.container.querySelector('.markdown-add-table-panel')!;
|
||||
// here the tippy can't attach to the button because the button already owns a tippy for tooltip
|
||||
const addTablePanelTippy = createTippy(addTablePanel, {
|
||||
content: addTablePanel,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom',
|
||||
hideOnClick: true,
|
||||
interactive: true,
|
||||
getReferenceClientRect: () => addTableButton.getBoundingClientRect(),
|
||||
});
|
||||
addTableButton.addEventListener('click', () => addTablePanelTippy.show());
|
||||
|
||||
addTablePanel.querySelector('.ui.button.primary')!.addEventListener('click', () => {
|
||||
let rows = parseInt(addTablePanel.querySelector<HTMLInputElement>('.add-table-rows')!.value);
|
||||
let cols = parseInt(addTablePanel.querySelector<HTMLInputElement>('.add-table-cols')!.value);
|
||||
rows = Math.max(1, Math.min(100, rows));
|
||||
cols = Math.max(1, Math.min(100, cols));
|
||||
replaceTextareaSelection(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`);
|
||||
addTablePanelTippy.hide();
|
||||
});
|
||||
}
|
||||
|
||||
switchTabToEditor() {
|
||||
this.tabEditor!.click(); // when this function is called, the tab must exist
|
||||
}
|
||||
|
||||
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: typeof EasyMDE, actions: any) {
|
||||
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' && this.supportEasyMDE) {
|
||||
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;
|
||||
const [{default: EasyMDE}] = await Promise.all([
|
||||
import('easymde'),
|
||||
import('../../../css/easymde.css'),
|
||||
]);
|
||||
const easyMDEOpt: EasyMDE.Options = {
|
||||
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', () => triggerEditorContentChanged(this.container));
|
||||
this.easyMDE.codemirror.setOption('extraKeys', {
|
||||
'Cmd-Enter': (cm: any) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
|
||||
'Ctrl-Enter': (cm: any) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
|
||||
Enter: (cm: any) => {
|
||||
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
|
||||
if (!tributeContainer || tributeContainer.style.display === 'none') {
|
||||
cm.execCommand('newlineAndIndent');
|
||||
}
|
||||
},
|
||||
Up: (cm: any) => {
|
||||
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
|
||||
if (!tributeContainer || tributeContainer.style.display === 'none') {
|
||||
return cm.execCommand('goLineUp');
|
||||
}
|
||||
},
|
||||
Down: (cm: any) => {
|
||||
const tributeContainer = document.querySelector<HTMLElement>('.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());
|
||||
if (this.dropzone) {
|
||||
initEasyMDEPaste(this.easyMDE, this.dropzone);
|
||||
}
|
||||
hideElem(this.textareaMarkdownToolbar);
|
||||
}
|
||||
|
||||
value(v?: any) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
get userPreferredEditor(): string {
|
||||
return localUserSettings.getString(`markdown-editor-${this.previewMode ?? 'default'}`);
|
||||
}
|
||||
|
||||
set userPreferredEditor(s: string) {
|
||||
localUserSettings.setString(`markdown-editor-${this.previewMode ?? 'default'}`, s);
|
||||
}
|
||||
|
||||
applyMonospace() {
|
||||
const enabled = localUserSettings.getBoolean('markdown-editor-monospace');
|
||||
const text = this.buttonMonospace.getAttribute(enabled ? 'data-disable-text' : 'data-enable-text')!;
|
||||
this.textarea.classList.toggle('tw-font-mono', enabled);
|
||||
this.buttonMonospace.setAttribute('data-tooltip-content', text);
|
||||
this.buttonMonospace.setAttribute('aria-checked', String(enabled));
|
||||
}
|
||||
}
|
||||
|
||||
function applyMonospaceToAllEditors() {
|
||||
const editors = document.querySelectorAll<ComboMarkdownEditorContainer>('.combo-markdown-editor');
|
||||
for (const editorContainer of editors) {
|
||||
const editor = getComboMarkdownEditor(editorContainer);
|
||||
if (editor) editor.applyMonospace();
|
||||
}
|
||||
}
|
||||
|
||||
export function getComboMarkdownEditor(el: any): ComboMarkdownEditor | null {
|
||||
if (!el) return null;
|
||||
if (el.length) el = el[0];
|
||||
return el._giteaComboMarkdownEditor;
|
||||
}
|
||||
|
||||
export async function initComboMarkdownEditor(container: HTMLElement, options:ComboMarkdownEditorOptions = {}) {
|
||||
if (!container) {
|
||||
throw new Error('initComboMarkdownEditor: container is null');
|
||||
}
|
||||
const editor = new ComboMarkdownEditor(container, options);
|
||||
await editor.init();
|
||||
return editor;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {svg} from '../../svg.ts';
|
||||
import {html, htmlRaw} from '../../utils/html.ts';
|
||||
import {createElementFromHTML} from '../../utils/dom.ts';
|
||||
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
|
||||
import {hideToastsAll} from '../../modules/toast.ts';
|
||||
|
||||
const {i18n} = window.config;
|
||||
|
||||
type ConfirmModalOptions = {
|
||||
header?: string;
|
||||
content?: string;
|
||||
confirmButtonColor?: 'primary' | 'red' | 'green' | 'blue';
|
||||
};
|
||||
|
||||
export function createConfirmModal({header = '', content = '', confirmButtonColor = 'primary'}:ConfirmModalOptions = {}): HTMLElement {
|
||||
const headerHtml = header ? html`<div class="header">${header}</div>` : '';
|
||||
return createElementFromHTML(html`
|
||||
<div class="ui g-modal-confirm modal">
|
||||
${htmlRaw(headerHtml)}
|
||||
<div class="content">${content}</div>
|
||||
<div class="actions">
|
||||
<button class="ui cancel button">${htmlRaw(svg('octicon-x'))} ${i18n.modal_cancel}</button>
|
||||
<button class="ui ${confirmButtonColor} ok button">${htmlRaw(svg('octicon-check'))} ${i18n.modal_confirm}</button>
|
||||
</div>
|
||||
</div>
|
||||
`.trim());
|
||||
}
|
||||
|
||||
export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<boolean> {
|
||||
if (!(modal instanceof HTMLElement)) modal = createConfirmModal(modal);
|
||||
// hide existing toasts when we need to show a new modal, otherwise the toasts only interfere the UI
|
||||
// it's fine to do so because the modal is triggered by user's explicit action, so the user should already have read the toast messages
|
||||
hideToastsAll();
|
||||
return new Promise((resolve) => {
|
||||
showFomanticModal(modal, {
|
||||
onApprove() {
|
||||
resolve(true);
|
||||
},
|
||||
onHidden() {
|
||||
modal.remove();
|
||||
resolve(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {showElem} from '../../utils/dom.ts';
|
||||
|
||||
type CropperOpts = {
|
||||
container: HTMLElement,
|
||||
imageSource: HTMLImageElement,
|
||||
fileInput: HTMLInputElement,
|
||||
};
|
||||
|
||||
async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
|
||||
const {default: Cropper} = await import('cropperjs');
|
||||
let currentFileName = '';
|
||||
let currentFileLastModified = 0;
|
||||
const cropper = new Cropper(imageSource, {
|
||||
aspectRatio: 1,
|
||||
viewMode: 2,
|
||||
autoCrop: false,
|
||||
crop() {
|
||||
const canvas = cropper.getCroppedCanvas();
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
const croppedFileName = currentFileName.replace(/\.[^.]{3,4}$/, '.png');
|
||||
const croppedFile = new File([blob], croppedFileName, {type: 'image/png', lastModified: currentFileLastModified});
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(croppedFile);
|
||||
fileInput.files = dataTransfer.files;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
fileInput.addEventListener('input', (e) => {
|
||||
const files = (e.target as HTMLInputElement).files;
|
||||
if (files?.length) {
|
||||
currentFileName = files[0].name;
|
||||
currentFileLastModified = files[0].lastModified;
|
||||
const fileURL = URL.createObjectURL(files[0]);
|
||||
imageSource.src = fileURL;
|
||||
cropper.replace(fileURL);
|
||||
showElem(container);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function initAvatarUploaderWithCropper(fileInput: HTMLInputElement) {
|
||||
const panel = fileInput.nextElementSibling as HTMLElement;
|
||||
if (!panel?.matches('.cropper-panel')) throw new Error('Missing cropper panel for avatar uploader');
|
||||
const imageSource = panel.querySelector<HTMLImageElement>('.cropper-source')!;
|
||||
await initCompCropper({container: panel, fileInput, imageSource});
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import {svg} from '../../svg.ts';
|
||||
import type EasyMDE from 'easymde';
|
||||
import type {ComboMarkdownEditor} from './ComboMarkdownEditor.ts';
|
||||
|
||||
export function easyMDEToolbarActions(easyMde: typeof EasyMDE, editor: ComboMarkdownEditor): Record<string, Partial<EasyMDE.ToolbarIcon | string>> {
|
||||
const actions: Record<string, Partial<EasyMDE.ToolbarIcon> | string> = {
|
||||
'|': '|',
|
||||
'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',
|
||||
},
|
||||
|
||||
// gitea's 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;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import {initTextareaMarkdown, markdownHandleIndention, textareaSplitLines} from './EditorMarkdown.ts';
|
||||
|
||||
test('textareaSplitLines', () => {
|
||||
let ret = textareaSplitLines('a\nbc\nd', 0);
|
||||
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 0, posLineIndex: 0, inlinePos: 0});
|
||||
|
||||
ret = textareaSplitLines('a\nbc\nd', 1);
|
||||
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 0, posLineIndex: 0, inlinePos: 1});
|
||||
|
||||
ret = textareaSplitLines('a\nbc\nd', 2);
|
||||
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 0});
|
||||
|
||||
ret = textareaSplitLines('a\nbc\nd', 3);
|
||||
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 1});
|
||||
|
||||
ret = textareaSplitLines('a\nbc\nd', 4);
|
||||
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 2});
|
||||
|
||||
ret = textareaSplitLines('a\nbc\nd', 5);
|
||||
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 5, posLineIndex: 2, inlinePos: 0});
|
||||
|
||||
ret = textareaSplitLines('a\nbc\nd', 6);
|
||||
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 5, posLineIndex: 2, inlinePos: 1});
|
||||
});
|
||||
|
||||
test('markdownHandleIndention', () => {
|
||||
const testInput = (input: string, expected: string | null) => {
|
||||
const inputPos = input.indexOf('|');
|
||||
input = input.replaceAll('|', '');
|
||||
const ret = markdownHandleIndention({value: input, selStart: inputPos, selEnd: inputPos});
|
||||
if (expected === null) {
|
||||
expect(ret).toEqual({handled: false});
|
||||
} else {
|
||||
const expectedPos = expected.indexOf('|');
|
||||
expected = expected.replaceAll('|', '');
|
||||
expect(ret).toEqual({
|
||||
handled: true,
|
||||
valueSelection: {value: expected, selStart: expectedPos, selEnd: expectedPos},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
testInput(`
|
||||
a|b
|
||||
`, `
|
||||
a
|
||||
|b
|
||||
`);
|
||||
|
||||
testInput(`
|
||||
1. a
|
||||
2. |
|
||||
`, `
|
||||
1. a
|
||||
|
|
||||
`);
|
||||
|
||||
testInput(`
|
||||
|1. a
|
||||
`, null); // let browser handle it
|
||||
|
||||
testInput(`
|
||||
1. a
|
||||
1. b|c
|
||||
`, `
|
||||
1. a
|
||||
2. b
|
||||
3. |c
|
||||
`);
|
||||
|
||||
testInput(`
|
||||
2. a
|
||||
2. b|
|
||||
|
||||
1. x
|
||||
1. y
|
||||
`, `
|
||||
1. a
|
||||
2. b
|
||||
3. |
|
||||
|
||||
1. x
|
||||
1. y
|
||||
`);
|
||||
|
||||
testInput(`
|
||||
2. a
|
||||
2. b
|
||||
|
||||
1. x|
|
||||
1. y
|
||||
`, `
|
||||
2. a
|
||||
2. b
|
||||
|
||||
1. x
|
||||
2. |
|
||||
3. y
|
||||
`);
|
||||
|
||||
testInput(`
|
||||
1. a
|
||||
2. b|
|
||||
3. c
|
||||
`, `
|
||||
1. a
|
||||
2. b
|
||||
3. |
|
||||
4. c
|
||||
`);
|
||||
|
||||
testInput(`
|
||||
1. a
|
||||
1. b
|
||||
2. b
|
||||
3. b
|
||||
4. b
|
||||
1. c|
|
||||
`, `
|
||||
1. a
|
||||
1. b
|
||||
2. b
|
||||
3. b
|
||||
4. b
|
||||
2. c
|
||||
3. |
|
||||
`);
|
||||
|
||||
testInput(`
|
||||
1. a
|
||||
2. a
|
||||
3. a
|
||||
4. a
|
||||
5. a
|
||||
6. a
|
||||
7. a
|
||||
8. a
|
||||
9. b|c
|
||||
`, `
|
||||
1. a
|
||||
2. a
|
||||
3. a
|
||||
4. a
|
||||
5. a
|
||||
6. a
|
||||
7. a
|
||||
8. a
|
||||
9. b
|
||||
10. |c
|
||||
`);
|
||||
|
||||
// this is a special case, it's difficult to re-format the parent level at the moment, so leave it to the future
|
||||
testInput(`
|
||||
1. a
|
||||
2. b|
|
||||
3. c
|
||||
`, `
|
||||
1. a
|
||||
1. b
|
||||
2. |
|
||||
3. c
|
||||
`);
|
||||
});
|
||||
|
||||
test('EditorMarkdown', () => {
|
||||
const textarea = document.createElement('textarea');
|
||||
initTextareaMarkdown(textarea);
|
||||
|
||||
type ValueWithCursor = string | {
|
||||
value: string;
|
||||
pos: number;
|
||||
};
|
||||
const testInput = (input: ValueWithCursor, result: ValueWithCursor) => {
|
||||
const inputValue = typeof input === 'string' ? input : input.value;
|
||||
const inputPos = typeof input === 'string' ? inputValue.length : input.pos;
|
||||
textarea.value = inputValue;
|
||||
textarea.setSelectionRange(inputPos, inputPos);
|
||||
|
||||
const e = new KeyboardEvent('keydown', {key: 'Enter', cancelable: true});
|
||||
textarea.dispatchEvent(e);
|
||||
if (!e.defaultPrevented) textarea.value += '\n'; // simulate default behavior
|
||||
|
||||
const expectedValue = typeof result === 'string' ? result : result.value;
|
||||
const expectedPos = typeof result === 'string' ? expectedValue.length : result.pos;
|
||||
expect(textarea.value).toEqual(expectedValue);
|
||||
expect(textarea.selectionStart).toEqual(expectedPos);
|
||||
};
|
||||
|
||||
testInput('-', '-\n');
|
||||
testInput('1.', '1.\n');
|
||||
|
||||
testInput('- ', '');
|
||||
testInput('1. ', '');
|
||||
testInput({value: '1. \n2. ', pos: 3}, {value: '\n2. ', pos: 0});
|
||||
|
||||
testInput('- x', '- x\n- ');
|
||||
testInput('1. foo', '1. foo\n2. ');
|
||||
testInput({value: '1. a\n2. b\n3. c', pos: 4}, {value: '1. a\n2. \n3. b\n4. c', pos: 8});
|
||||
testInput('- [ ]', '- [ ]\n- ');
|
||||
testInput('- [ ] foo', '- [ ] foo\n- [ ] ');
|
||||
testInput('* [x] foo', '* [x] foo\n* [ ] ');
|
||||
testInput('1. [x] foo', '1. [x] foo\n2. [ ] ');
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
export const EventEditorContentChanged = 'ce-editor-content-changed';
|
||||
|
||||
export function triggerEditorContentChanged(target: HTMLElement) {
|
||||
target.dispatchEvent(new CustomEvent(EventEditorContentChanged, {bubbles: true}));
|
||||
}
|
||||
|
||||
/** replace selected text or insert text by creating a new edit history entry,
|
||||
* e.g. CTRL-Z works after this */
|
||||
export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: string) {
|
||||
const before = textarea.value.slice(0, textarea.selectionStart);
|
||||
const after = textarea.value.slice(textarea.selectionEnd);
|
||||
|
||||
textarea.focus();
|
||||
let success = false;
|
||||
try {
|
||||
success = document.execCommand('insertText', false, text); // eslint-disable-line @typescript-eslint/no-deprecated
|
||||
} catch {}
|
||||
|
||||
// fall back to regular replacement
|
||||
if (!success) {
|
||||
textarea.value = `${before}${text}${after}`;
|
||||
triggerEditorContentChanged(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
type TextareaValueSelection = {
|
||||
value: string;
|
||||
selStart: number;
|
||||
selEnd: number;
|
||||
};
|
||||
|
||||
function handleIndentSelection(textarea: HTMLTextAreaElement, e: KeyboardEvent) {
|
||||
const selStart = textarea.selectionStart;
|
||||
const selEnd = textarea.selectionEnd;
|
||||
if (selEnd === selStart) return; // do not process when no selection
|
||||
|
||||
e.preventDefault();
|
||||
const lines = textarea.value.split('\n');
|
||||
const selectedLines = [];
|
||||
|
||||
let pos = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (pos > selEnd) break;
|
||||
if (pos >= selStart) selectedLines.push(i);
|
||||
pos += lines[i].length + 1;
|
||||
}
|
||||
|
||||
for (const i of selectedLines) {
|
||||
if (e.shiftKey) {
|
||||
lines[i] = lines[i].replace(/^(\t| {1,2})/, '');
|
||||
} else {
|
||||
lines[i] = ` ${lines[i]}`;
|
||||
}
|
||||
}
|
||||
|
||||
// re-calculating the selection range
|
||||
let newSelStart: number | null = null;
|
||||
let newSelEnd: number | null = null;
|
||||
pos = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (i === selectedLines[0]) {
|
||||
newSelStart = pos;
|
||||
}
|
||||
if (i === selectedLines[selectedLines.length - 1]) {
|
||||
newSelEnd = pos + lines[i].length;
|
||||
break;
|
||||
}
|
||||
pos += lines[i].length + 1;
|
||||
}
|
||||
textarea.value = lines.join('\n');
|
||||
textarea.setSelectionRange(newSelStart, newSelEnd);
|
||||
triggerEditorContentChanged(textarea);
|
||||
}
|
||||
|
||||
type MarkdownHandleIndentionResult = {
|
||||
handled: boolean;
|
||||
valueSelection?: TextareaValueSelection;
|
||||
};
|
||||
|
||||
type TextLinesBuffer = {
|
||||
lines: string[];
|
||||
lengthBeforePosLine: number;
|
||||
posLineIndex: number;
|
||||
inlinePos: number
|
||||
};
|
||||
|
||||
export function textareaSplitLines(value: string, pos: number): TextLinesBuffer {
|
||||
const lines = value.split('\n');
|
||||
let lengthBeforePosLine = 0, inlinePos = 0, posLineIndex = 0;
|
||||
for (; posLineIndex < lines.length; posLineIndex++) {
|
||||
const lineLength = lines[posLineIndex].length + 1;
|
||||
if (lengthBeforePosLine + lineLength > pos) {
|
||||
inlinePos = pos - lengthBeforePosLine;
|
||||
break;
|
||||
}
|
||||
lengthBeforePosLine += lineLength;
|
||||
}
|
||||
return {lines, lengthBeforePosLine, posLineIndex, inlinePos};
|
||||
}
|
||||
|
||||
function markdownReformatListNumbers(linesBuf: TextLinesBuffer, indention: string) {
|
||||
const reDeeperIndention = new RegExp(`^${indention}\\s+`);
|
||||
const reSameLevel = new RegExp(`^${indention}([0-9]+)\\.`);
|
||||
let firstLineIdx: number;
|
||||
for (firstLineIdx = linesBuf.posLineIndex - 1; firstLineIdx >= 0; firstLineIdx--) {
|
||||
const line = linesBuf.lines[firstLineIdx];
|
||||
if (!reDeeperIndention.test(line) && !reSameLevel.test(line)) break;
|
||||
}
|
||||
firstLineIdx++;
|
||||
let num = 1;
|
||||
for (let i = firstLineIdx; i < linesBuf.lines.length; i++) {
|
||||
const oldLine = linesBuf.lines[i];
|
||||
const sameLevel = reSameLevel.test(oldLine);
|
||||
if (!sameLevel && !reDeeperIndention.test(oldLine)) break;
|
||||
if (sameLevel) {
|
||||
const newLine = `${indention}${num}.${oldLine.replace(reSameLevel, '')}`;
|
||||
linesBuf.lines[i] = newLine;
|
||||
num++;
|
||||
if (linesBuf.posLineIndex === i) {
|
||||
// need to correct the cursor inline position if the line length changes
|
||||
linesBuf.inlinePos += newLine.length - oldLine.length;
|
||||
linesBuf.inlinePos = Math.max(0, linesBuf.inlinePos);
|
||||
linesBuf.inlinePos = Math.min(newLine.length, linesBuf.inlinePos);
|
||||
}
|
||||
}
|
||||
}
|
||||
recalculateLengthBeforeLine(linesBuf);
|
||||
}
|
||||
|
||||
function recalculateLengthBeforeLine(linesBuf: TextLinesBuffer) {
|
||||
linesBuf.lengthBeforePosLine = 0;
|
||||
for (let i = 0; i < linesBuf.posLineIndex; i++) {
|
||||
linesBuf.lengthBeforePosLine += linesBuf.lines[i].length + 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function markdownHandleIndention(tvs: TextareaValueSelection): MarkdownHandleIndentionResult {
|
||||
const unhandled: MarkdownHandleIndentionResult = {handled: false};
|
||||
if (tvs.selEnd !== tvs.selStart) return unhandled; // do not process when there is a selection
|
||||
|
||||
const linesBuf = textareaSplitLines(tvs.value, tvs.selStart);
|
||||
const line = linesBuf.lines[linesBuf.posLineIndex] ?? '';
|
||||
if (!line) return unhandled; // if the line is empty, do nothing, let the browser handle it
|
||||
|
||||
// parse the indention
|
||||
let lineContent = line;
|
||||
const indention = (/^\s*/.exec(lineContent) || [''])[0];
|
||||
lineContent = lineContent.slice(indention.length);
|
||||
if (linesBuf.inlinePos <= indention.length) return unhandled; // if cursor is at the indention, do nothing, let the browser handle it
|
||||
|
||||
// parse the prefixes: "1. ", "- ", "* ", there could also be " [ ] " or " [x] " for task lists
|
||||
// there must be a space after the prefix because none of "1.foo" / "-foo" is a list item
|
||||
const prefixMatch = /^([0-9]+\.|[-*])(\s\[([ x])\])?\s/.exec(lineContent);
|
||||
let prefix = '';
|
||||
if (prefixMatch) {
|
||||
prefix = prefixMatch[0];
|
||||
if (prefix.length > linesBuf.inlinePos) prefix = ''; // do not add new line if cursor is at prefix
|
||||
}
|
||||
|
||||
lineContent = lineContent.slice(prefix.length);
|
||||
if (!indention && !prefix) return unhandled; // if no indention and no prefix, do nothing, let the browser handle it
|
||||
|
||||
if (!lineContent) {
|
||||
// clear current line if we only have i.e. '1. ' and the user presses enter again to finish creating a list
|
||||
linesBuf.lines[linesBuf.posLineIndex] = '';
|
||||
linesBuf.inlinePos = 0;
|
||||
} else {
|
||||
// start a new line with the same indention
|
||||
let newPrefix = prefix;
|
||||
if (/^\d+\./.test(prefix)) newPrefix = `1. ${newPrefix.slice(newPrefix.indexOf('.') + 2)}`;
|
||||
newPrefix = newPrefix.replace('[x]', '[ ]');
|
||||
|
||||
const inlinePos = linesBuf.inlinePos;
|
||||
linesBuf.lines[linesBuf.posLineIndex] = line.substring(0, inlinePos);
|
||||
const newLineLeft = `${indention}${newPrefix}`;
|
||||
const newLine = `${newLineLeft}${line.substring(inlinePos)}`;
|
||||
linesBuf.lines.splice(linesBuf.posLineIndex + 1, 0, newLine);
|
||||
linesBuf.posLineIndex++;
|
||||
linesBuf.inlinePos = newLineLeft.length;
|
||||
recalculateLengthBeforeLine(linesBuf);
|
||||
}
|
||||
|
||||
markdownReformatListNumbers(linesBuf, indention);
|
||||
const newPos = linesBuf.lengthBeforePosLine + linesBuf.inlinePos;
|
||||
return {handled: true, valueSelection: {value: linesBuf.lines.join('\n'), selStart: newPos, selEnd: newPos}};
|
||||
}
|
||||
|
||||
function handleNewline(textarea: HTMLTextAreaElement, e: KeyboardEvent) {
|
||||
if (e.isComposing) return;
|
||||
const ret = markdownHandleIndention({value: textarea.value, selStart: textarea.selectionStart, selEnd: textarea.selectionEnd});
|
||||
if (!ret.handled || !ret.valueSelection) return; // FIXME: the "handled" seems redundant, only valueSelection is enough (null for unhandled)
|
||||
e.preventDefault();
|
||||
textarea.value = ret.valueSelection.value;
|
||||
textarea.setSelectionRange(ret.valueSelection.selStart, ret.valueSelection.selEnd);
|
||||
triggerEditorContentChanged(textarea);
|
||||
}
|
||||
|
||||
// Keys that act as dead keys will not work because the spec dictates that such keys are
|
||||
// emitted as `Dead` in e.key instead of the actual key.
|
||||
const pairs = new Map<string, string>([
|
||||
["'", "'"],
|
||||
['"', '"'],
|
||||
['`', '`'],
|
||||
['(', ')'],
|
||||
['[', ']'],
|
||||
['{', '}'],
|
||||
['<', '>'],
|
||||
]);
|
||||
|
||||
function handlePairCharacter(textarea: HTMLTextAreaElement, e: KeyboardEvent): void {
|
||||
const selStart = textarea.selectionStart;
|
||||
const selEnd = textarea.selectionEnd;
|
||||
if (selEnd === selStart) return; // do not process when no selection
|
||||
e.preventDefault();
|
||||
const inner = textarea.value.substring(selStart, selEnd);
|
||||
replaceTextareaSelection(textarea, `${e.key}${inner}${pairs.get(e.key)}`);
|
||||
textarea.setSelectionRange(selStart + 1, selEnd + 1);
|
||||
}
|
||||
|
||||
function isTextExpanderShown(textarea: HTMLElement): boolean {
|
||||
return Boolean(textarea.closest('text-expander')?.querySelector('.suggestions'));
|
||||
}
|
||||
|
||||
export function initTextareaMarkdown(textarea: HTMLTextAreaElement) {
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
if (e.isComposing) return;
|
||||
if (isTextExpanderShown(textarea)) return;
|
||||
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// use Tab/Shift-Tab to indent/unindent the selected lines
|
||||
handleIndentSelection(textarea, e);
|
||||
} else if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// use Enter to insert a new line with the same indention and prefix
|
||||
handleNewline(textarea, e);
|
||||
} else if (pairs.has(e.key)) {
|
||||
handlePairCharacter(textarea, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import {removeAttachmentLinksFromMarkdown} from './EditorUpload.ts';
|
||||
|
||||
test('removeAttachmentLinksFromMarkdown', () => {
|
||||
expect(removeAttachmentLinksFromMarkdown('a foo b', 'foo')).toBe('a foo b');
|
||||
expect(removeAttachmentLinksFromMarkdown('a [x](attachments/foo) b', 'foo')).toBe('a b');
|
||||
expect(removeAttachmentLinksFromMarkdown('a  b', 'foo')).toBe('a b');
|
||||
expect(removeAttachmentLinksFromMarkdown('a [x](/attachments/foo) b', 'foo')).toBe('a b');
|
||||
expect(removeAttachmentLinksFromMarkdown('a  b', 'foo')).toBe('a b');
|
||||
|
||||
expect(removeAttachmentLinksFromMarkdown('a <img src="attachments/foo"> b', 'foo')).toBe('a b');
|
||||
expect(removeAttachmentLinksFromMarkdown('a <img width="100" src="attachments/foo"> b', 'foo')).toBe('a b');
|
||||
expect(removeAttachmentLinksFromMarkdown('a <img src="/attachments/foo"> b', 'foo')).toBe('a b');
|
||||
expect(removeAttachmentLinksFromMarkdown('a <img src="/attachments/foo" width="100"/> b', 'foo')).toBe('a b');
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import {imageInfo} from '../../utils/image.ts';
|
||||
import {replaceTextareaSelection, triggerEditorContentChanged} from './EditorMarkdown.ts';
|
||||
import {
|
||||
DropzoneCustomEventRemovedFile,
|
||||
DropzoneCustomEventUploadDone,
|
||||
generateMarkdownLinkForAttachment,
|
||||
} from '../dropzone.ts';
|
||||
import {subscribe} from '@github/paste-markdown';
|
||||
import type CodeMirror from 'codemirror';
|
||||
import type EasyMDE from 'easymde';
|
||||
import type Dropzone from '@deltablot/dropzone';
|
||||
|
||||
let uploadIdCounter = 0;
|
||||
|
||||
export const EventUploadStateChanged = 'ce-upload-state-changed';
|
||||
|
||||
export function triggerUploadStateChanged(target: HTMLElement) {
|
||||
target.dispatchEvent(new CustomEvent(EventUploadStateChanged, {bubbles: true}));
|
||||
}
|
||||
|
||||
function uploadFile(dropzoneEl: HTMLElement, file: File) {
|
||||
return new Promise((resolve) => {
|
||||
const curUploadId = uploadIdCounter++;
|
||||
(file as any)._giteaUploadId = curUploadId;
|
||||
const dropzoneInst = dropzoneEl.dropzone;
|
||||
const onUploadDone = ({file}: {file: any}) => {
|
||||
if (file._giteaUploadId === curUploadId) {
|
||||
dropzoneInst.off(DropzoneCustomEventUploadDone, onUploadDone);
|
||||
resolve(file);
|
||||
}
|
||||
};
|
||||
dropzoneInst.on(DropzoneCustomEventUploadDone, onUploadDone);
|
||||
// FIXME: this is not entirely correct because `file` does not satisfy DropzoneFile (we have abused the Dropzone for long time)
|
||||
dropzoneInst.addFile(file as Dropzone.DropzoneFile);
|
||||
});
|
||||
}
|
||||
|
||||
class TextareaEditor {
|
||||
editor: HTMLTextAreaElement;
|
||||
|
||||
constructor(editor: HTMLTextAreaElement) {
|
||||
this.editor = editor;
|
||||
}
|
||||
|
||||
insertPlaceholder(value: string) {
|
||||
replaceTextareaSelection(this.editor, value);
|
||||
}
|
||||
|
||||
replacePlaceholder(oldVal: string, newVal: string) {
|
||||
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 {
|
||||
editor: CodeMirror.EditorFromTextArea;
|
||||
|
||||
constructor(editor: CodeMirror.EditorFromTextArea) {
|
||||
this.editor = editor;
|
||||
}
|
||||
|
||||
insertPlaceholder(value: string) {
|
||||
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: string, newVal: string) {
|
||||
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 handleUploadFiles(editor: CodeMirrorEditor | TextareaEditor, dropzoneEl: HTMLElement, files: Array<File> | FileList, e: Event) {
|
||||
e.preventDefault();
|
||||
for (const file of files) {
|
||||
const name = file.name.slice(0, file.name.lastIndexOf('.'));
|
||||
const {width, dppx} = await imageInfo(file);
|
||||
const placeholder = `[${name}](uploading ...)`;
|
||||
|
||||
editor.insertPlaceholder(placeholder);
|
||||
await uploadFile(dropzoneEl, file); // the "file" will get its "uuid" during the upload
|
||||
editor.replacePlaceholder(placeholder, generateMarkdownLinkForAttachment(file, {width, dppx}));
|
||||
}
|
||||
}
|
||||
|
||||
export function removeAttachmentLinksFromMarkdown(text: string, fileUuid: string) {
|
||||
text = text.replace(new RegExp(`!?\\[([^\\]]+)\\]\\(/?attachments/${fileUuid}\\)`, 'g'), '');
|
||||
text = text.replace(new RegExp(`[<]img[^>]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), '');
|
||||
return text;
|
||||
}
|
||||
|
||||
function getPastedImages(e: ClipboardEvent) {
|
||||
const images: Array<File> = [];
|
||||
for (const item of e.clipboardData?.items ?? []) {
|
||||
if (item.type?.startsWith('image/')) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
images.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
export function initEasyMDEPaste(easyMDE: EasyMDE, dropzoneEl: HTMLElement) {
|
||||
const editor = new CodeMirrorEditor(easyMDE.codemirror as any);
|
||||
easyMDE.codemirror.on('paste', (_, e) => {
|
||||
const images = getPastedImages(e);
|
||||
if (!images.length) return;
|
||||
handleUploadFiles(editor, dropzoneEl, images, e);
|
||||
});
|
||||
easyMDE.codemirror.on('drop', (_, e) => {
|
||||
if (!e.dataTransfer?.files.length) return;
|
||||
handleUploadFiles(editor, dropzoneEl, e.dataTransfer.files, e);
|
||||
});
|
||||
dropzoneEl.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}) => {
|
||||
const oldText = easyMDE.codemirror.getValue();
|
||||
const newText = removeAttachmentLinksFromMarkdown(oldText, fileUuid);
|
||||
if (oldText !== newText) easyMDE.codemirror.setValue(newText);
|
||||
});
|
||||
}
|
||||
|
||||
export function initTextareaEvents(textarea: HTMLTextAreaElement, dropzoneEl: HTMLElement | null) {
|
||||
subscribe(textarea); // enable paste features
|
||||
textarea.addEventListener('paste', (e: ClipboardEvent) => {
|
||||
const images = getPastedImages(e);
|
||||
if (images.length && dropzoneEl) {
|
||||
handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, images, e);
|
||||
}
|
||||
});
|
||||
textarea.addEventListener('drop', (e: DragEvent) => {
|
||||
if (!e.dataTransfer?.files.length) return;
|
||||
if (!dropzoneEl) return;
|
||||
handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, e.dataTransfer.files, e);
|
||||
});
|
||||
dropzoneEl?.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}: {fileUuid: string}) => {
|
||||
const newText = removeAttachmentLinksFromMarkdown(textarea.value, fileUuid);
|
||||
if (textarea.value !== newText) textarea.value = newText;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {toggleElem} from '../../utils/dom.ts';
|
||||
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
|
||||
import {submitFormFetchAction} from '../common-fetch-action.ts';
|
||||
|
||||
function nameHasScope(name: string): boolean {
|
||||
return /.*[^/]\/[^/].*/.test(name);
|
||||
}
|
||||
|
||||
export function initCompLabelEdit(pageSelector: string) {
|
||||
const pageContent = document.querySelector<HTMLElement>(pageSelector);
|
||||
if (!pageContent) return;
|
||||
|
||||
// for guest view, the modal is not available, the "labels" are read-only
|
||||
const elModal = pageContent.querySelector<HTMLElement>('#issue-label-edit-modal');
|
||||
if (!elModal) return;
|
||||
|
||||
const elLabelId = elModal.querySelector<HTMLInputElement>('input[name="id"]')!;
|
||||
const elNameInput = elModal.querySelector<HTMLInputElement>('.label-name-input')!;
|
||||
const elExclusiveField = elModal.querySelector('.label-exclusive-input-field')!;
|
||||
const elExclusiveInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-input')!;
|
||||
const elExclusiveWarning = elModal.querySelector('.label-exclusive-warning')!;
|
||||
const elExclusiveOrderField = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input-field')!;
|
||||
const elExclusiveOrderInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input')!;
|
||||
const elIsArchivedField = elModal.querySelector('.label-is-archived-input-field')!;
|
||||
const elIsArchivedInput = elModal.querySelector<HTMLInputElement>('.label-is-archived-input')!;
|
||||
const elDescInput = elModal.querySelector<HTMLInputElement>('.label-desc-input')!;
|
||||
const elColorInput = elModal.querySelector<HTMLInputElement>('.color-picker-combo input')!;
|
||||
|
||||
const syncModalUi = () => {
|
||||
const hasScope = nameHasScope(elNameInput.value);
|
||||
elExclusiveField.classList.toggle('disabled', !hasScope);
|
||||
const showExclusiveWarning = hasScope && elExclusiveInput.checked && elModal.hasAttribute('data-need-warn-exclusive');
|
||||
toggleElem(elExclusiveWarning, showExclusiveWarning);
|
||||
if (!hasScope) elExclusiveInput.checked = false;
|
||||
toggleElem(elExclusiveOrderField, elExclusiveInput.checked);
|
||||
|
||||
if (parseInt(elExclusiveOrderInput.value) <= 0) {
|
||||
elExclusiveOrderInput.style.color = 'var(--color-placeholder-text) !important';
|
||||
} else {
|
||||
elExclusiveOrderInput.style.removeProperty('color');
|
||||
}
|
||||
};
|
||||
|
||||
const showLabelEditModal = (btn:HTMLElement) => {
|
||||
// the "btn" should contain the label's attributes by its `data-label-xxx` attributes
|
||||
const form = elModal.querySelector<HTMLFormElement>('form')!;
|
||||
elLabelId.value = btn.getAttribute('data-label-id') || '';
|
||||
elNameInput.value = btn.getAttribute('data-label-name') || '';
|
||||
elExclusiveOrderInput.value = btn.getAttribute('data-label-exclusive-order') || '0';
|
||||
elIsArchivedInput.checked = btn.getAttribute('data-label-is-archived') === 'true';
|
||||
elExclusiveInput.checked = btn.getAttribute('data-label-exclusive') === 'true';
|
||||
elDescInput.value = btn.getAttribute('data-label-description') || '';
|
||||
elColorInput.value = btn.getAttribute('data-label-color') || '';
|
||||
elColorInput.dispatchEvent(new Event('input', {bubbles: true})); // trigger the color picker
|
||||
|
||||
// if label id exists: "edit label" mode; otherwise: "new label" mode
|
||||
const isEdit = Boolean(elLabelId.value);
|
||||
|
||||
// if a label was not exclusive but has issues, then it should warn user if it will become exclusive
|
||||
const numIssues = parseInt(btn.getAttribute('data-label-num-issues') || '0');
|
||||
elModal.toggleAttribute('data-need-warn-exclusive', !elExclusiveInput.checked && numIssues > 0);
|
||||
elModal.querySelector('.header')!.textContent = isEdit ? elModal.getAttribute('data-text-edit-label') : elModal.getAttribute('data-text-new-label');
|
||||
|
||||
const curPageLink = elModal.getAttribute('data-current-page-link');
|
||||
form.action = isEdit ? `${curPageLink}/edit` : `${curPageLink}/new`;
|
||||
toggleElem(elIsArchivedField, isEdit);
|
||||
syncModalUi();
|
||||
showFomanticModal(elModal, {
|
||||
onApprove() {
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return false;
|
||||
}
|
||||
submitFormFetchAction(form);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
elModal.addEventListener('input', () => syncModalUi());
|
||||
|
||||
// theoretically, if the modal exists, the "new label" button should also exist, just in case it doesn't, use "?."
|
||||
const elNewLabel = pageContent.querySelector<HTMLElement>('.ui.button.new-label');
|
||||
elNewLabel?.addEventListener('click', () => showLabelEditModal(elNewLabel));
|
||||
|
||||
const elEditLabelButtons = pageContent.querySelectorAll<HTMLElement>('.edit-label-button');
|
||||
for (const btn of elEditLabelButtons) {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
showLabelEditModal(btn);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {querySingleVisibleElem} from '../../utils/dom.ts';
|
||||
|
||||
export function handleGlobalEnterQuickSubmit(target: HTMLElement) {
|
||||
let form = target.closest('form');
|
||||
if (form) {
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
} else {
|
||||
// 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}));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
form = target.closest('.ui.form');
|
||||
if (form) {
|
||||
// A form should only have at most one "primary" button to do quick-submit.
|
||||
// Here we don't use a special class to mark the primary button,
|
||||
// because there could be a lot of forms with a primary button, the quick submit should work out-of-box,
|
||||
// but not keeps asking developers to add that special class again and again (it could be forgotten easily)
|
||||
querySingleVisibleElem<HTMLButtonElement>(form, '.ui.primary.button')?.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {POST} from '../../modules/fetch.ts';
|
||||
import {registerGlobalEventFunc} from '../../modules/observer.ts';
|
||||
|
||||
export function initCompReactionSelector() {
|
||||
registerGlobalEventFunc('click', 'onCommentReactionButtonClick', async (target: HTMLElement, e: Event) => {
|
||||
// there are 2 places for the "reaction" buttons, one is the top-right reaction menu, one is the bottom of the comment
|
||||
e.preventDefault();
|
||||
|
||||
if (target.classList.contains('disabled')) return;
|
||||
|
||||
const actionUrl = target.closest('[data-action-url]')!.getAttribute('data-action-url');
|
||||
const reactionContent = target.getAttribute('data-reaction-content')!;
|
||||
|
||||
const commentContainer = target.closest('.comment-container')!;
|
||||
|
||||
const bottomReactions = commentContainer.querySelector('.bottom-reactions'); // may not exist if there is no reaction
|
||||
const bottomReactionBtn = bottomReactions?.querySelector(`a[data-reaction-content="${CSS.escape(reactionContent)}"]`);
|
||||
const hasReacted = bottomReactionBtn?.getAttribute('data-has-reacted') === 'true';
|
||||
|
||||
const res = await POST(`${actionUrl}/${hasReacted ? 'unreact' : 'react'}`, {
|
||||
data: new URLSearchParams({content: reactionContent}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
bottomReactions?.remove();
|
||||
if (data.html) {
|
||||
commentContainer.insertAdjacentHTML('beforeend', data.html);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {attachSearchBox} from '../../modules/search.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
type RepoSearchResponse = {data: Array<{repository: {full_name: string}}>};
|
||||
|
||||
export function initCompSearchRepoBox(el: HTMLElement) {
|
||||
const uid = el.getAttribute('data-uid');
|
||||
const exclusive = el.getAttribute('data-exclusive');
|
||||
let url = `${appSubUrl}/repo/search?q={query}&uid=${uid}`;
|
||||
if (exclusive === 'true') url += `&exclusive=true`;
|
||||
attachSearchBox(el, url, (response: RepoSearchResponse) => response.data.map((item) => ({
|
||||
title: item.repository.full_name.split('/')[1],
|
||||
description: item.repository.full_name,
|
||||
})));
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {attachSearchBox, type SearchResult} from '../../modules/search.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
const looksLikeEmailAddressCheck = /^\S+@\S+$/;
|
||||
|
||||
type UserSearchResponse = {data: Array<{login: string; avatar_url: string; full_name: string}>};
|
||||
|
||||
export function initCompSearchUserBox() {
|
||||
const box = document.querySelector<HTMLElement>('#search-user-box');
|
||||
if (!box) return;
|
||||
|
||||
const allowEmailInput = box.getAttribute('data-allow-email') === 'true';
|
||||
const allowEmailDescription = box.getAttribute('data-allow-email-description') ?? undefined;
|
||||
const includeOrgs = box.getAttribute('data-include-orgs') === 'true';
|
||||
const url = `${appSubUrl}/user/search_candidates?q={query}&orgs=${includeOrgs}`;
|
||||
|
||||
attachSearchBox(box, url, (response: UserSearchResponse, query) => {
|
||||
const items: SearchResult[] = [];
|
||||
const queryUpper = query.toUpperCase();
|
||||
for (const item of response.data) {
|
||||
const result: SearchResult = {title: item.login, image: item.avatar_url, description: item.full_name};
|
||||
if (queryUpper === item.login.toUpperCase()) items.unshift(result); // exact match floats to top
|
||||
else items.push(result);
|
||||
}
|
||||
if (allowEmailInput && !items.length && looksLikeEmailAddressCheck.test(query)) {
|
||||
items.push({title: query, description: allowEmailDescription});
|
||||
}
|
||||
return items;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import {matchEmoji, matchMention, matchIssue} from '../../utils/match.ts';
|
||||
import {emojiString} from '../emoji.ts';
|
||||
import {svg} from '../../svg.ts';
|
||||
import {parseIssueHref, parseRepoOwnerPathInfo} from '../../utils.ts';
|
||||
import {createElementFromAttrs, createElementFromHTML} from '../../utils/dom.ts';
|
||||
import {getIssueColorClass, getIssueIcon} from '../issue.ts';
|
||||
import {debounce} from 'perfect-debounce';
|
||||
import type TextExpanderElement from '@github/text-expander-element';
|
||||
import type {TextExpanderChangeEvent, TextExpanderResult} from '@github/text-expander-element';
|
||||
|
||||
async function fetchIssueSuggestions(key: string, text: string): Promise<TextExpanderResult> {
|
||||
const issuePathInfo = parseIssueHref(window.location.href);
|
||||
if (!issuePathInfo.ownerName) {
|
||||
const repoOwnerPathInfo = parseRepoOwnerPathInfo(window.location.pathname);
|
||||
issuePathInfo.ownerName = repoOwnerPathInfo.ownerName;
|
||||
issuePathInfo.repoName = repoOwnerPathInfo.repoName;
|
||||
// then no issuePathInfo.indexString here, it is only used to exclude the current issue when "matchIssue"
|
||||
}
|
||||
if (!issuePathInfo.ownerName) return {matched: false};
|
||||
|
||||
const matches = await matchIssue(issuePathInfo.ownerName, issuePathInfo.repoName, issuePathInfo.indexString, text);
|
||||
if (!matches.length) return {matched: false};
|
||||
|
||||
const ul = createElementFromAttrs('ul', {class: 'suggestions'});
|
||||
for (const issue of matches) {
|
||||
const li = createElementFromAttrs(
|
||||
'li', {role: 'option', class: 'tw-flex tw-gap-2', 'data-value': `${key}${issue.number}`},
|
||||
createElementFromHTML(svg(getIssueIcon(issue), 16, [getIssueColorClass(issue)])),
|
||||
createElementFromAttrs('span', null, `#${issue.number}`),
|
||||
createElementFromAttrs('span', null, issue.title),
|
||||
);
|
||||
ul.append(li);
|
||||
}
|
||||
return {matched: true, fragment: ul};
|
||||
}
|
||||
|
||||
export function initTextExpander(expander: TextExpanderElement) {
|
||||
if (!expander) return;
|
||||
|
||||
const textarea = expander.querySelector<HTMLTextAreaElement>('textarea')!;
|
||||
const mentionsUrl = expander.closest('[data-mentions-url]')?.getAttribute('data-mentions-url');
|
||||
|
||||
// help to fix the text-expander "multiword+promise" bug: do not show the popup when there is no "#" before current line
|
||||
const shouldShowIssueSuggestions = () => {
|
||||
const posVal = textarea.value.substring(0, textarea.selectionStart);
|
||||
const lineStart = posVal.lastIndexOf('\n');
|
||||
const keyStart = posVal.lastIndexOf('#');
|
||||
return keyStart > lineStart;
|
||||
};
|
||||
|
||||
const debouncedIssueSuggestions = debounce(async (key: string, text: string): Promise<TextExpanderResult> => {
|
||||
// https://github.com/github/text-expander-element/issues/71
|
||||
// Upstream bug: when using "multiword+promise", TextExpander will get wrong "key" position.
|
||||
// To reproduce, comment out the "shouldShowIssueSuggestions" check, use the "await sleep" below,
|
||||
// then use content "close #20\nclose #20\nclose #20" (3 lines), keep changing the last line `#20` part from the end (including removing the `#`)
|
||||
// There will be a JS error: Uncaught (in promise) IndexSizeError: Failed to execute 'setStart' on 'Range': The offset 28 is larger than the node's length (27).
|
||||
|
||||
// check the input before the request, to avoid emitting empty query to backend (still related to the upstream bug)
|
||||
if (!shouldShowIssueSuggestions()) return {matched: false};
|
||||
// await sleep(Math.random() * 1000); // help to reproduce the text-expander bug
|
||||
const ret = await fetchIssueSuggestions(key, text);
|
||||
// check the input again to avoid text-expander using incorrect position (upstream bug)
|
||||
if (!shouldShowIssueSuggestions()) return {matched: false};
|
||||
return ret;
|
||||
}, 300); // to match onInputDebounce delay
|
||||
|
||||
expander.addEventListener('text-expander-change', (e: TextExpanderChangeEvent) => {
|
||||
if (!e.detail) return;
|
||||
const {key, text, provide} = e.detail;
|
||||
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 === '@') {
|
||||
provide((async (): Promise<TextExpanderResult> => {
|
||||
if (!mentionsUrl) return {matched: false};
|
||||
const matches = await matchMention(mentionsUrl, text);
|
||||
if (!matches.length) return {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.classList.add('name');
|
||||
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);
|
||||
}
|
||||
|
||||
return {matched: true, fragment: ul};
|
||||
})());
|
||||
} else if (key === '#') {
|
||||
provide(debouncedIssueSuggestions(key, text));
|
||||
}
|
||||
});
|
||||
|
||||
expander.addEventListener('text-expander-value', ({detail}: Record<string, any>) => {
|
||||
if (detail?.item) {
|
||||
// add a space after @mentions and #issue as it's likely the user wants one
|
||||
const suffix = ['@', '#'].includes(detail.key) ? ' ' : '';
|
||||
detail.value = `${detail.item.getAttribute('data-value')}${suffix}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {POST} from '../../modules/fetch.ts';
|
||||
import {hideElem, showElem, toggleElem} from '../../utils/dom.ts';
|
||||
|
||||
export function initCompWebHookEditor() {
|
||||
if (!document.querySelectorAll('.new.webhook').length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const input of document.querySelectorAll<HTMLInputElement>('.events.checkbox input')) {
|
||||
input.addEventListener('change', function () {
|
||||
if (this.checked) {
|
||||
showElem('.events.fields');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const input of document.querySelectorAll<HTMLInputElement>('.non-events.checkbox input')) {
|
||||
input.addEventListener('change', function () {
|
||||
if (this.checked) {
|
||||
hideElem('.events.fields');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// some webhooks (like Gitea) allow to set the request method (GET/POST), and it would toggle the "Content Type" field
|
||||
const httpMethodInput = document.querySelector<HTMLInputElement>('#http_method');
|
||||
if (httpMethodInput) {
|
||||
const updateContentType = function () {
|
||||
const visible = httpMethodInput.value === 'POST';
|
||||
toggleElem(document.querySelector('#content_type')!.closest('.field')!, visible);
|
||||
};
|
||||
updateContentType();
|
||||
httpMethodInput.addEventListener('change', updateContentType);
|
||||
}
|
||||
|
||||
// Test delivery
|
||||
document.querySelector<HTMLButtonElement>('#test-delivery')?.addEventListener('click', async function () {
|
||||
this.classList.add('is-loading', 'disabled');
|
||||
await POST(this.getAttribute('data-link')!);
|
||||
setTimeout(() => window.location.reload(), 5000);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {createApp} from 'vue';
|
||||
|
||||
export async function initRepoContributors() {
|
||||
const el = document.querySelector('#repo-contributors-chart');
|
||||
if (!el) return;
|
||||
|
||||
const {default: RepoContributors} = await import('../components/RepoContributors.vue');
|
||||
try {
|
||||
const View = createApp(RepoContributors, {
|
||||
repoLink: el.getAttribute('data-repo-link'),
|
||||
repoDefaultBranchName: el.getAttribute('data-repo-default-branch-name'),
|
||||
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'),
|
||||
chartZoomHint: el.getAttribute('data-locale-chart-zoom-hint'),
|
||||
},
|
||||
});
|
||||
View.mount(el);
|
||||
} catch (err) {
|
||||
console.error('RepoContributors failed to load', err);
|
||||
el.textContent = el.getAttribute('data-locale-component-failed-to-load');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
|
||||
import {convertImage} from '../utils.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {registerGlobalEventFunc} from '../modules/observer.ts';
|
||||
|
||||
export function initCopyContent() {
|
||||
registerGlobalEventFunc('click', 'onCopyContentButtonClick', async (btn: HTMLElement) => {
|
||||
if (btn.classList.contains('disabled') || btn.classList.contains('is-loading')) return;
|
||||
await copyToClipboardWithFeedback(btn, async () => {
|
||||
const rawFileLink = btn.getAttribute('data-raw-file-link');
|
||||
if (!rawFileLink) {
|
||||
const lineEls = document.querySelectorAll('.file-view .lines-code');
|
||||
return Array.from(lineEls, (el) => el.textContent).join('');
|
||||
}
|
||||
const res = await GET(rawFileLink, {credentials: 'include', redirect: 'follow'});
|
||||
const contentType = res.headers.get('content-type')!;
|
||||
if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {
|
||||
// browsers only accept image/png in the clipboard, convert other raster formats
|
||||
const blob = await res.blob();
|
||||
return contentType === 'image/png' ? blob : convertImage(blob, 'image/png');
|
||||
}
|
||||
return await res.text();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createApp} from 'vue';
|
||||
import DashboardRepoList from '../components/DashboardRepoList.vue';
|
||||
|
||||
export function initDashboardRepoList() {
|
||||
const el = document.querySelector('#dashboard-repo-list');
|
||||
if (el) {
|
||||
createApp(DashboardRepoList).mount(el);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import {svg} from '../svg.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {isImageFile, isVideoFile} from '../utils.ts';
|
||||
import type Dropzone from '@deltablot/dropzone';
|
||||
|
||||
type CustomDropzoneFile = Dropzone.DropzoneFile & {uuid: string};
|
||||
|
||||
// dropzone has its owner event dispatcher (emitter)
|
||||
export const DropzoneCustomEventReloadFiles = 'dropzone-custom-reload-files';
|
||||
export const DropzoneCustomEventRemovedFile = 'dropzone-custom-removed-file';
|
||||
export const DropzoneCustomEventUploadDone = 'dropzone-custom-upload-done';
|
||||
|
||||
async function createDropzone(el: HTMLElement, opts: Dropzone.DropzoneOptions) {
|
||||
const [{default: Dropzone}] = await Promise.all([
|
||||
import('@deltablot/dropzone'),
|
||||
import('@deltablot/dropzone/dist/dropzone.css'),
|
||||
]);
|
||||
return new Dropzone(el, opts);
|
||||
}
|
||||
|
||||
export function generateMarkdownLinkForAttachment(file: Partial<CustomDropzoneFile>, {width, dppx}: {width?: number, dppx?: number} = {}) {
|
||||
let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`;
|
||||
if (isImageFile(file)) {
|
||||
if (width && width > 0 && dppx && dppx > 1) {
|
||||
// Scale down images from HiDPI monitors. This uses the <img> tag because it's the only
|
||||
// method to change image size in Markdown that is supported by all implementations.
|
||||
// Make the image link relative to the repo path, then the final URL is "/sub-path/owner/repo/attachments/{uuid}"
|
||||
fileMarkdown = html`<img width="${Math.round(width / dppx)}" alt="${file.name}" src="attachments/${file.uuid}">`;
|
||||
} else {
|
||||
// Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}"
|
||||
// TODO: it should also use relative path for consistency, because absolute is ambiguous for "/sub-path/attachments" or "/attachments"
|
||||
fileMarkdown = ``;
|
||||
}
|
||||
} else if (isVideoFile(file)) {
|
||||
fileMarkdown = html`<video src="attachments/${file.uuid}" title="${file.name}" controls></video>`;
|
||||
}
|
||||
return fileMarkdown;
|
||||
}
|
||||
|
||||
function addCopyLink(file: Partial<CustomDropzoneFile>) {
|
||||
// Create a "Copy Link" element, to conveniently copy the image or file link as Markdown to the clipboard
|
||||
// The "<a>" element has a hardcoded cursor: pointer because the default is overridden by .dropzone
|
||||
const copyLinkEl = createElementFromHTML<HTMLDivElement>(`
|
||||
<div class="tw-text-center">
|
||||
<a href="#" class="tw-cursor-pointer">${svg('octicon-copy', 14)} Copy link</a>
|
||||
</div>`);
|
||||
copyLinkEl.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
await copyToClipboardWithFeedback(copyLinkEl, generateMarkdownLinkForAttachment(file));
|
||||
});
|
||||
file.previewTemplate!.append(copyLinkEl);
|
||||
}
|
||||
|
||||
type FileUuidDict = Record<string, {submitted: boolean}>;
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} dropzoneEl
|
||||
*/
|
||||
export async function initDropzone(dropzoneEl: HTMLElement) {
|
||||
const listAttachmentsUrl = dropzoneEl.closest('[data-attachment-url]')?.getAttribute('data-attachment-url');
|
||||
const removeAttachmentUrl = dropzoneEl.getAttribute('data-remove-url')!;
|
||||
const attachmentBaseLinkUrl = dropzoneEl.getAttribute('data-link-url')!;
|
||||
|
||||
let disableRemovedfileEvent = false; // when resetting the dropzone (removeAllFiles), disable the "removedfile" event
|
||||
let fileUuidDict: 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 opts: Record<string, any> = {
|
||||
url: dropzoneEl.getAttribute('data-upload-url'),
|
||||
acceptedFiles: ['*/*', ''].includes(dropzoneEl.getAttribute('data-accepts')!) ? null : dropzoneEl.getAttribute('data-accepts'),
|
||||
addRemoveLinks: true,
|
||||
dictDefaultMessage: dropzoneEl.getAttribute('data-default-message'),
|
||||
dictInvalidFileType: dropzoneEl.getAttribute('data-invalid-input-type'),
|
||||
dictFileTooBig: dropzoneEl.getAttribute('data-file-too-big'),
|
||||
dictRemoveFile: dropzoneEl.getAttribute('data-remove-file'),
|
||||
timeout: 0,
|
||||
thumbnailMethod: 'contain',
|
||||
thumbnailWidth: 480,
|
||||
thumbnailHeight: 480,
|
||||
};
|
||||
if (dropzoneEl.hasAttribute('data-max-file')) opts.maxFiles = Number(dropzoneEl.getAttribute('data-max-file'));
|
||||
if (dropzoneEl.hasAttribute('data-max-size')) opts.maxFilesize = Number(dropzoneEl.getAttribute('data-max-size'));
|
||||
|
||||
// there is a bug in dropzone: if a non-image file is uploaded, then it tries to request the file from server by something like:
|
||||
// "http://localhost:3000/owner/repo/issues/[object%20Event]"
|
||||
// the reason is that the preview "callback(dataURL)" is assign to "img.onerror" then "thumbnail" uses the error object as the dataURL and generates '<img src="[object Event]">'
|
||||
const dzInst = await createDropzone(dropzoneEl, opts);
|
||||
dzInst.on('success', (file: CustomDropzoneFile, resp: any) => {
|
||||
file.uuid = resp.uuid;
|
||||
fileUuidDict[file.uuid] = {submitted: false};
|
||||
const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${resp.uuid}`, value: resp.uuid});
|
||||
dropzoneEl.querySelector('.files')!.append(input);
|
||||
addCopyLink(file);
|
||||
dzInst.emit(DropzoneCustomEventUploadDone, {file});
|
||||
});
|
||||
|
||||
dzInst.on('removedfile', async (file: CustomDropzoneFile) => {
|
||||
if (disableRemovedfileEvent) return;
|
||||
|
||||
dzInst.emit(DropzoneCustomEventRemovedFile, {fileUuid: file.uuid});
|
||||
document.querySelector(`#dropzone-file-${file.uuid}`)?.remove();
|
||||
// when the uploaded file number reaches the limit, there is no uuid in the dict, and it doesn't need to be removed from server
|
||||
if (removeAttachmentUrl && fileUuidDict[file.uuid] && !fileUuidDict[file.uuid].submitted) {
|
||||
await POST(removeAttachmentUrl, {data: new URLSearchParams({file: file.uuid})});
|
||||
}
|
||||
});
|
||||
|
||||
dzInst.on('submit', () => {
|
||||
for (const fileUuid of Object.keys(fileUuidDict)) {
|
||||
fileUuidDict[fileUuid].submitted = true;
|
||||
}
|
||||
});
|
||||
|
||||
dzInst.on(DropzoneCustomEventReloadFiles, async () => {
|
||||
try {
|
||||
if (!listAttachmentsUrl) return;
|
||||
const resp = await GET(listAttachmentsUrl);
|
||||
const respData = await resp.json();
|
||||
// do not trigger the "removedfile" event, otherwise the attachments would be deleted from server
|
||||
disableRemovedfileEvent = true;
|
||||
dzInst.removeAllFiles(true);
|
||||
disableRemovedfileEvent = false;
|
||||
|
||||
dropzoneEl.querySelector('.files')!.innerHTML = '';
|
||||
for (const el of dropzoneEl.querySelectorAll('.dz-preview')) el.remove();
|
||||
fileUuidDict = {};
|
||||
for (const attachment of respData) {
|
||||
const file = {name: attachment.name, uuid: attachment.uuid, size: attachment.size};
|
||||
dzInst.emit('addedfile', file);
|
||||
dzInst.emit('complete', file);
|
||||
if (isImageFile(file.name)) {
|
||||
const imgSrc = `${attachmentBaseLinkUrl}/${file.uuid}`;
|
||||
dzInst.emit('thumbnail', file, imgSrc);
|
||||
}
|
||||
addCopyLink(file); // it is from server response, so no "type"
|
||||
fileUuidDict[file.uuid] = {submitted: true};
|
||||
const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${file.uuid}`, value: file.uuid});
|
||||
dropzoneEl.querySelector('.files')!.append(input);
|
||||
}
|
||||
if (!dropzoneEl.querySelector('.dz-preview')) {
|
||||
dropzoneEl.classList.remove('dz-started');
|
||||
}
|
||||
} catch (error) {
|
||||
// TODO: if listing the existing attachments failed, it should stop from operating the content or attachments,
|
||||
// otherwise the attachments might be lost.
|
||||
showErrorToast(`Failed to load attachments: ${errorMessage(error)}`);
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
dzInst.on('error', (file, message) => {
|
||||
showErrorToast(`Dropzone upload error: ${message}`);
|
||||
dzInst.removeFile(file);
|
||||
});
|
||||
|
||||
if (listAttachmentsUrl) dzInst.emit(DropzoneCustomEventReloadFiles);
|
||||
return dzInst;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import emojis from '../../../assets/emoji.json' with {type: 'json'};
|
||||
import {html} from '../utils/html.ts';
|
||||
|
||||
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: Record<string, string> = {};
|
||||
for (const key of emojiKeys) {
|
||||
emojiMap[key] = tempMap[key];
|
||||
}
|
||||
|
||||
// retrieve HTML for given emoji name
|
||||
export function emojiHTML(name: string) {
|
||||
let inner;
|
||||
if (Object.hasOwn(customEmojis, name)) {
|
||||
inner = html`<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`;
|
||||
} else {
|
||||
inner = emojiString(name);
|
||||
}
|
||||
return html`<span class="emoji" title=":${name}:">${inner}</span>`;
|
||||
}
|
||||
|
||||
// retrieve string for given emoji name
|
||||
export function emojiString(name: string) {
|
||||
return emojiMap[name] || `:${name}:`;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {svg} from '../svg.ts';
|
||||
|
||||
// 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: Element, foldArrow: HTMLElement, newFold: boolean) {
|
||||
foldArrow.innerHTML = svg(`octicon-chevron-${newFold ? 'right' : 'down'}`, 18);
|
||||
fileContentBox.setAttribute('data-folded', String(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:HTMLElement, foldArrow: HTMLElement) {
|
||||
setFileFolding(fileContentBox, foldArrow, fileContentBox.getAttribute('data-folded') !== 'true');
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type {InplaceRenderPlugin} from '../render/plugin.ts';
|
||||
import {newInplacePluginPdfViewer} from '../render/plugins/inplace-pdf-viewer.ts';
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {createElementFromHTML} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
import {basename} from '../utils.ts';
|
||||
|
||||
const inplacePlugins: InplaceRenderPlugin[] = [];
|
||||
|
||||
function initInplacePluginsOnce(): void {
|
||||
if (inplacePlugins.length) return;
|
||||
inplacePlugins.push(newInplacePluginPdfViewer());
|
||||
}
|
||||
|
||||
function findInplaceRenderPlugin(filename: string, mimeType: string): InplaceRenderPlugin | null {
|
||||
return inplacePlugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null;
|
||||
}
|
||||
|
||||
async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string) {
|
||||
const elViewRawPrompt = container.querySelector('.file-view-raw-prompt');
|
||||
if (!rawFileLink || !elViewRawPrompt) throw new Error('unexpected file view container');
|
||||
|
||||
let rendered = false, errorMsg = '';
|
||||
try {
|
||||
const plugin = findInplaceRenderPlugin(basename(rawFileLink), mimeType);
|
||||
if (plugin) {
|
||||
container.classList.add('is-loading');
|
||||
container.setAttribute('data-render-name', plugin.name); // not used yet
|
||||
await plugin.render(container, rawFileLink);
|
||||
rendered = true;
|
||||
}
|
||||
} catch (e) {
|
||||
errorMsg = errorMessage(e);
|
||||
} finally {
|
||||
container.classList.remove('is-loading');
|
||||
}
|
||||
|
||||
if (rendered) {
|
||||
elViewRawPrompt.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// remove all children from the container, and only show the raw file link
|
||||
container.replaceChildren(elViewRawPrompt);
|
||||
|
||||
if (errorMsg) {
|
||||
const elErrorMessage = createElementFromHTML(html`<div class="ui error message">${errorMsg}</div>`);
|
||||
elViewRawPrompt.insertAdjacentElement('afterbegin', elErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
export function initRepoFileView(): void {
|
||||
registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => {
|
||||
initInplacePluginsOnce();
|
||||
const rawFileLink = elFileView.getAttribute('data-raw-file-link')!;
|
||||
const mimeType = elFileView.getAttribute('data-mime-type') || ''; // not used yet
|
||||
const plugin = findInplaceRenderPlugin(basename(rawFileLink), mimeType);
|
||||
if (!plugin) return;
|
||||
|
||||
const renderContainer = elFileView.querySelector<HTMLElement>('.file-view-render-container');
|
||||
if (renderContainer) await renderRawFileToContainer(renderContainer, rawFileLink, mimeType);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {createApp} from 'vue';
|
||||
import {translateMonth, translateDay} from '../utils.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
|
||||
type HeatmapResponse = {
|
||||
heatmapData: Array<[number, number]>; // [[1617235200, 2]] = [unix timestamp, count]
|
||||
totalContributions: number;
|
||||
};
|
||||
|
||||
export async function initHeatmap() {
|
||||
const el = document.querySelector<HTMLElement>('#user-heatmap');
|
||||
if (!el) return;
|
||||
|
||||
try {
|
||||
const url = el.getAttribute('data-heatmap-url')!;
|
||||
const resp = await GET(url);
|
||||
if (!resp.ok) throw new Error(`Failed to load heatmap data: ${resp.status} ${resp.statusText}`);
|
||||
const {heatmapData, totalContributions} = await resp.json() as HeatmapResponse;
|
||||
|
||||
const heatmap: Record<string, number> = {};
|
||||
for (const [timestamp, contributions] of heatmapData) {
|
||||
// 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 totalFormatted = totalContributions.toLocaleString();
|
||||
const textTotalContributions = el.getAttribute('data-locale-total-contributions')!.replace('%s', totalFormatted);
|
||||
|
||||
// last heatmap tooltip localization attempt https://github.com/go-gitea/gitea/pull/24131/commits/a83761cbbae3c2e3b4bced71e680f44432073ac8
|
||||
const locale = {
|
||||
heatMapLocale: {
|
||||
months: new Array(12).fill(undefined).map((_, idx) => translateMonth(idx)),
|
||||
days: new Array(7).fill(undefined).map((_, idx) => translateDay(idx)),
|
||||
on: ' - ', // no correct locale support for it, because in many languages the sentence is not "something on someday"
|
||||
more: el.getAttribute('data-locale-more'),
|
||||
less: el.getAttribute('data-locale-less'),
|
||||
},
|
||||
tooltipUnit: 'contributions',
|
||||
textTotalContributions,
|
||||
noDataText: el.getAttribute('data-locale-no-contributions'),
|
||||
};
|
||||
|
||||
const {default: ActivityHeatmap} = await import('../components/ActivityHeatmap.vue');
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {hideElem, loadElem, queryElemChildren, queryElems} from '../utils/dom.ts';
|
||||
import {parseDom} from '../utils.ts';
|
||||
|
||||
type ImageContext = {
|
||||
imageBefore: HTMLImageElement | undefined,
|
||||
imageAfter: HTMLImageElement | undefined,
|
||||
sizeBefore: {width: number, height: number},
|
||||
sizeAfter: {width: number, height: number},
|
||||
maxSize: {width: number, height: number},
|
||||
ratio: [number, number, number, number],
|
||||
};
|
||||
|
||||
type ImageInfo = {
|
||||
path: string | null,
|
||||
mime: string | null,
|
||||
images: NodeListOf<HTMLImageElement>,
|
||||
boundsInfo: HTMLElement | null,
|
||||
};
|
||||
|
||||
type Bounds = {
|
||||
width: number,
|
||||
height: number,
|
||||
} | null;
|
||||
|
||||
type SvgBoundsInfo = {
|
||||
before: Bounds,
|
||||
after: Bounds,
|
||||
};
|
||||
|
||||
function getDefaultSvgBoundsIfUndefined(text: string, src: string): Bounds | null {
|
||||
const defaultSize = 300;
|
||||
const maxSize = 99999;
|
||||
|
||||
const svgDoc = parseDom(text, 'image/svg+xml');
|
||||
const svg = (svgDoc.documentElement as unknown) as SVGSVGElement;
|
||||
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.height / viewBox.width,
|
||||
};
|
||||
}
|
||||
return {
|
||||
width: defaultSize,
|
||||
height: defaultSize,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createContext(imageAfter: HTMLImageElement, imageBefore: HTMLImageElement, svgBoundsInfo: SvgBoundsInfo): ImageContext {
|
||||
const sizeAfter = {
|
||||
width: svgBoundsInfo.after?.width || imageAfter?.width || 0,
|
||||
height: svgBoundsInfo.after?.height || imageAfter?.height || 0,
|
||||
};
|
||||
const sizeBefore = {
|
||||
width: svgBoundsInfo.before?.width || imageBefore?.width || 0,
|
||||
height: svgBoundsInfo.before?.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,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
class ImageDiff {
|
||||
containerEl!: HTMLElement;
|
||||
diffContainerWidth!: number;
|
||||
|
||||
async init(containerEl: HTMLElement) {
|
||||
this.containerEl = containerEl;
|
||||
containerEl.setAttribute('data-image-diff-loaded', 'true');
|
||||
|
||||
// the container may be hidden by "viewed" checkbox, so use the parent's width for reference
|
||||
this.diffContainerWidth = Math.max(containerEl.closest('.diff-file-box')!.clientWidth - 300, 100);
|
||||
|
||||
const imagePair: [ImageInfo, ImageInfo] = [{
|
||||
path: containerEl.getAttribute('data-path-after'),
|
||||
mime: containerEl.getAttribute('data-mime-after'),
|
||||
images: containerEl.querySelectorAll<HTMLImageElement>('img.image-after'), // matches 3 <img>
|
||||
boundsInfo: containerEl.querySelector('.bounds-info-after'),
|
||||
}, {
|
||||
path: containerEl.getAttribute('data-path-before'),
|
||||
mime: containerEl.getAttribute('data-mime-before'),
|
||||
images: containerEl.querySelectorAll<HTMLImageElement>('img.image-before'), // matches 3 <img>
|
||||
boundsInfo: containerEl.querySelector('.bounds-info-before'),
|
||||
}];
|
||||
|
||||
const svgBoundsInfo: SvgBoundsInfo = {before: null, after: null};
|
||||
await Promise.all(imagePair.map(async (info, index) => {
|
||||
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) info.boundsInfo.textContent = '(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!);
|
||||
svgBoundsInfo[index === 0 ? 'after' : 'before'] = bounds;
|
||||
if (bounds) {
|
||||
hideElem(info.boundsInfo!);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const imagesAfter = imagePair[0].images;
|
||||
const imagesBefore = imagePair[1].images;
|
||||
|
||||
this.initSideBySide(createContext(imagesAfter[0], imagesBefore[0], svgBoundsInfo));
|
||||
if (imagesAfter.length > 0 && imagesBefore.length > 0) {
|
||||
this.initSwipe(createContext(imagesAfter[1], imagesBefore[1], svgBoundsInfo));
|
||||
this.initOverlay(createContext(imagesAfter[2], imagesBefore[2], svgBoundsInfo));
|
||||
}
|
||||
queryElemChildren(containerEl, '.image-diff-tabs', (el) => el.classList.remove('is-loading'));
|
||||
}
|
||||
|
||||
initSideBySide(ctx: ImageContext) {
|
||||
let factor = 1;
|
||||
if (ctx.maxSize.width > (this.diffContainerWidth - 24) / 2) {
|
||||
factor = (this.diffContainerWidth - 24) / 2 / ctx.maxSize.width;
|
||||
}
|
||||
|
||||
const widthChanged = ctx.imageAfter && ctx.imageBefore && ctx.imageAfter.naturalWidth !== ctx.imageBefore.naturalWidth;
|
||||
const heightChanged = ctx.imageAfter && ctx.imageBefore && ctx.imageAfter.naturalHeight !== ctx.imageBefore.naturalHeight;
|
||||
if (ctx.imageAfter) {
|
||||
const boundsInfoAfterWidth = this.containerEl.querySelector('.bounds-info-after .bounds-info-width');
|
||||
if (boundsInfoAfterWidth) {
|
||||
boundsInfoAfterWidth.textContent = `${ctx.imageAfter.naturalWidth}px`;
|
||||
boundsInfoAfterWidth.classList.toggle('green', widthChanged);
|
||||
}
|
||||
const boundsInfoAfterHeight = this.containerEl.querySelector('.bounds-info-after .bounds-info-height');
|
||||
if (boundsInfoAfterHeight) {
|
||||
boundsInfoAfterHeight.textContent = `${ctx.imageAfter.naturalHeight}px`;
|
||||
boundsInfoAfterHeight.classList.toggle('green', heightChanged);
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.imageBefore) {
|
||||
const boundsInfoBeforeWidth = this.containerEl.querySelector('.bounds-info-before .bounds-info-width');
|
||||
if (boundsInfoBeforeWidth) {
|
||||
boundsInfoBeforeWidth.textContent = `${ctx.imageBefore.naturalWidth}px`;
|
||||
boundsInfoBeforeWidth.classList.toggle('red', widthChanged);
|
||||
}
|
||||
const boundsInfoBeforeHeight = this.containerEl.querySelector('.bounds-info-before .bounds-info-height');
|
||||
if (boundsInfoBeforeHeight) {
|
||||
boundsInfoBeforeHeight.textContent = `${ctx.imageBefore.naturalHeight}px`;
|
||||
boundsInfoBeforeHeight.classList.toggle('red', heightChanged);
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.imageAfter) {
|
||||
const container = ctx.imageAfter.parentNode as HTMLElement;
|
||||
ctx.imageAfter.style.width = `${ctx.sizeAfter.width * factor}px`;
|
||||
ctx.imageAfter.style.height = `${ctx.sizeAfter.height * factor}px`;
|
||||
container.style.margin = '10px auto';
|
||||
container.style.width = `${ctx.sizeAfter.width * factor + 2}px`;
|
||||
container.style.height = `${ctx.sizeAfter.height * factor + 2}px`;
|
||||
}
|
||||
|
||||
if (ctx.imageBefore) {
|
||||
const container = ctx.imageBefore.parentNode as HTMLElement;
|
||||
ctx.imageBefore.style.width = `${ctx.sizeBefore.width * factor}px`;
|
||||
ctx.imageBefore.style.height = `${ctx.sizeBefore.height * factor}px`;
|
||||
container.style.margin = '10px auto';
|
||||
container.style.width = `${ctx.sizeBefore.width * factor + 2}px`;
|
||||
container.style.height = `${ctx.sizeBefore.height * factor + 2}px`;
|
||||
}
|
||||
}
|
||||
|
||||
initSwipe(ctx: ImageContext) {
|
||||
let factor = 1;
|
||||
if (ctx.maxSize.width > this.diffContainerWidth - 12) {
|
||||
factor = (this.diffContainerWidth - 12) / ctx.maxSize.width;
|
||||
}
|
||||
|
||||
if (ctx.imageAfter) {
|
||||
const imgParent = ctx.imageAfter.parentNode as HTMLElement;
|
||||
const swipeFrame = imgParent.parentNode as HTMLElement;
|
||||
ctx.imageAfter.style.width = `${ctx.sizeAfter.width * factor}px`;
|
||||
ctx.imageAfter.style.height = `${ctx.sizeAfter.height * factor}px`;
|
||||
imgParent.style.margin = `0px ${ctx.ratio[0] * factor}px`;
|
||||
imgParent.style.width = `${ctx.sizeAfter.width * factor + 2}px`;
|
||||
imgParent.style.height = `${ctx.sizeAfter.height * factor + 2}px`;
|
||||
swipeFrame.style.padding = `${ctx.ratio[1] * factor}px 0 0 0`;
|
||||
swipeFrame.style.width = `${ctx.maxSize.width * factor + 2}px`;
|
||||
}
|
||||
|
||||
if (ctx.imageBefore) {
|
||||
const imgParent = ctx.imageBefore.parentNode as HTMLElement;
|
||||
const swipeFrame = imgParent.parentNode as HTMLElement;
|
||||
ctx.imageBefore.style.width = `${ctx.sizeBefore.width * factor}px`;
|
||||
ctx.imageBefore.style.height = `${ctx.sizeBefore.height * factor}px`;
|
||||
imgParent.style.margin = `${ctx.ratio[3] * factor}px ${ctx.ratio[2] * factor}px`;
|
||||
imgParent.style.width = `${ctx.sizeBefore.width * factor + 2}px`;
|
||||
imgParent.style.height = `${ctx.sizeBefore.height * factor + 2}px`;
|
||||
swipeFrame.style.width = `${ctx.maxSize.width * factor + 2}px`;
|
||||
swipeFrame.style.height = `${ctx.maxSize.height * factor + 2}px`;
|
||||
}
|
||||
|
||||
// extra height for inner "position: absolute" elements
|
||||
const swipe = this.containerEl.querySelector<HTMLElement>('.diff-swipe');
|
||||
if (swipe) {
|
||||
swipe.style.width = `${ctx.maxSize.width * factor + 2}px`;
|
||||
swipe.style.height = `${ctx.maxSize.height * factor + 30}px`;
|
||||
}
|
||||
|
||||
this.containerEl.querySelector('.swipe-bar')!.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
this.initSwipeEventListeners(e.currentTarget as HTMLElement);
|
||||
});
|
||||
}
|
||||
|
||||
initSwipeEventListeners(swipeBar: HTMLElement) {
|
||||
const swipeFrame = swipeBar.parentNode as HTMLElement;
|
||||
const width = swipeFrame.clientWidth;
|
||||
const onSwipeMouseMove = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = swipeFrame.getBoundingClientRect();
|
||||
const value = Math.max(0, Math.min(e.clientX - rect.left, width));
|
||||
swipeBar.style.left = `${value}px`;
|
||||
this.containerEl.querySelector<HTMLElement>('.swipe-container')!.style.width = `${swipeFrame.clientWidth - value}px`;
|
||||
};
|
||||
const removeEventListeners = () => {
|
||||
document.removeEventListener('mousemove', onSwipeMouseMove);
|
||||
document.removeEventListener('mouseup', removeEventListeners);
|
||||
};
|
||||
document.addEventListener('mousemove', onSwipeMouseMove);
|
||||
document.addEventListener('mouseup', removeEventListeners);
|
||||
}
|
||||
|
||||
initOverlay(ctx: ImageContext) {
|
||||
let factor = 1;
|
||||
if (ctx.maxSize.width > this.diffContainerWidth - 12) {
|
||||
factor = (this.diffContainerWidth - 12) / ctx.maxSize.width;
|
||||
}
|
||||
|
||||
if (ctx.imageAfter) {
|
||||
const container = ctx.imageAfter.parentNode as HTMLElement;
|
||||
ctx.imageAfter.style.width = `${ctx.sizeAfter.width * factor}px`;
|
||||
ctx.imageAfter.style.height = `${ctx.sizeAfter.height * factor}px`;
|
||||
container.style.margin = `${ctx.ratio[1] * factor}px ${ctx.ratio[0] * factor}px`;
|
||||
container.style.width = `${ctx.sizeAfter.width * factor + 2}px`;
|
||||
container.style.height = `${ctx.sizeAfter.height * factor + 2}px`;
|
||||
}
|
||||
|
||||
if (ctx.imageBefore) {
|
||||
const container = ctx.imageBefore.parentNode as HTMLElement;
|
||||
const overlayFrame = container.parentNode as HTMLElement;
|
||||
ctx.imageBefore.style.width = `${ctx.sizeBefore.width * factor}px`;
|
||||
ctx.imageBefore.style.height = `${ctx.sizeBefore.height * factor}px`;
|
||||
container.style.margin = `${ctx.ratio[3] * factor}px ${ctx.ratio[2] * factor}px`;
|
||||
container.style.width = `${ctx.sizeBefore.width * factor + 2}px`;
|
||||
container.style.height = `${ctx.sizeBefore.height * factor + 2}px`;
|
||||
|
||||
// some inner elements are `position: absolute`, so the container's height must be large enough
|
||||
overlayFrame.style.width = `${ctx.maxSize.width * factor + 2}px`;
|
||||
overlayFrame.style.height = `${ctx.maxSize.height * factor + 2}px`;
|
||||
}
|
||||
|
||||
const rangeInput = this.containerEl.querySelector<HTMLInputElement>('input[type="range"]')!;
|
||||
|
||||
function updateOpacity() {
|
||||
if (ctx.imageAfter) {
|
||||
(ctx.imageAfter.parentNode as HTMLElement).style.opacity = `${Number(rangeInput.value) / 100}`;
|
||||
}
|
||||
}
|
||||
|
||||
rangeInput?.addEventListener('input', updateOpacity);
|
||||
updateOpacity();
|
||||
}
|
||||
}
|
||||
|
||||
export function initImageDiff() {
|
||||
for (const el of queryElems<HTMLImageElement>(document, '.image-diff:not([data-image-diff-loaded])')) {
|
||||
(new ImageDiff()).init(el); // it is async, but we don't need to await for it
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {hideElem, showElem} from '../utils/dom.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
|
||||
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 = 'gitea';
|
||||
const defaultDbName = 'gitea';
|
||||
|
||||
const defaultDbHosts: Record<string, string> = {
|
||||
mysql: '127.0.0.1:3306',
|
||||
postgres: '127.0.0.1:5432',
|
||||
mssql: '127.0.0.1:1433',
|
||||
};
|
||||
|
||||
const dbHost = document.querySelector<HTMLInputElement>('#db_host')!;
|
||||
const dbUser = document.querySelector<HTMLInputElement>('#db_user')!;
|
||||
const dbName = document.querySelector<HTMLInputElement>('#db_name')!;
|
||||
|
||||
// Database type change detection.
|
||||
document.querySelector<HTMLInputElement>('#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.querySelector('#db_type')!.dispatchEvent(new Event('change'));
|
||||
|
||||
const appUrl = document.querySelector<HTMLInputElement>('#app_url')!;
|
||||
if (appUrl.value.includes('://localhost')) {
|
||||
appUrl.value = window.location.href;
|
||||
}
|
||||
|
||||
const domain = document.querySelector<HTMLInputElement>('#domain')!;
|
||||
if (domain.value.trim() === 'localhost') {
|
||||
domain.value = window.location.hostname;
|
||||
}
|
||||
|
||||
// TODO: better handling of exclusive relations.
|
||||
document.querySelector<HTMLInputElement>('#enable-openid-signin input')!.addEventListener('change', function () {
|
||||
if (this.checked) {
|
||||
if (!document.querySelector<HTMLInputElement>('#disable-registration input')!.checked) {
|
||||
document.querySelector<HTMLInputElement>('#enable-openid-signup input')!.checked = true;
|
||||
}
|
||||
} else {
|
||||
document.querySelector<HTMLInputElement>('#enable-openid-signup input')!.checked = false;
|
||||
}
|
||||
});
|
||||
document.querySelector<HTMLInputElement>('#disable-registration input')!.addEventListener('change', function () {
|
||||
if (this.checked) {
|
||||
document.querySelector<HTMLInputElement>('#enable-captcha input')!.checked = false;
|
||||
document.querySelector<HTMLInputElement>('#enable-openid-signup input')!.checked = false;
|
||||
} else {
|
||||
document.querySelector<HTMLInputElement>('#enable-openid-signup input')!.checked = true;
|
||||
}
|
||||
});
|
||||
document.querySelector<HTMLInputElement>('#enable-captcha input')!.addEventListener('change', function () {
|
||||
if (this.checked) {
|
||||
document.querySelector<HTMLInputElement>('#disable-registration input')!.checked = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initPostInstall() {
|
||||
const el = document.querySelector('#goto-after-install');
|
||||
if (!el) return;
|
||||
|
||||
const targetUrl = el.getAttribute('href')!;
|
||||
let tid: ReturnType<typeof setInterval> | null = setInterval(async () => {
|
||||
try {
|
||||
const resp = await GET(targetUrl);
|
||||
if (tid && resp.status === 200) {
|
||||
clearInterval(tid);
|
||||
tid = null;
|
||||
window.location.href = targetUrl;
|
||||
}
|
||||
} catch {}
|
||||
}, 1000);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type {Issue} from '../types.ts';
|
||||
|
||||
// the getIssueIcon/getIssueColorClass logic should be kept the same as "templates/shared/issueicon.tmpl"
|
||||
|
||||
export function getIssueIcon(issue: Issue) {
|
||||
if (issue.pull_request) {
|
||||
if (issue.state === 'open') {
|
||||
if (issue.pull_request.draft) {
|
||||
return 'octicon-git-pull-request-draft'; // WIP PR
|
||||
}
|
||||
return 'octicon-git-pull-request'; // Open PR
|
||||
} else if (issue.pull_request.merged) {
|
||||
return 'octicon-git-merge'; // Merged PR
|
||||
}
|
||||
return 'octicon-git-pull-request-closed'; // Closed PR
|
||||
}
|
||||
|
||||
if (issue.state === 'open') {
|
||||
return 'octicon-issue-opened'; // Open Issue
|
||||
}
|
||||
return 'octicon-issue-closed'; // Closed Issue
|
||||
}
|
||||
|
||||
export function getIssueColorClass(issue: Issue) {
|
||||
if (issue.pull_request) {
|
||||
if (issue.state === 'open') {
|
||||
if (issue.pull_request.draft) {
|
||||
return 'tw-text-text-light'; // WIP PR
|
||||
}
|
||||
return 'tw-text-green'; // Open PR
|
||||
} else if (issue.pull_request.merged) {
|
||||
return 'tw-text-purple'; // Merged PR
|
||||
}
|
||||
return 'tw-text-red'; // Closed PR
|
||||
}
|
||||
|
||||
if (issue.state === 'open') {
|
||||
return 'tw-text-green'; // Open Issue
|
||||
}
|
||||
return 'tw-text-red'; // Closed Issue
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {toggleElem, createElementFromHTML} from '../utils/dom.ts';
|
||||
import {UserEventsSharedWorker} from '../modules/worker.ts';
|
||||
|
||||
const {appSubUrl, notificationSettings} = window.config;
|
||||
let notificationSequenceNumber = 0;
|
||||
|
||||
async function receiveUpdateCount(event: MessageEvent<{type: string, data: string}>) {
|
||||
try {
|
||||
const data = JSON.parse(event.data.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() {
|
||||
if (!document.querySelector('.notification_count')) return;
|
||||
|
||||
let usingPeriodicPoller = false;
|
||||
const startPeriodicPoller = (timeout: number, lastCount?: number) => {
|
||||
if (timeout <= 0 || !Number.isFinite(timeout)) return;
|
||||
usingPeriodicPoller = true;
|
||||
lastCount = lastCount ?? getCurrentCount();
|
||||
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 UserEventsSharedWorker('notification-worker');
|
||||
worker.addMessageEventListener((event: MessageEvent) => {
|
||||
if (event.data.type === 'no-event-source') {
|
||||
if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout);
|
||||
} else if (event.data.type === 'notification-count') {
|
||||
receiveUpdateCount(event); // no await
|
||||
}
|
||||
});
|
||||
worker.startPort();
|
||||
return;
|
||||
}
|
||||
|
||||
startPeriodicPoller(notificationSettings.MinTimeout);
|
||||
}
|
||||
|
||||
function getCurrentCount() {
|
||||
return Number(document.querySelector('.notification_count')!.textContent ?? '0');
|
||||
}
|
||||
|
||||
async function updateNotificationCountWithCallback(callback: (timeout: number, newCount: number) => void, timeout: number, lastCount: number) {
|
||||
const currentCount = getCurrentCount();
|
||||
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.querySelector('#notification_div');
|
||||
if (!notificationDiv) return;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('div-only', 'true');
|
||||
params.set('sequence-number', String(++notificationSequenceNumber));
|
||||
const response = await GET(`${appSubUrl}/notifications?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch notification table');
|
||||
}
|
||||
|
||||
const data = await response.text();
|
||||
const el = createElementFromHTML(data);
|
||||
if (parseInt(el.getAttribute('data-sequence-number')!) === notificationSequenceNumber) {
|
||||
notificationDiv.outerHTML = data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateNotificationCount(): Promise<number> {
|
||||
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.querySelectorAll('.notification_count')) {
|
||||
el.textContent = `${data.new}`;
|
||||
}
|
||||
|
||||
return data.new as number;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function initOAuth2SettingsDisableCheckbox() {
|
||||
for (const el of document.querySelectorAll<HTMLInputElement>('.disable-setting')) {
|
||||
el.addEventListener('change', (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const dataTarget = target.getAttribute('data-target')!;
|
||||
document.querySelector(dataTarget)!.classList.toggle('disabled', target.checked);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {queryElems, toggleElem} from '../utils/dom.ts';
|
||||
|
||||
function initOrgTeamSettings() {
|
||||
// on the page "page-content organization new team"
|
||||
const pageContent = document.querySelector('.page-content.organization.new.team');
|
||||
if (!pageContent) return;
|
||||
queryElems(pageContent, 'input[name=permission]', (el) => el.addEventListener('change', () => {
|
||||
// Change team access mode
|
||||
const val = pageContent.querySelector<HTMLInputElement>('input[name=permission]:checked')?.value;
|
||||
toggleElem(pageContent.querySelectorAll('.team-units'), val !== 'admin');
|
||||
}));
|
||||
}
|
||||
|
||||
export function initOrgTeam() {
|
||||
if (!document.querySelector('.page-content.organization')) return;
|
||||
initOrgTeamSettings();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {diffTreeStore, diffTreeStoreSetViewed} from '../modules/diff-file.ts';
|
||||
import {setFileFolding} from './file-fold.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
|
||||
const {pageData} = window.config;
|
||||
// it is undefined on most pages, fortunately, when it is accessed by the related functions, it exists
|
||||
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
|
||||
// The data used will be window.config.pageData.prReview.numberOf{Viewed}Files
|
||||
function refreshViewedFilesSummary() {
|
||||
const viewedFilesProgress = document.querySelector('#viewed-files-summary')!;
|
||||
viewedFilesProgress.setAttribute('value', String(prReview.numberOfViewedFiles));
|
||||
const summaryLabel = document.querySelector<HTMLElement>('#viewed-files-summary-label')!;
|
||||
summaryLabel.textContent = summaryLabel.getAttribute('data-text-changed-template')!
|
||||
.replace('%[1]d', String(prReview.numberOfViewedFiles))
|
||||
.replace('%[2]d', String(prReview.numberOfFiles));
|
||||
}
|
||||
|
||||
// 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', String(true));
|
||||
|
||||
// The checkbox consists of a div containing the real checkbox with its label,
|
||||
// hence the actual checkbox first has to be found
|
||||
const checkbox = form.querySelector<HTMLInputElement>('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
|
||||
diffTreeStoreSetViewed(diffTreeStore(), fileName, this.checked);
|
||||
|
||||
// Unfortunately, actual forms cause too many problems, hence another approach is needed
|
||||
const files: Record<string, boolean> = {};
|
||||
files[fileName] = this.checked;
|
||||
const data: Record<string, any> = {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<HTMLElement>('.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<HTMLElement>('.file-content:not([data-folded="true"])')) {
|
||||
if (box.getAttribute('id') === 'diff-incomplete') continue;
|
||||
setFileFolding(box, box.querySelector('.fold-file')!, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {createApp} from 'vue';
|
||||
|
||||
export async function initRepoRecentCommits() {
|
||||
const el = document.querySelector('#repo-recent-commits-chart');
|
||||
if (!el) return;
|
||||
|
||||
const {default: RepoRecentCommits} = await import('../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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {parseIssueHref} from '../utils.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {createApp} from 'vue';
|
||||
import {createTippy, getAttachedTippyInstance} from '../modules/tippy.ts';
|
||||
import {addDelegatedEventListener} from '../utils/dom.ts';
|
||||
import type {Issue} from '../types.ts';
|
||||
|
||||
type IssueInfo = {
|
||||
convertedIssue: Issue,
|
||||
renderedLabels: string,
|
||||
};
|
||||
|
||||
const issueInfoCache = new Map<string, IssueInfo>();
|
||||
|
||||
async function getIssueInfo(url: string): Promise<IssueInfo> {
|
||||
if (issueInfoCache.has(url)) return issueInfoCache.get(url)!;
|
||||
const resp = await GET(url);
|
||||
if (!resp.ok) throw new Error(resp.statusText || 'Unknown network error');
|
||||
const data = await resp.json();
|
||||
issueInfoCache.set(url, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function showRefIssuePopup(link: HTMLAnchorElement) {
|
||||
const [data, {default: ContextPopup}] = await Promise.all([
|
||||
getIssueInfo(`${link.pathname}/info`),
|
||||
import('../components/ContextPopup.vue'),
|
||||
]);
|
||||
const el = document.createElement('div');
|
||||
const app = createApp(ContextPopup, {
|
||||
issue: data.convertedIssue,
|
||||
renderedLabels: data.renderedLabels,
|
||||
});
|
||||
app.mount(el);
|
||||
// suppress ancestor title like from .commit-summary to prevent double tooltip
|
||||
link.title = '';
|
||||
createTippy(link, {
|
||||
theme: 'default',
|
||||
content: el,
|
||||
trigger: 'mouseenter focus',
|
||||
placement: 'top-start',
|
||||
interactive: true,
|
||||
role: 'dialog',
|
||||
interactiveBorder: 5,
|
||||
onDestroy: () => app.unmount(),
|
||||
}).show();
|
||||
}
|
||||
|
||||
export function initRefIssueContextPopup() {
|
||||
const selector = 'a[href]:not([data-ref-issue-popup]):not(.ref-external-issue)';
|
||||
addDelegatedEventListener<HTMLAnchorElement, MouseEvent>(document, 'mouseover', selector, (link) => {
|
||||
if (!parseIssueHref(link.getAttribute('href')!).ownerName) return;
|
||||
if (!link.classList.contains('ref-issue') && !link.closest('[data-ref-issue-container]')) return;
|
||||
if (getAttachedTippyInstance(link)) return;
|
||||
link.setAttribute('data-ref-issue-popup', '');
|
||||
|
||||
// delay so a mouse passing over the link doesn't fire a fetch
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const cancel = () => {
|
||||
clearTimeout(timer);
|
||||
link.removeAttribute('data-ref-issue-popup');
|
||||
link.removeEventListener('mouseleave', cancel);
|
||||
};
|
||||
timer = setTimeout(async () => {
|
||||
link.removeEventListener('mouseleave', cancel);
|
||||
try {
|
||||
await showRefIssuePopup(link);
|
||||
} catch (err) {
|
||||
console.error('Failed to load issue info:', err);
|
||||
link.removeAttribute('data-ref-issue-popup');
|
||||
}
|
||||
}, 300);
|
||||
link.addEventListener('mouseleave', cancel);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {createApp} from 'vue';
|
||||
import RepoActionView from '../components/RepoActionView.vue';
|
||||
|
||||
export function initRepositoryActionView() {
|
||||
const el = document.querySelector('#repo-action-view');
|
||||
if (!el) return;
|
||||
|
||||
// TODO: the parent element's full height doesn't work well now,
|
||||
// but we can not pollute the global style at the moment, only fix the height problem for pages with this component
|
||||
const parentFullHeight = document.querySelector<HTMLElement>('body > div.full.height');
|
||||
if (parentFullHeight) parentFullHeight.classList.add('tw-pb-0');
|
||||
|
||||
const view = createApp(RepoActionView, {
|
||||
jobId: parseInt(el.getAttribute('data-job-id')!),
|
||||
actionsViewUrl: el.getAttribute('data-actions-view-url'),
|
||||
locale: {
|
||||
approve: el.getAttribute('data-locale-approve'),
|
||||
cancel: el.getAttribute('data-locale-cancel'),
|
||||
rerun: el.getAttribute('data-locale-rerun'),
|
||||
rerun_all: el.getAttribute('data-locale-rerun-all'),
|
||||
rerun_failed: el.getAttribute('data-locale-rerun-failed'),
|
||||
latest: el.getAttribute('data-locale-latest'),
|
||||
latestAttempt: el.getAttribute('data-locale-latest-attempt'),
|
||||
attempt: el.getAttribute('data-locale-attempt'),
|
||||
scheduled: el.getAttribute('data-locale-runs-scheduled'),
|
||||
commit: el.getAttribute('data-locale-runs-commit'),
|
||||
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
|
||||
summary: el.getAttribute('data-locale-summary'),
|
||||
allJobs: el.getAttribute('data-locale-all-jobs'),
|
||||
expandCallerJobs: el.getAttribute('data-locale-expand-caller-jobs'),
|
||||
collapseCallerJobs: el.getAttribute('data-locale-collapse-caller-jobs'),
|
||||
triggeredVia: el.getAttribute('data-locale-triggered-via'),
|
||||
totalDuration: el.getAttribute('data-locale-total-duration'),
|
||||
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
|
||||
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
|
||||
artifactExpiresAt: el.getAttribute('data-locale-artifact-expires-at'),
|
||||
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
|
||||
showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
|
||||
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
|
||||
showFullScreen: el.getAttribute('data-locale-show-full-screen'),
|
||||
downloadLogs: el.getAttribute('data-locale-download-logs'),
|
||||
copyOutput: el.getAttribute('data-locale-copy-output'),
|
||||
status: {
|
||||
unknown: el.getAttribute('data-locale-status-unknown'),
|
||||
waiting: el.getAttribute('data-locale-status-waiting'),
|
||||
running: el.getAttribute('data-locale-status-running'),
|
||||
cancelling: el.getAttribute('data-locale-status-cancelling'),
|
||||
success: el.getAttribute('data-locale-status-success'),
|
||||
failure: el.getAttribute('data-locale-status-failure'),
|
||||
cancelled: el.getAttribute('data-locale-status-cancelled'),
|
||||
skipped: el.getAttribute('data-locale-status-skipped'),
|
||||
blocked: el.getAttribute('data-locale-status-blocked'),
|
||||
},
|
||||
logsAlwaysAutoScroll: el.getAttribute('data-locale-logs-always-auto-scroll'),
|
||||
logsAlwaysExpandRunning: el.getAttribute('data-locale-logs-always-expand-running'),
|
||||
workflowFile: el.getAttribute('data-locale-workflow-file'),
|
||||
runDetails: el.getAttribute('data-locale-run-details'),
|
||||
workflowDependencies: el.getAttribute('data-locale-workflow-dependencies'),
|
||||
graphJobsCount1: el.getAttribute('data-locale-graph-jobs-count-1'),
|
||||
graphJobsCountN: el.getAttribute('data-locale-graph-jobs-count-n'),
|
||||
graphDependenciesCount1: el.getAttribute('data-locale-graph-dependencies-count-1'),
|
||||
graphDependenciesCountN: el.getAttribute('data-locale-graph-dependencies-count-n'),
|
||||
graphSuccessRate: el.getAttribute('data-locale-graph-success-rate'),
|
||||
graphZoomIn: el.getAttribute('data-locale-graph-zoom-in'),
|
||||
graphZoomMax: el.getAttribute('data-locale-graph-zoom-max'),
|
||||
graphZoomOut: el.getAttribute('data-locale-graph-zoom-out'),
|
||||
graphResetView: el.getAttribute('data-locale-graph-reset-view'),
|
||||
},
|
||||
});
|
||||
view.mount(el);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {toggleElem} from '../utils/dom.ts';
|
||||
import {showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
|
||||
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<HTMLFormElement>(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');
|
||||
|
||||
showFomanticModal(document.querySelector(el.getAttribute('data-modal')!));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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<HTMLInputElement>('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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import {svg} from '../svg.ts';
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {toAbsoluteUrl} from '../utils.ts';
|
||||
import {addDelegatedEventListener} from '../utils/dom.ts';
|
||||
|
||||
function changeHash(hash: string) {
|
||||
if (window.history.pushState) {
|
||||
window.history.pushState(null, '', hash);
|
||||
} else {
|
||||
window.location.hash = hash;
|
||||
}
|
||||
}
|
||||
|
||||
// it selects the code lines defined by range: `L1-L3` (3 lines) or `L2` (singe line)
|
||||
function selectRange(range: string): Element | null {
|
||||
for (const el of document.querySelectorAll('.code-view tr.active')) el.classList.remove('active');
|
||||
const elLineNums = document.querySelectorAll(`.code-view td.lines-num span[data-line-number]`);
|
||||
|
||||
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: string) {
|
||||
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: string) {
|
||||
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: string) {
|
||||
if (!copyPermalink) return;
|
||||
let link = copyPermalink.getAttribute('data-url')!;
|
||||
link = `${window.location.origin}${link.replace(/#L\d+$|#L\d+-L\d+$/, '')}#${anchor}`;
|
||||
copyPermalink.setAttribute('data-clipboard-text', link);
|
||||
};
|
||||
|
||||
const rangeFields = range ? range.split('-') : [];
|
||||
const start = rangeFields[0] ?? '';
|
||||
if (!start) return null;
|
||||
const stop = rangeFields[1] || start;
|
||||
|
||||
// format is i.e. 'L14-L26'
|
||||
let startLineNum = parseInt(start.substring(1));
|
||||
let stopLineNum = parseInt(stop.substring(1));
|
||||
if (startLineNum > stopLineNum) {
|
||||
const tmp = startLineNum;
|
||||
startLineNum = stopLineNum;
|
||||
stopLineNum = tmp;
|
||||
range = `${stop}-${start}`;
|
||||
}
|
||||
|
||||
const first = elLineNums[startLineNum - 1] ?? null;
|
||||
for (let i = startLineNum - 1; i <= stopLineNum - 1 && i < elLineNums.length; i++) {
|
||||
elLineNums[i].closest('tr')!.classList.add('active');
|
||||
}
|
||||
changeHash(`#${range}`);
|
||||
updateIssueHref(range);
|
||||
updateViewGitBlameFragment(range);
|
||||
updateCopyPermalinkUrl(range);
|
||||
return first;
|
||||
}
|
||||
|
||||
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');
|
||||
if (!tr) return;
|
||||
|
||||
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, {
|
||||
theme: 'menu',
|
||||
trigger: 'click',
|
||||
hideOnClick: true,
|
||||
content: menu,
|
||||
placement: 'right-start',
|
||||
interactive: true,
|
||||
onShow: (tippy) => {
|
||||
tippy.popper.addEventListener('click', () => {
|
||||
tippy.hide();
|
||||
}, {once: true});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function initRepoCodeView() {
|
||||
// When viewing a file or blame, there is always a ".file-view" element,
|
||||
// but the ".code-view" class is only present when viewing the "code" of a file; it is not present when viewing a PDF file.
|
||||
// Since the ".file-view" will be dynamically reloaded when navigating via the left file tree (eg: view a PDF file, then view a source code file, etc.)
|
||||
// the "code-view" related event listeners should always be added when the current page contains ".file-view" element.
|
||||
if (!document.querySelector('.repo-view-container .file-view')) return;
|
||||
|
||||
// "file code view" and "blame" pages need this "line number button" feature
|
||||
let selRangeStart: string | undefined;
|
||||
addDelegatedEventListener(document, 'click', '.code-view .lines-num span', (el: HTMLElement, e: KeyboardEvent) => {
|
||||
if (!selRangeStart || !e.shiftKey) {
|
||||
selRangeStart = el.getAttribute('id')!;
|
||||
selectRange(selRangeStart);
|
||||
} else {
|
||||
const selRangeStop = el.getAttribute('id');
|
||||
selectRange(`${selRangeStart}-${selRangeStop}`);
|
||||
}
|
||||
window.getSelection()!.removeAllRanges();
|
||||
showLineButton();
|
||||
});
|
||||
|
||||
// apply the selected range from the URL hash
|
||||
const onHashChange = () => {
|
||||
if (!window.location.hash) return;
|
||||
if (!document.querySelector('.code-view .lines-num')) return;
|
||||
const range = window.location.hash.substring(1);
|
||||
const first = selectRange(range);
|
||||
if (first) {
|
||||
// set scrollRestoration to 'manual' when there is a hash in the URL, so that the scroll position will not be remembered after refreshing
|
||||
if (window.history.scrollRestoration !== 'manual') window.history.scrollRestoration = 'manual';
|
||||
first.scrollIntoView({block: 'start'});
|
||||
showLineButton();
|
||||
}
|
||||
};
|
||||
onHashChange();
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {toggleElem} from '../utils/dom.ts';
|
||||
import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
|
||||
export function initRepoEllipsisButton() {
|
||||
registerGlobalEventFunc('click', 'onRepoEllipsisButtonClick', async (el: HTMLInputElement, e: Event) => {
|
||||
e.preventDefault();
|
||||
const expanded = el.getAttribute('aria-expanded') === 'true';
|
||||
toggleElem(el.parentElement!.querySelector('.commit-body')!);
|
||||
el.setAttribute('aria-expanded', String(!expanded));
|
||||
});
|
||||
}
|
||||
|
||||
export function initCommitStatuses() {
|
||||
registerGlobalInitFunc('initCommitStatuses', (el: HTMLElement) => {
|
||||
const nextEl = el.nextElementSibling!;
|
||||
if (!nextEl.matches('.tippy-target')) throw new Error('Expected next element to be a tippy target');
|
||||
createTippy(el, {
|
||||
content: nextEl,
|
||||
placement: 'bottom-start',
|
||||
interactive: true,
|
||||
role: 'dialog',
|
||||
theme: 'box-with-header',
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {sanitizeRepoName, substituteRepoOpenWithUrl} from './repo-common.ts';
|
||||
|
||||
test('substituteRepoOpenWithUrl', () => {
|
||||
// For example: "x-github-client://openRepo/https://github.com/go-gitea/gitea"
|
||||
expect(substituteRepoOpenWithUrl('proto://a/{url}', 'https://gitea')).toEqual('proto://a/https://gitea');
|
||||
expect(substituteRepoOpenWithUrl('proto://a?link={url}', 'https://gitea')).toEqual('proto://a?link=https%3A%2F%2Fgitea');
|
||||
});
|
||||
|
||||
test('sanitizeRepoName', () => {
|
||||
expect(sanitizeRepoName(' a b ')).toEqual('a-b');
|
||||
expect(sanitizeRepoName('a-b_c.git ')).toEqual('a-b_c');
|
||||
expect(sanitizeRepoName('/x.git/')).toEqual('-x.git-');
|
||||
expect(sanitizeRepoName('.profile')).toEqual('.profile');
|
||||
expect(sanitizeRepoName('.profile.')).toEqual('.profile');
|
||||
expect(sanitizeRepoName('.pro..file')).toEqual('.pro.file');
|
||||
|
||||
expect(sanitizeRepoName('foo.rss.atom.git.wiki')).toEqual('foo');
|
||||
|
||||
expect(sanitizeRepoName('.')).toEqual('');
|
||||
expect(sanitizeRepoName('..')).toEqual('');
|
||||
expect(sanitizeRepoName('-')).toEqual('');
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import {queryElems} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
import RepoActivityTopAuthors from '../components/RepoActivityTopAuthors.vue';
|
||||
import {createApp} from 'vue';
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {localUserSettings} from '../modules/user-settings.ts';
|
||||
|
||||
async function onDownloadArchive(e: Event) {
|
||||
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 as HTMLElement).closest<HTMLAnchorElement>('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: ${errorMessage(e)}`, {duration: 2500});
|
||||
} finally {
|
||||
targetLoading.classList.remove('is-loading', 'loading-icon-2px');
|
||||
}
|
||||
}
|
||||
|
||||
export function initRepoArchiveLinks() {
|
||||
queryElems(document, 'a.archive-link[href]', (el) => el.addEventListener('click', onDownloadArchive));
|
||||
}
|
||||
|
||||
export function initRepoActivityTopAuthorsChart() {
|
||||
const el = document.querySelector('#repo-activity-top-authors-chart');
|
||||
if (el) {
|
||||
createApp(RepoActivityTopAuthors).mount(el);
|
||||
}
|
||||
}
|
||||
|
||||
export function substituteRepoOpenWithUrl(tmpl: string, url: string): string {
|
||||
const pos = tmpl.indexOf('{url}');
|
||||
if (pos === -1) return tmpl;
|
||||
const posQuestionMark = tmpl.indexOf('?');
|
||||
const needEncode = posQuestionMark >= 0 && posQuestionMark < pos;
|
||||
return tmpl.replace('{url}', needEncode ? encodeURIComponent(url) : url);
|
||||
}
|
||||
|
||||
function initCloneSchemeUrlSelection(parent: Element) {
|
||||
const elCloneUrlInput = parent.querySelector<HTMLInputElement>('.repo-clone-url')!;
|
||||
|
||||
const tabHttps = parent.querySelector('.repo-clone-https');
|
||||
const tabSsh = parent.querySelector('.repo-clone-ssh');
|
||||
const tabTea = parent.querySelector('.repo-clone-tea');
|
||||
const updateClonePanelUi = function() {
|
||||
let scheme = localUserSettings.getString('repo-clone-protocol');
|
||||
if (!['https', 'ssh', 'tea'].includes(scheme)) {
|
||||
scheme = 'https';
|
||||
}
|
||||
|
||||
// Fallbacks if the scheme preference is not available in the tabs, for example: empty repo page, there are only HTTPS and SSH
|
||||
if (scheme === 'tea' && !tabTea) {
|
||||
scheme = 'https';
|
||||
}
|
||||
if (scheme === 'https' && !tabHttps) {
|
||||
scheme = 'ssh';
|
||||
} else if (scheme === 'ssh' && !tabSsh) {
|
||||
scheme = 'https';
|
||||
}
|
||||
|
||||
const isHttps = scheme === 'https';
|
||||
const isSsh = scheme === 'ssh';
|
||||
const isTea = scheme === 'tea';
|
||||
|
||||
if (tabHttps) {
|
||||
const link = tabHttps.getAttribute('data-link')!;
|
||||
tabHttps.textContent = link.split(':')[0].toUpperCase(); // show "HTTP" or "HTTPS"
|
||||
tabHttps.classList.toggle('active', isHttps);
|
||||
}
|
||||
if (tabSsh) {
|
||||
tabSsh.classList.toggle('active', isSsh);
|
||||
}
|
||||
if (tabTea) {
|
||||
tabTea.classList.toggle('active', isTea);
|
||||
}
|
||||
|
||||
let tab: Element | null = null;
|
||||
if (isHttps) {
|
||||
tab = tabHttps;
|
||||
} else if (isSsh) {
|
||||
tab = tabSsh;
|
||||
} else if (isTea) {
|
||||
tab = tabTea;
|
||||
}
|
||||
|
||||
if (!tab) return;
|
||||
const link = tab.getAttribute('data-link')!;
|
||||
|
||||
for (const el of document.querySelectorAll('.js-clone-url')) {
|
||||
if (el.nodeName === 'INPUT') {
|
||||
(el as HTMLInputElement).value = link;
|
||||
} else {
|
||||
el.textContent = link;
|
||||
}
|
||||
}
|
||||
for (const el of parent.querySelectorAll<HTMLAnchorElement>('.js-clone-url-editor')) {
|
||||
el.href = substituteRepoOpenWithUrl(el.getAttribute('data-href-template')!, link);
|
||||
}
|
||||
};
|
||||
|
||||
updateClonePanelUi();
|
||||
// tabSsh or tabHttps might not both exist, eg: guest view, or one is disabled by the server
|
||||
tabHttps?.addEventListener('click', () => {
|
||||
localUserSettings.setString('repo-clone-protocol', 'https');
|
||||
updateClonePanelUi();
|
||||
});
|
||||
tabSsh?.addEventListener('click', () => {
|
||||
localUserSettings.setString('repo-clone-protocol', 'ssh');
|
||||
updateClonePanelUi();
|
||||
});
|
||||
tabTea?.addEventListener('click', () => {
|
||||
localUserSettings.setString('repo-clone-protocol', 'tea');
|
||||
updateClonePanelUi();
|
||||
});
|
||||
elCloneUrlInput.addEventListener('focus', () => {
|
||||
elCloneUrlInput.select();
|
||||
});
|
||||
}
|
||||
|
||||
function initClonePanelButton(btn: HTMLButtonElement) {
|
||||
const elPanel = btn.nextElementSibling!;
|
||||
// "init" must be before the "createTippy" otherwise the "tippy-target" will be removed from the document
|
||||
initCloneSchemeUrlSelection(elPanel);
|
||||
createTippy(btn, {
|
||||
content: elPanel,
|
||||
trigger: 'click',
|
||||
placement: 'bottom-end',
|
||||
interactive: true,
|
||||
hideOnClick: true,
|
||||
arrow: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function initRepoCloneButtons() {
|
||||
queryElems(document, '.js-btn-clone-panel', initClonePanelButton);
|
||||
queryElems(document, '.clone-buttons-combo', initCloneSchemeUrlSelection);
|
||||
}
|
||||
|
||||
export async function updateIssuesMeta(url: string, action: string, issue_ids: string, id: string) {
|
||||
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 sanitizeRepoName(name: string): string {
|
||||
name = name.trim().replace(/[^-.\w]/g, '-');
|
||||
for (let lastName = ''; lastName !== name;) {
|
||||
lastName = name;
|
||||
name = name.replace(/\.+$/g, '');
|
||||
name = name.replace(/\.{2,}/g, '.');
|
||||
for (const ext of ['.git', '.wiki', '.rss', '.atom']) {
|
||||
if (name.endsWith(ext)) {
|
||||
name = name.substring(0, name.length - ext.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (['.', '..', '-'].includes(name)) name = '';
|
||||
return name;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {hideElem, showElem, toggleElem} from '../utils/dom.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
|
||||
async function loadBranchesAndTags(area: Element, loadingButton: Element) {
|
||||
loadingButton.classList.add('disabled');
|
||||
try {
|
||||
const res = await GET(loadingButton.getAttribute('data-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: Element, tags: Array<Record<string, any>>) {
|
||||
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: Element, branches: Array<Record<string, any>>, defaultBranch: string) {
|
||||
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: Element, href: string, text: string, tooltip: string | null = null) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {createApp} from 'vue';
|
||||
import DiffCommitSelector from '../components/DiffCommitSelector.vue';
|
||||
|
||||
export function initDiffCommitSelect() {
|
||||
const el = document.querySelector('#diff-commit-select');
|
||||
if (!el) return;
|
||||
|
||||
const commitSelect = createApp(DiffCommitSelector);
|
||||
commitSelect.mount(el);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {createApp} from 'vue';
|
||||
import DiffFileTree from '../components/DiffFileTree.vue';
|
||||
|
||||
export function initDiffFileTree() {
|
||||
const el = document.querySelector('#diff-file-tree');
|
||||
if (!el) return;
|
||||
|
||||
const fileTreeView = createApp(DiffFileTree);
|
||||
fileTreeView.mount(el);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import {initRepoIssueContentHistory} from './repo-issue-content.ts';
|
||||
import {initDiffFileTree} from './repo-diff-filetree.ts';
|
||||
import {initDiffCommitSelect} from './repo-diff-commitselect.ts';
|
||||
import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.ts';
|
||||
import {initViewedCheckboxListenerFor, initExpandAndCollapseFilesButton} from './pull-view-file.ts';
|
||||
import {initImageDiff} from './imagediff.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {queryElemSiblings, hideElem, showElem, animateOnce, addDelegatedEventListener, createElementFromHTML, queryElems} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {POST, GET} from '../modules/fetch.ts';
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {invertFileFolding} from './file-fold.ts';
|
||||
import {parseDom} from '../utils.ts';
|
||||
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||
import {performFetchActionTrigger} from './common-fetch-action.ts';
|
||||
|
||||
function initRepoDiffFileBox(el: HTMLElement) {
|
||||
// switch between "rendered" and "source", for image and CSV files
|
||||
queryElems(el, '.file-view-toggle', (btn) => btn.addEventListener('click', () => {
|
||||
queryElemSiblings(btn, '.file-view-toggle', (el) => el.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
const target = document.querySelector(btn.getAttribute('data-toggle-selector')!);
|
||||
if (!target) throw new Error('Target element not found');
|
||||
|
||||
hideElem(queryElemSiblings(target));
|
||||
showElem(target);
|
||||
}));
|
||||
}
|
||||
|
||||
function initRepoDiffConversationForm() {
|
||||
// FIXME: there could be various different form in a conversation-holder (for example: reply form, edit form).
|
||||
// This listener is for "reply form" only, it should clearly distinguish different forms in the future.
|
||||
addDelegatedEventListener<HTMLFormElement, SubmitEvent>(document, 'submit', '.conversation-holder form', async (form, e) => {
|
||||
e.preventDefault();
|
||||
const textArea = form.querySelector<HTMLTextAreaElement>('textarea')!;
|
||||
if (!validateTextareaNonEmpty(textArea)) return;
|
||||
if (form.classList.contains('is-loading')) return;
|
||||
|
||||
try {
|
||||
form.classList.add('is-loading');
|
||||
const formData = new FormData(form);
|
||||
|
||||
// if the form is submitted by a button, append the button's name and value to the form data
|
||||
const submitter = e.submitter;
|
||||
const isSubmittedByButton = submitter instanceof HTMLButtonElement || (submitter instanceof HTMLInputElement && submitter.type === 'submit');
|
||||
if (isSubmittedByButton && submitter.name) {
|
||||
formData.append(submitter.name, submitter.value);
|
||||
}
|
||||
|
||||
// on the diff page, the form is inside a "tr" and need to get the line-type ahead
|
||||
// but on the conversation page, there is no parent "tr"
|
||||
const trLineType = form.closest('tr')?.getAttribute('data-line-type');
|
||||
const response = await POST(form.getAttribute('action')!, {data: formData});
|
||||
const newConversationHolder = createElementFromHTML(await response.text());
|
||||
const path = newConversationHolder.getAttribute('data-path');
|
||||
const side = newConversationHolder.getAttribute('data-side');
|
||||
const idx = newConversationHolder.getAttribute('data-idx');
|
||||
|
||||
form.closest('.conversation-holder')!.replaceWith(newConversationHolder);
|
||||
(form as any) = null; // prevent further usage of the form because it should have been replaced
|
||||
|
||||
if (trLineType) {
|
||||
// if there is a line-type for the "tr", it means the form is on the diff page
|
||||
// then hide the "add-code-comment" [+] button for current code line by adding "tw-invisible" because the conversation has been added
|
||||
let selector;
|
||||
if (trLineType === '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');
|
||||
}
|
||||
}
|
||||
|
||||
// the default behavior is to add a pending review, so if no submitter, it also means "pending_review"
|
||||
if (!submitter || submitter?.matches('button[name="pending_review"]')) {
|
||||
const reviewBox = document.querySelector('#review-box')!;
|
||||
const counter = reviewBox?.querySelector('.review-comments-counter');
|
||||
if (!counter) return;
|
||||
const num = parseInt(counter.getAttribute('data-pending-comment-number')!) + 1 || 1;
|
||||
counter.setAttribute('data-pending-comment-number', String(num));
|
||||
counter.textContent = String(num);
|
||||
animateOnce(reviewBox, 'pulse-1p5-200');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showErrorToast(`Submit form failed: ${errorMessage(error)}`);
|
||||
} finally {
|
||||
form?.classList.remove('is-loading');
|
||||
}
|
||||
});
|
||||
|
||||
addDelegatedEventListener(document, 'click', '.resolve-conversation', async (el, e) => {
|
||||
e.preventDefault();
|
||||
const comment_id = el.getAttribute('data-comment-id')!;
|
||||
const origin = el.getAttribute('data-origin')!;
|
||||
const action = el.getAttribute('data-action')!;
|
||||
const url = el.getAttribute('data-update-url')!;
|
||||
|
||||
try {
|
||||
const response = await POST(url, {data: new URLSearchParams({origin, action, comment_id})});
|
||||
const data = await response.text();
|
||||
|
||||
const elConversationHolder = el.closest('.conversation-holder');
|
||||
if (elConversationHolder) {
|
||||
const elNewConversation = createElementFromHTML(data);
|
||||
elConversationHolder.replaceWith(elNewConversation);
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initRepoDiffConversationNav() {
|
||||
// Previous/Next code review conversation
|
||||
addDelegatedEventListener(document, 'click', '.previous-conversation, .next-conversation', (el, e) => {
|
||||
e.preventDefault();
|
||||
const isPrevious = el.matches('.previous-conversation');
|
||||
const elCurConversation = el.closest('.comment-code-cloud')!;
|
||||
const elAllConversations = document.querySelectorAll('.comment-code-cloud:not(.tw-hidden)');
|
||||
const index = Array.from(elAllConversations).indexOf(elCurConversation);
|
||||
const previousIndex = index > 0 ? index - 1 : elAllConversations.length - 1;
|
||||
const nextIndex = index < elAllConversations.length - 1 ? index + 1 : 0;
|
||||
const navIndex = isPrevious ? previousIndex : nextIndex;
|
||||
const elNavConversation = elAllConversations[navIndex];
|
||||
const anchor = elNavConversation.querySelector('.comment')!.id;
|
||||
window.location.href = `#${anchor}`;
|
||||
});
|
||||
}
|
||||
|
||||
function initDiffHeaderPopup() {
|
||||
for (const btn of document.querySelectorAll('.diff-header-popup-btn:not([data-header-popup-initialized])')) {
|
||||
btn.setAttribute('data-header-popup-initialized', '');
|
||||
const popup = btn.nextElementSibling;
|
||||
if (!popup?.matches('.tippy-target')) throw new Error('Popup element not found');
|
||||
createTippy(btn, {
|
||||
content: popup,
|
||||
theme: 'menu',
|
||||
placement: 'bottom-end',
|
||||
trigger: 'click',
|
||||
interactive: true,
|
||||
hideOnClick: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Will be called when the show more (files) button has been pressed
|
||||
function onShowMoreFiles() {
|
||||
// TODO: replace these calls with the "observer.ts" methods
|
||||
initRepoIssueContentHistory();
|
||||
initViewedCheckboxListenerFor();
|
||||
initImageDiff();
|
||||
initDiffHeaderPopup();
|
||||
}
|
||||
|
||||
async function loadMoreFiles(btn: Element): Promise<boolean> {
|
||||
if (btn.classList.contains('disabled')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
btn.classList.add('disabled');
|
||||
const url = btn.getAttribute('data-href')!;
|
||||
try {
|
||||
const response = await GET(url);
|
||||
const resp = await response.text();
|
||||
const respDoc = parseDom(resp, 'text/html');
|
||||
const respFileBoxes = respDoc.querySelector('#diff-file-boxes')!;
|
||||
// the response is a full HTML page, we need to extract the relevant contents:
|
||||
// * append the newly loaded file list items to the existing list
|
||||
const respFileBoxesChildren = Array.from(respFileBoxes.children); // "children:HTMLCollection" will be empty after replaceWith
|
||||
document.querySelector('#diff-incomplete')!.replaceWith(...respFileBoxesChildren);
|
||||
onShowMoreFiles();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showErrorToast('An error occurred while loading more files.');
|
||||
} finally {
|
||||
btn.classList.remove('disabled');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function initRepoDiffShowMore() {
|
||||
addDelegatedEventListener(document, 'click', 'a#diff-show-more-files', (el, e) => {
|
||||
e.preventDefault();
|
||||
loadMoreFiles(el);
|
||||
});
|
||||
|
||||
addDelegatedEventListener(document, 'click', 'a.diff-load-button', async (el, e) => {
|
||||
e.preventDefault();
|
||||
if (el.classList.contains('disabled')) return;
|
||||
|
||||
el.classList.add('disabled');
|
||||
const url = el.getAttribute('data-href')!;
|
||||
|
||||
try {
|
||||
const response = await GET(url);
|
||||
const resp = await response.text();
|
||||
const respDoc = parseDom(resp, 'text/html');
|
||||
const respFileBody = respDoc.querySelector('#diff-file-boxes .diff-file-body .file-body')!;
|
||||
const respFileBodyChildren = Array.from(respFileBody.children); // "children:HTMLCollection" will be empty after replaceWith
|
||||
el.parentElement!.replaceWith(...respFileBodyChildren);
|
||||
// FIXME: calling onShowMoreFiles is not quite right here.
|
||||
// But since onShowMoreFiles mixes "init diff box" and "init diff body" together,
|
||||
// so it still needs to call it to make the "ImageDiff" and something similar work.
|
||||
onShowMoreFiles();
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
} finally {
|
||||
el.classList.remove('disabled');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onLocationHashChange() {
|
||||
// try to scroll to the target element by the current hash
|
||||
const currentHash = window.location.hash;
|
||||
if (!currentHash.startsWith('#diff-') && !currentHash.startsWith('#issuecomment-')) return;
|
||||
|
||||
// avoid reentrance when we are changing the hash to scroll and trigger ":target" selection
|
||||
const attrAutoScrollRunning = 'data-auto-scroll-running';
|
||||
if (document.body.hasAttribute(attrAutoScrollRunning)) return;
|
||||
|
||||
const targetElementId = currentHash.substring(1);
|
||||
while (currentHash === window.location.hash) {
|
||||
// use getElementById to avoid querySelector throws an error when the hash is invalid
|
||||
// eslint-disable-next-line unicorn/prefer-query-selector
|
||||
const targetElement = document.getElementById(targetElementId);
|
||||
if (targetElement) {
|
||||
// need to change hash to re-trigger ":target" CSS selector, let's manually scroll to it
|
||||
targetElement.scrollIntoView();
|
||||
document.body.setAttribute(attrAutoScrollRunning, 'true');
|
||||
window.location.hash = '';
|
||||
window.location.hash = currentHash;
|
||||
setTimeout(() => document.body.removeAttribute(attrAutoScrollRunning), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// If looking for a hidden comment, try to expand the section that contains it
|
||||
const issueCommentPrefix = '#issuecomment-';
|
||||
if (currentHash.startsWith(issueCommentPrefix)) {
|
||||
const commentId = currentHash.substring(issueCommentPrefix.length);
|
||||
const expandButton = document.querySelector<HTMLElement>(`.code-expander-button[data-hidden-comment-ids*=",${commentId},"]`);
|
||||
if (expandButton) {
|
||||
// avoid infinite loop, do not re-click the button if already clicked
|
||||
const attrAutoLoadClicked = 'data-auto-load-clicked';
|
||||
if (expandButton.hasAttribute(attrAutoLoadClicked)) return;
|
||||
expandButton.setAttribute(attrAutoLoadClicked, 'true');
|
||||
// trigger the fetch action to load the hidden comments, after loading, it will try to find the target element again
|
||||
await performFetchActionTrigger(expandButton, 'load');
|
||||
continue; // Try again to find the element
|
||||
}
|
||||
}
|
||||
|
||||
// the button will be refreshed after each "load more", so query it every time
|
||||
const showMoreButton = document.querySelector('#diff-show-more-files');
|
||||
if (!showMoreButton) {
|
||||
return; // nothing more to load
|
||||
}
|
||||
|
||||
// Load more files, await ensures we don't block progress
|
||||
const ok = await loadMoreFiles(showMoreButton);
|
||||
if (!ok) return; // failed to load more files
|
||||
}
|
||||
}
|
||||
|
||||
function initRepoDiffHashChangeListener() {
|
||||
window.addEventListener('hashchange', onLocationHashChange);
|
||||
onLocationHashChange();
|
||||
}
|
||||
|
||||
export function initRepoDiffView() {
|
||||
initRepoDiffConversationForm(); // such form appears on the "conversation" page and "diff" page
|
||||
|
||||
if (!document.querySelector('#diff-file-boxes')) return;
|
||||
initRepoDiffConversationNav(); // "previous" and "next" buttons only appear on "diff" page
|
||||
initDiffFileTree();
|
||||
initDiffCommitSelect();
|
||||
initRepoDiffShowMore();
|
||||
initDiffHeaderPopup();
|
||||
initViewedCheckboxListenerFor();
|
||||
initExpandAndCollapseFilesButton();
|
||||
initRepoDiffHashChangeListener();
|
||||
|
||||
registerGlobalSelectorFunc('#diff-file-boxes .diff-file-box', initRepoDiffFileBox);
|
||||
addDelegatedEventListener(document, 'click', '.fold-file', (el) => {
|
||||
invertFileFolding(el.closest('.file-content')!, el);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import {html, htmlRaw} from '../utils/html.ts';
|
||||
import {createCodeEditor} from '../modules/codeeditor/main.ts';
|
||||
import {trimTrailingWhitespaceFromView} from '../modules/codeeditor/utils.ts';
|
||||
import {hideElem, queryElems, showElem, createElementFromHTML, onInputDebounce} from '../utils/dom.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {initDropzone} from './dropzone.ts';
|
||||
import {confirmModal} from './comp/ConfirmModal.ts';
|
||||
import {applyAreYouSure, ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
||||
import {submitFormFetchAction} from './common-fetch-action.ts';
|
||||
import {dirname} from '../utils.ts';
|
||||
import {pathEscapeSegments} from '../utils/url.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
|
||||
function initEditPreviewTab(elForm: HTMLFormElement) {
|
||||
const elTabMenu = elForm.querySelector('.repo-editor-menu');
|
||||
if (!elTabMenu) return;
|
||||
|
||||
const elTreePath = elForm.querySelector<HTMLInputElement>('input#tree_path');
|
||||
const elTextarea = elForm.querySelector<HTMLTextAreaElement>('.tab[data-tab="write"] textarea');
|
||||
if (!elTreePath || !elTextarea) return;
|
||||
|
||||
const repoLink = elTabMenu.getAttribute('data-repo-link')!;
|
||||
const refSubUrl = elTabMenu.getAttribute('data-ref-sub-url')!;
|
||||
const branchName = elTabMenu.getAttribute('data-branch-name')!;
|
||||
|
||||
const elPreviewTab = elTabMenu.querySelector('a[data-tab="preview"]')!;
|
||||
const elPreviewPanel = elForm.querySelector('.tab[data-tab="preview"]')!;
|
||||
elPreviewTab.addEventListener('click', async () => {
|
||||
// "preview context" is the request path directory of the file, the rendered links will be resolved based on this path
|
||||
// TODO: MARKUP-RENDER-CONTEXT: due to various hacky patches, this logic is unnecessarily complicated, see the backend
|
||||
const previewContext = dirname(`${repoLink}/src/${refSubUrl}/${pathEscapeSegments(elTreePath.value)}`);
|
||||
const formData = new FormData();
|
||||
formData.append('mode', 'file');
|
||||
formData.append('context', previewContext);
|
||||
formData.append('text', elTextarea.value);
|
||||
formData.append('file_path', elTreePath.value);
|
||||
const resp = await POST(`${repoLink}/markup`, {data: formData});
|
||||
if (!resp.ok) {
|
||||
showErrorToast(`Failed to render preview: ${resp.status} ${resp.statusText}`);
|
||||
return;
|
||||
}
|
||||
const data = await resp.text();
|
||||
renderPreviewPanelContent(elPreviewPanel, data);
|
||||
});
|
||||
|
||||
const elDiffTab = elTabMenu.querySelector('a[data-tab="diff"]');
|
||||
const elDiffPanel = elForm.querySelector('.tab[data-tab="diff"]');
|
||||
if (elDiffTab && elDiffPanel) {
|
||||
// the "diff" tab only exists for an existing file, but not for a new file
|
||||
elDiffTab.addEventListener('click', async () => {
|
||||
const diffUrl = `${repoLink}/_preview/${pathEscapeSegments(branchName)}/${pathEscapeSegments(elTreePath.value)}`;
|
||||
// don't use FormData, because FormData sends "\r\n" line endings, backend assumes "\n" line endings
|
||||
const resp = await POST(diffUrl, {data: new URLSearchParams({content: elTextarea.value})});
|
||||
if (!resp.ok) {
|
||||
showErrorToast(`Failed to render diff: ${resp.status} ${resp.statusText}`);
|
||||
return;
|
||||
}
|
||||
elDiffPanel.innerHTML = await resp.text();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function initRepoEditor() {
|
||||
const dropzoneUpload = document.querySelector<HTMLElement>('.page-content.repository.editor.upload .dropzone');
|
||||
if (dropzoneUpload) initDropzone(dropzoneUpload);
|
||||
|
||||
for (const el of queryElems<HTMLInputElement>(document, '.js-quick-pull-choice-option')) {
|
||||
el.addEventListener('input', () => {
|
||||
if (el.value === 'commit-to-new-branch') {
|
||||
showElem('.quick-pull-branch-name');
|
||||
document.querySelector<HTMLInputElement>('.quick-pull-branch-name input')!.required = true;
|
||||
} else {
|
||||
hideElem('.quick-pull-branch-name');
|
||||
document.querySelector<HTMLInputElement>('.quick-pull-branch-name input')!.required = false;
|
||||
}
|
||||
document.querySelector('#commit-button')!.textContent = el.getAttribute('data-button-text');
|
||||
});
|
||||
}
|
||||
|
||||
// ATTENTION: two pages have this filename input
|
||||
// * new/edit file page: there is a code editor
|
||||
// * upload page: there is no code editor, but a uploader
|
||||
// FIXME: the related logic is totally a mess, need to completely rewrite, that's also the root reason for
|
||||
// why the "migrate to CodeMirror" PR took very long time on the legacy code and introduced "#file-name (filenameInput)" regressions many times
|
||||
const filenameInput = document.querySelector<HTMLInputElement>('#file-name')!;
|
||||
if (!filenameInput) return;
|
||||
filenameInput.value = filenameInput.defaultValue; // prevent browser from restoring form values on refresh
|
||||
function joinTreePath() {
|
||||
const parts = [];
|
||||
for (const el of document.querySelectorAll('.breadcrumb span.section')) {
|
||||
const link = el.querySelector('a');
|
||||
parts.push(link ? link.textContent : el.textContent);
|
||||
}
|
||||
if (filenameInput.value) {
|
||||
parts.push(filenameInput.value);
|
||||
}
|
||||
document.querySelector<HTMLInputElement>('#tree_path')!.value = parts.join('/');
|
||||
}
|
||||
filenameInput.addEventListener('input', function () {
|
||||
const parts = filenameInput.value.split('/');
|
||||
const links = Array.from(document.querySelectorAll('.breadcrumb span.section'));
|
||||
const dividers = Array.from(document.querySelectorAll('.breadcrumb .breadcrumb-divider'));
|
||||
let warningDiv = document.querySelector<HTMLDivElement>('.ui.warning.message.flash-message.flash-warning.space-related');
|
||||
let containSpace = false;
|
||||
if (parts.length > 1) {
|
||||
for (let i = 0; i < parts.length; ++i) {
|
||||
const value = parts[i];
|
||||
const trimValue = value.trim();
|
||||
if (trimValue === '..') {
|
||||
// remove previous tree path
|
||||
if (links.length > 0) {
|
||||
const link = links.pop()!;
|
||||
const divider = dividers.pop()!;
|
||||
link.remove();
|
||||
divider.remove();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (i < parts.length - 1) {
|
||||
if (trimValue.length) {
|
||||
const linkElement = createElementFromHTML(
|
||||
html`<span class="section"><a href="#">${value}</a></span>`,
|
||||
);
|
||||
const dividerElement = createElementFromHTML(
|
||||
html`<div class="breadcrumb-divider">/</div>`,
|
||||
);
|
||||
links.push(linkElement);
|
||||
dividers.push(dividerElement);
|
||||
filenameInput.before(linkElement);
|
||||
filenameInput.before(dividerElement);
|
||||
}
|
||||
} else {
|
||||
filenameInput.value = value;
|
||||
}
|
||||
this.setSelectionRange(0, 0);
|
||||
containSpace = containSpace || (trimValue !== value && trimValue !== '');
|
||||
}
|
||||
}
|
||||
containSpace = containSpace || Array.from(links).some((link) => {
|
||||
const value = link.querySelector('a')!.textContent;
|
||||
return value.trim() !== value;
|
||||
});
|
||||
containSpace = containSpace || parts[parts.length - 1].trim() !== parts[parts.length - 1];
|
||||
if (containSpace) {
|
||||
if (!warningDiv) {
|
||||
warningDiv = document.createElement('div');
|
||||
warningDiv.classList.add('ui', 'warning', 'message', 'flash-message', 'flash-warning', 'space-related');
|
||||
warningDiv.innerHTML = html`<p>File path contains leading or trailing whitespace.</p>`;
|
||||
// Change to `block` display because it is set to 'none' in fomantic/build/semantic.css
|
||||
warningDiv.classList.add('tw-block');
|
||||
const inputContainer = document.querySelector('.repo-editor-header')!;
|
||||
inputContainer.insertAdjacentElement('beforebegin', warningDiv);
|
||||
}
|
||||
showElem(warningDiv);
|
||||
} else if (warningDiv) {
|
||||
hideElem(warningDiv);
|
||||
}
|
||||
joinTreePath();
|
||||
});
|
||||
filenameInput.addEventListener('keydown', function (e) {
|
||||
const sections = queryElems(document, '.breadcrumb span.section');
|
||||
const dividers = queryElems(document, '.breadcrumb .breadcrumb-divider');
|
||||
// Jump back to last directory once the filename is empty
|
||||
if (e.code === 'Backspace' && filenameInput.selectionStart === 0 && sections.length > 0) {
|
||||
e.preventDefault();
|
||||
const lastSection = sections[sections.length - 1];
|
||||
const lastDivider = dividers.length ? dividers[dividers.length - 1] : null;
|
||||
const value = lastSection.querySelector('a')!.textContent;
|
||||
filenameInput.value = value + filenameInput.value;
|
||||
this.setSelectionRange(value.length, value.length);
|
||||
lastDivider?.remove();
|
||||
lastSection.remove();
|
||||
joinTreePath();
|
||||
}
|
||||
});
|
||||
|
||||
const elForm = document.querySelector<HTMLFormElement>('.repository.editor .edit.form')!;
|
||||
|
||||
// see the ATTENTION above, on the upload page, there is no editor(textarea)
|
||||
// so only the filename input above is initialized, the code below (for the code editor) will be skipped
|
||||
const editArea = document.querySelector<HTMLTextAreaElement>('.page-content.repository.editor textarea#edit_area');
|
||||
if (!editArea) return;
|
||||
|
||||
// Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage
|
||||
// to enable or disable the commit button
|
||||
const commitButton = document.querySelector<HTMLButtonElement>('#commit-button')!;
|
||||
const dirtyFileClass = 'dirty-file';
|
||||
|
||||
const syncCommitButtonState = () => {
|
||||
const dirty = elForm.classList.contains(dirtyFileClass);
|
||||
commitButton.disabled = !dirty;
|
||||
};
|
||||
// Registering a custom listener for the file path and the file content
|
||||
// FIXME: it is not quite right here (old bug), it causes double-init, the global areYouSure "dirty" class will also be added
|
||||
applyAreYouSure(elForm, {
|
||||
silent: true,
|
||||
dirtyClass: dirtyFileClass,
|
||||
fieldSelector: ':input:not(.commit-form-wrapper :input)',
|
||||
change: syncCommitButtonState,
|
||||
});
|
||||
syncCommitButtonState(); // disable the "commit" button when no content changes
|
||||
|
||||
initEditPreviewTab(elForm);
|
||||
|
||||
(async () => {
|
||||
const editor = await createCodeEditor(editArea, filenameInput);
|
||||
filenameInput.addEventListener('input', onInputDebounce(() => editor.updateFilename(filenameInput.value)));
|
||||
|
||||
// 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.view.dispatch({
|
||||
changes: {from: 0, to: editor.view.state.doc.length, insert: value},
|
||||
});
|
||||
}
|
||||
|
||||
commitButton.addEventListener('click', async (e) => {
|
||||
if (editor.trimTrailingWhitespace) {
|
||||
trimTrailingWhitespaceFromView(editor.view);
|
||||
}
|
||||
// A modal which asks if an empty file should be committed
|
||||
if (!editArea.value) {
|
||||
e.preventDefault();
|
||||
if (await confirmModal({
|
||||
header: elForm.getAttribute('data-text-empty-confirm-header')!,
|
||||
content: elForm.getAttribute('data-text-empty-confirm-content')!,
|
||||
})) {
|
||||
ignoreAreYouSure(elForm);
|
||||
submitFormFetchAction(elForm);
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
export function renderPreviewPanelContent(previewPanel: Element, htmlContent: string) {
|
||||
// the content is from the server, so it is safe to use innerHTML
|
||||
previewPanel.innerHTML = html`<div class="render-content render-preview markup">${htmlRaw(htmlContent)}</div>`;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {strSubMatch, calcMatchedWeight, filterRepoFilesWeighted} from './repo-findfile.ts';
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import {createApp} from 'vue';
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
|
||||
const threshold = 50;
|
||||
|
||||
// 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: string, subLower: string) {
|
||||
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: Array<any>) {
|
||||
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: Array<string>, filter: string) {
|
||||
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 (const file of files) {
|
||||
const res = strSubMatch(file, 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;
|
||||
}
|
||||
|
||||
export function initRepoFileSearch() {
|
||||
registerGlobalInitFunc('initRepoFileSearch', async (el) => {
|
||||
const {default: RepoFileSearch} = await import('../components/RepoFileSearch.vue');
|
||||
createApp(RepoFileSearch, {
|
||||
repoLink: el.getAttribute('data-repo-link'),
|
||||
currentRefNameSubURL: el.getAttribute('data-current-ref-name-sub-url'),
|
||||
treeListUrl: el.getAttribute('data-tree-list-url'),
|
||||
noResultsText: el.getAttribute('data-no-results-text'),
|
||||
placeholder: el.getAttribute('data-placeholder'),
|
||||
}).mount(el);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {toggleElemClass} from '../utils/dom.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
|
||||
export function initRepoGraphGit() {
|
||||
const graphContainer = document.querySelector<HTMLElement>('#git-graph-container');
|
||||
if (!graphContainer) return;
|
||||
|
||||
const elColorMonochrome = document.querySelector<HTMLElement>('#flow-color-monochrome')!;
|
||||
const elColorColored = document.querySelector<HTMLElement>('#flow-color-colored')!;
|
||||
const toggleColorMode = (mode: 'monochrome' | 'colored') => {
|
||||
toggleElemClass(graphContainer, 'monochrome', mode === 'monochrome');
|
||||
toggleElemClass(graphContainer, 'colored', mode === 'colored');
|
||||
|
||||
toggleElemClass(elColorMonochrome, 'active', mode === 'monochrome');
|
||||
toggleElemClass(elColorColored, 'active', mode === 'colored');
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('mode', mode);
|
||||
window.history.replaceState(null, '', `?${params.toString()}`);
|
||||
for (const link of document.querySelectorAll('#git-graph-body .pagination a')) {
|
||||
const href = link.getAttribute('href');
|
||||
if (!href) continue;
|
||||
const url = new URL(href, window.location.href);
|
||||
const params = url.searchParams;
|
||||
params.set('mode', mode);
|
||||
url.search = `?${params.toString()}`;
|
||||
link.setAttribute('href', url.href);
|
||||
}
|
||||
};
|
||||
elColorMonochrome.addEventListener('click', () => toggleColorMode('monochrome'));
|
||||
elColorColored.addEventListener('click', () => toggleColorMode('colored'));
|
||||
|
||||
const elGraphBody = document.querySelector<HTMLElement>('#git-graph-body')!;
|
||||
const url = new URL(window.location.href);
|
||||
const params = url.searchParams;
|
||||
const loadGitGraph = async () => {
|
||||
const queryString = params.toString();
|
||||
const ajaxUrl = new URL(url);
|
||||
ajaxUrl.searchParams.set('div-only', 'true');
|
||||
window.history.replaceState(null, '', queryString ? `?${queryString}` : window.location.pathname);
|
||||
|
||||
elGraphBody.classList.add('is-loading');
|
||||
try {
|
||||
const resp = await GET(ajaxUrl.toString());
|
||||
elGraphBody.innerHTML = await resp.text();
|
||||
} finally {
|
||||
elGraphBody.classList.remove('is-loading');
|
||||
}
|
||||
};
|
||||
|
||||
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 $dropdown = fomanticQuery('#flow-select-refs-dropdown');
|
||||
$dropdown.dropdown({clearable: true});
|
||||
$dropdown.dropdown('set selected', dropdownSelected);
|
||||
// must add the callback after setting the selected items, otherwise each "selected" item will trigger the callback
|
||||
$dropdown.dropdown('setting', {
|
||||
onRemove(toRemove: string) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
loadGitGraph();
|
||||
},
|
||||
onAdd(toAdd: string) {
|
||||
if (toAdd === '...flow-hide-pr-refs') {
|
||||
params.set('hide-pr-refs', 'true');
|
||||
} else {
|
||||
params.append('branch', toAdd);
|
||||
}
|
||||
loadGitGraph();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import {stripTags} from '../utils.ts';
|
||||
import {hideElem, queryElemChildren, showElem} from '../utils/dom.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast, type Toast} from '../modules/toast.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
export function initRepoTopicBar() {
|
||||
const mgrBtn = document.querySelector<HTMLButtonElement>('#manage_topic');
|
||||
if (!mgrBtn) return;
|
||||
|
||||
const editDiv = document.querySelector('#topic_edit')!;
|
||||
const viewDiv = document.querySelector('#repo-topics')!;
|
||||
const topicDropdown = editDiv.querySelector('.ui.dropdown')!;
|
||||
let lastErrorToast: Toast | null = null;
|
||||
|
||||
mgrBtn.addEventListener('click', () => {
|
||||
hideElem([viewDiv, mgrBtn]);
|
||||
showElem(editDiv);
|
||||
topicDropdown.querySelector<HTMLInputElement>('input.search')!.focus();
|
||||
});
|
||||
|
||||
document.querySelector('#cancel_topic_edit')!.addEventListener('click', () => {
|
||||
lastErrorToast?.hideToast();
|
||||
hideElem(editDiv);
|
||||
showElem([viewDiv, mgrBtn]);
|
||||
mgrBtn.focus();
|
||||
});
|
||||
|
||||
document.querySelector<HTMLButtonElement>('#save_topic')!.addEventListener('click', async (e) => {
|
||||
lastErrorToast?.hideToast();
|
||||
const topics = editDiv.querySelector<HTMLInputElement>('input[name=topics]')!.value;
|
||||
|
||||
const data = new FormData();
|
||||
data.append('topics', topics);
|
||||
|
||||
const response = await POST((e.target as HTMLElement).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) {
|
||||
// TODO: sort items in topicDropdown, or items in edit div will have different order to the items in view div
|
||||
// !!!! it SHOULD and MUST match the code in "home_sidebar_top.tmpl" !!!!
|
||||
const link = document.createElement('a');
|
||||
link.classList.add('repo-topic', 'ui', 'large', 'label', 'gt-ellipsis');
|
||||
link.href = `${appSubUrl}/explore/repos?q=${encodeURIComponent(topic)}&topic=1`;
|
||||
link.textContent = topic;
|
||||
viewDiv.append(link);
|
||||
}
|
||||
}
|
||||
hideElem(editDiv);
|
||||
showElem([viewDiv, mgrBtn]);
|
||||
}
|
||||
} 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 && 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fomanticQuery(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(this: any, res: any) {
|
||||
const formattedResponse = {
|
||||
success: false,
|
||||
results: [] as Array<Record<string, any>>,
|
||||
};
|
||||
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 (const {topic_name} of res.topics) {
|
||||
// skip currently added tags
|
||||
if (current_topics.includes(topic_name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (topic_name.toLowerCase() === query.toLowerCase()) {
|
||||
found_query = true;
|
||||
}
|
||||
formattedResponse.results.push({description: topic_name, 'data-value': 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: string) {
|
||||
value = value.toLowerCase().trim();
|
||||
this.attr('data-value', value).contents().first().replaceWith(value);
|
||||
return fomanticQuery(this);
|
||||
},
|
||||
onAdd(addedValue: string, _addedText: any, $addedChoice: any) {
|
||||
addedValue = addedValue.toLowerCase().trim();
|
||||
$addedChoice[0].setAttribute('data-value', addedValue);
|
||||
$addedChoice[0].setAttribute('data-text', addedValue);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import {svg} from '../svg.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {createElementFromHTML, showElem} from '../utils/dom.ts';
|
||||
import {parseIssuePageInfo} from '../utils.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
|
||||
let i18nTextEdited: string;
|
||||
let i18nTextOptions: string;
|
||||
let i18nTextDeleteFromHistory: string;
|
||||
let i18nTextDeleteFromHistoryConfirm: string;
|
||||
|
||||
function showContentHistoryDetail(issueBaseUrl: string, commentId: string, historyId: string, itemTitleHtml: string) {
|
||||
const elDetailDialog = createElementFromHTML(`
|
||||
<div class="ui modal content-history-detail-dialog">
|
||||
${svg('octicon-x', 16, 'close icon inside')}
|
||||
<div class="header flex-left-right">
|
||||
<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 tw-text-red" data-option-item="delete">${i18nTextDeleteFromHistory}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment-diff-data is-loading"></div>
|
||||
</div>`);
|
||||
document.body.append(elDetailDialog);
|
||||
const elOptionsDropdown = elDetailDialog.querySelector('.ui.dropdown.dialog-header-options')!;
|
||||
const $fomanticDropdownOptions = fomanticQuery(elOptionsDropdown);
|
||||
$fomanticDropdownOptions.dropdown({
|
||||
showOnFocus: false,
|
||||
allowReselection: true,
|
||||
async onChange(_value: string, _text: string, $item: any) {
|
||||
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) {
|
||||
hideFomanticModal(elDetailDialog);
|
||||
} 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() {
|
||||
$fomanticDropdownOptions.dropdown('clear', true);
|
||||
},
|
||||
});
|
||||
showFomanticModal(elDetailDialog, {
|
||||
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 = elDetailDialog.querySelector('.comment-diff-data')!;
|
||||
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(elOptionsDropdown);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
},
|
||||
onHidden() {
|
||||
elDetailDialog.remove();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function showContentHistoryMenu(issueBaseUrl: string, elCommentItem: Element, commentId: string) {
|
||||
const elHeaderLeft = elCommentItem.querySelector('.comment-header-left')!;
|
||||
const menuHtml = `
|
||||
<div class="ui dropdown interact-fg content-history-menu tw-flex-shrink-0" data-comment-id="${commentId}">
|
||||
• ${i18nTextEdited}${svg('octicon-triangle-down', 14, 'dropdown icon')}
|
||||
<div class="menu">
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
elHeaderLeft.querySelector(`.ui.dropdown.content-history-menu`)?.remove(); // remove the old one if exists
|
||||
elHeaderLeft.append(createElementFromHTML(menuHtml));
|
||||
|
||||
const elDropdown = elHeaderLeft.querySelector('.ui.dropdown.content-history-menu')!;
|
||||
const $fomanticDropdown = fomanticQuery(elDropdown);
|
||||
$fomanticDropdown.dropdown({
|
||||
action: 'hide',
|
||||
apiSettings: {
|
||||
cache: false,
|
||||
url: `${issueBaseUrl}/content-history/list?comment_id=${commentId}`,
|
||||
},
|
||||
saveRemoteData: false,
|
||||
onHide() {
|
||||
$fomanticDropdown.dropdown('change values', null);
|
||||
},
|
||||
onChange(value: string, itemHtml: string, $item: any) {
|
||||
if (value && !$item.find('[data-history-is-deleted=1]').length) {
|
||||
showContentHistoryDetail(issueBaseUrl, commentId, value, itemHtml);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function initRepoIssueContentHistory() {
|
||||
const issuePageInfo = parseIssuePageInfo();
|
||||
if (!issuePageInfo.issueNumber) return;
|
||||
|
||||
const elIssueDescription = document.querySelector('.repository.issue .timeline-item.comment.issue-content-comment'); // issue(PR) main content
|
||||
const elComments = document.querySelectorAll('.repository.issue .comment-list .comment'); // includes: issue(PR) comments, review comments, code comments
|
||||
if (!elIssueDescription && !elComments.length) return;
|
||||
|
||||
const issueBaseUrl = `${issuePageInfo.repoLink}/issues/${issuePageInfo.issueNumber}`;
|
||||
|
||||
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] && elIssueDescription) {
|
||||
showContentHistoryMenu(issueBaseUrl, elIssueDescription, '0');
|
||||
}
|
||||
for (const [commentId, _editedCount] of Object.entries(resp.editedHistoryCountMap)) {
|
||||
if (commentId === '0') continue;
|
||||
const elIssueComment = document.querySelector(`#issuecomment-${commentId}`);
|
||||
if (elIssueComment) showContentHistoryMenu(issueBaseUrl, elIssueComment, commentId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import {handleReply} from './repo-issue.ts';
|
||||
import {getComboMarkdownEditor, initComboMarkdownEditor, ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {hideElem, querySingleVisibleElem, showElem} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {triggerUploadStateChanged} from './comp/EditorUpload.ts';
|
||||
import {convertHtmlToMarkdown} from '../markup/html2markdown.ts';
|
||||
import {applyAreYouSure, reinitializeAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
||||
|
||||
async function tryOnEditContent(e: Event) {
|
||||
const clickTarget = (e.target as HTMLElement).closest('.edit-content');
|
||||
if (!clickTarget) return;
|
||||
|
||||
e.preventDefault();
|
||||
const commentContent = clickTarget.closest('.comment-header')!.nextElementSibling!;
|
||||
const editContentZone = commentContent.querySelector('.edit-content-zone')!;
|
||||
let renderContent = commentContent.querySelector('.render-content')!;
|
||||
const rawContent = commentContent.querySelector('.raw-content')!;
|
||||
|
||||
let comboMarkdownEditor : ComboMarkdownEditor;
|
||||
|
||||
const cancelAndReset = (e: Event) => {
|
||||
e.preventDefault();
|
||||
showElem(renderContent);
|
||||
hideElem(editContentZone);
|
||||
comboMarkdownEditor.dropzoneReloadFiles();
|
||||
};
|
||||
|
||||
const saveAndRefresh = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
// we are already in a form, do not bubble up to the document otherwise there will be other "form submit handlers"
|
||||
// at the moment, the form submit event conflicts with initRepoDiffConversationForm (global '.conversation-holder form' event handler)
|
||||
e.stopPropagation();
|
||||
renderContent.classList.add('is-loading');
|
||||
showElem(renderContent);
|
||||
hideElem(editContentZone);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
content: comboMarkdownEditor.value(),
|
||||
context: String(editContentZone.getAttribute('data-context')),
|
||||
content_version: String(editContentZone.getAttribute('data-content-version')),
|
||||
});
|
||||
for (const file of comboMarkdownEditor.dropzoneGetFiles() ?? []) {
|
||||
params.append('files[]', file);
|
||||
}
|
||||
|
||||
const response = await POST(editContentZone.getAttribute('data-update-url')!, {data: params});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
showErrorToast(data?.errorMessage ?? window.config.i18n.error_occurred);
|
||||
return;
|
||||
}
|
||||
|
||||
reinitializeAreYouSure(editContentZone.querySelector('form')); // the form is no longer dirty
|
||||
editContentZone.setAttribute('data-content-version', data.contentVersion);
|
||||
|
||||
// replace the render content with new one, to trigger re-initialization of all features
|
||||
const newRenderContent = renderContent.cloneNode(false) as HTMLElement;
|
||||
newRenderContent.innerHTML = data.content;
|
||||
renderContent.replaceWith(newRenderContent);
|
||||
renderContent = newRenderContent;
|
||||
|
||||
rawContent.textContent = comboMarkdownEditor.value();
|
||||
|
||||
if (!commentContent.querySelector('.dropzone-attachments')) {
|
||||
if (data.attachments !== '') {
|
||||
commentContent.insertAdjacentHTML('beforeend', data.attachments);
|
||||
}
|
||||
} else if (data.attachments === '') {
|
||||
commentContent.querySelector('.dropzone-attachments')!.remove();
|
||||
} else {
|
||||
commentContent.querySelector('.dropzone-attachments')!.outerHTML = data.attachments;
|
||||
}
|
||||
comboMarkdownEditor.dropzoneSubmitReload();
|
||||
} catch (error) {
|
||||
showErrorToast(`Failed to save the content: ${errorMessage(error)}`);
|
||||
console.error(error);
|
||||
} finally {
|
||||
renderContent.classList.remove('is-loading');
|
||||
}
|
||||
};
|
||||
|
||||
// Show write/preview tab and copy raw content as needed
|
||||
showElem(editContentZone);
|
||||
hideElem(renderContent);
|
||||
|
||||
comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'))!;
|
||||
if (!comboMarkdownEditor) {
|
||||
editContentZone.innerHTML = document.querySelector('#issue-comment-editor-template')!.innerHTML;
|
||||
const form = editContentZone.querySelector('form')!;
|
||||
applyAreYouSure(form);
|
||||
const saveButton = querySingleVisibleElem<HTMLButtonElement>(editContentZone, '.ui.primary.button')!;
|
||||
const cancelButton = querySingleVisibleElem<HTMLButtonElement>(editContentZone, '.ui.cancel.button')!;
|
||||
comboMarkdownEditor = await initComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor')!);
|
||||
const syncUiState = () => saveButton.disabled = comboMarkdownEditor.isUploading();
|
||||
comboMarkdownEditor.container.addEventListener(ComboMarkdownEditor.EventUploadStateChanged, syncUiState);
|
||||
cancelButton.addEventListener('click', cancelAndReset);
|
||||
form.addEventListener('submit', saveAndRefresh);
|
||||
}
|
||||
// when the content has changed on server side, there is no sync, and this page doesn't have the latest content,
|
||||
// the editor still shows the old content, server will reject end user's submit by "data-content-version" check
|
||||
comboMarkdownEditor.value(rawContent.textContent);
|
||||
comboMarkdownEditor.switchTabToEditor();
|
||||
comboMarkdownEditor.focus();
|
||||
triggerUploadStateChanged(comboMarkdownEditor.container);
|
||||
}
|
||||
|
||||
function extractSelectedMarkdown(container: HTMLElement) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) return '';
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!container.contains(range.commonAncestorContainer)) return '';
|
||||
|
||||
// todo: if commonAncestorContainer parent has "[data-markdown-original-content]" attribute, use the parent's markdown content
|
||||
// otherwise, use the selected HTML content and respect all "[data-markdown-original-content]/[data-markdown-generated-content]" attributes
|
||||
const contents = selection.getRangeAt(0).cloneContents();
|
||||
const el = document.createElement('div');
|
||||
el.append(contents);
|
||||
return convertHtmlToMarkdown(el);
|
||||
}
|
||||
|
||||
async function tryOnQuoteReply(e: Event) {
|
||||
const clickTarget = (e.target as HTMLElement).closest('.quote-reply');
|
||||
if (!clickTarget) return;
|
||||
|
||||
e.preventDefault();
|
||||
const contentToQuoteId = clickTarget.getAttribute('data-target');
|
||||
const targetRawToQuote = document.querySelector<HTMLElement>(`#${contentToQuoteId}.raw-content`)!;
|
||||
const targetMarkupToQuote = targetRawToQuote.parentElement!.querySelector<HTMLElement>('.render-content.markup')!;
|
||||
let contentToQuote = extractSelectedMarkdown(targetMarkupToQuote);
|
||||
if (!contentToQuote) contentToQuote = targetRawToQuote.textContent;
|
||||
const quotedContent = `${contentToQuote.replace(/^/mg, '> ')}\n\n`;
|
||||
|
||||
let editor;
|
||||
if (clickTarget.classList.contains('quote-reply-diff')) {
|
||||
const replyBtn = clickTarget.closest('.comment-code-cloud')!.querySelector<HTMLElement>('button.comment-form-reply')!;
|
||||
editor = await handleReply(replyBtn);
|
||||
} else {
|
||||
// for normal issue/comment page
|
||||
editor = getComboMarkdownEditor(document.querySelector('#comment-form .combo-markdown-editor'))!;
|
||||
}
|
||||
|
||||
if (editor.value()) {
|
||||
editor.value(`${editor.value()}\n\n${quotedContent}`);
|
||||
} else {
|
||||
editor.value(quotedContent);
|
||||
}
|
||||
editor.focus();
|
||||
editor.moveCursorToEnd();
|
||||
}
|
||||
|
||||
export function initRepoIssueCommentEdit() {
|
||||
document.addEventListener('click', (e) => {
|
||||
tryOnEditContent(e); // Edit issue or comment content
|
||||
tryOnQuoteReply(e); // Quote reply to the comment editor
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import {updateIssuesMeta} from './repo-common.ts';
|
||||
import {toggleElem, queryElems, isElemVisible} from '../utils/dom.ts';
|
||||
import {html, htmlRaw} from '../utils/html.ts';
|
||||
import {confirmModal} from './comp/ConfirmModal.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {createSortable} from '../modules/sortable.ts';
|
||||
import {DELETE, POST} from '../modules/fetch.ts';
|
||||
import {parseDom} from '../utils.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import type {SortableEvent} from 'sortablejs';
|
||||
|
||||
function initRepoIssueListCheckboxes() {
|
||||
const issueSelectAll = document.querySelector<HTMLInputElement>('.issue-checkbox-all');
|
||||
if (!issueSelectAll) return; // logged out state
|
||||
const issueCheckboxes = document.querySelectorAll<HTMLInputElement>('.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<HTMLElement>('#issue-filters, #issue-actions');
|
||||
const visiblePanel = Array.from(panels).find((el) => isElemVisible(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();
|
||||
});
|
||||
|
||||
queryElems(document, '.issue-action', (el) => el.addEventListener('click',
|
||||
async (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const url = el.getAttribute('data-url')!;
|
||||
let action = el.getAttribute('data-action')!;
|
||||
let elementId = el.getAttribute('data-element-id')!;
|
||||
const issueIDList: string[] = [];
|
||||
for (const el of document.querySelectorAll('.issue-checkbox:checked')) {
|
||||
issueIDList.push(el.getAttribute('data-issue-id')!);
|
||||
}
|
||||
const issueIDs = issueIDList.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 = el.getAttribute('data-action-delete-confirm')!;
|
||||
if (!await confirmModal({content: confirmText, confirmButtonColor: 'red'})) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await updateIssuesMeta(url, action, issueIDs, elementId);
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
// FIXME: this logic (including updateIssuesMeta) is not right, should refactor to our JSONError framework
|
||||
const e = err as {responseJSON?: {error: string}};
|
||||
showErrorToast(e.responseJSON?.error ?? errorMessage(err));
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
function initDropdownUserRemoteSearch(el: Element) {
|
||||
let searchUrl = el.getAttribute('data-search-url');
|
||||
const actionJumpUrl = el.getAttribute('data-action-jump-url')!;
|
||||
let selectedUsername = el.getAttribute('data-selected-username') || '';
|
||||
const $searchDropdown = fomanticQuery(el);
|
||||
const elMenu = el.querySelector('.menu')!;
|
||||
const elSearchInput = el.querySelector<HTMLInputElement>('.ui.search input')!;
|
||||
const elItemFromInput = el.querySelector('.menu > .item-from-input')!;
|
||||
|
||||
$searchDropdown.dropdown('setting', {
|
||||
fullTextSearch: true,
|
||||
selectOnKeydown: false,
|
||||
action: (_text: string, value: string) => {
|
||||
window.location.href = actionJumpUrl.replace('{username}', encodeURIComponent(value));
|
||||
},
|
||||
});
|
||||
|
||||
const selectUsername = (username: string) => {
|
||||
queryElems(elMenu, '.item.active, .item.selected', (el) => el.classList.remove('active', 'selected'));
|
||||
elMenu.querySelector(`.item[data-value="${CSS.escape(username)}"]`)?.classList.add('selected');
|
||||
};
|
||||
|
||||
type ProcessedResult = {value: string, name: string};
|
||||
const processedResults: ProcessedResult[] = []; // to be used by dropdown to generate menu items
|
||||
const syncItemFromInput = () => {
|
||||
const inputVal = elSearchInput.value.trim();
|
||||
elItemFromInput.setAttribute('data-value', inputVal);
|
||||
elItemFromInput.textContent = inputVal;
|
||||
const showItemFromInput = !processedResults.length && inputVal !== '';
|
||||
toggleElem(elItemFromInput, showItemFromInput);
|
||||
selectUsername(showItemFromInput ? inputVal : selectedUsername);
|
||||
};
|
||||
|
||||
elSearchInput.value = selectedUsername;
|
||||
if (!searchUrl) {
|
||||
elSearchInput.addEventListener('input', syncItemFromInput);
|
||||
} else {
|
||||
if (!searchUrl.includes('?')) searchUrl += '?';
|
||||
$searchDropdown.dropdown('setting', 'apiSettings', {
|
||||
cache: false,
|
||||
url: `${searchUrl}&q={query}`,
|
||||
onResponse(resp: any) {
|
||||
// the content is provided by backend IssuePosters handler
|
||||
processedResults.length = 0;
|
||||
for (const item of resp.results) {
|
||||
const htmlAvatar = html`<img class="ui avatar tw-align-middle" src="${item.avatar_link}" aria-hidden="true" alt width="20" height="20">`;
|
||||
const htmlFullName = item.full_name ? html`<span class="username-fullname gt-ellipsis">(${item.full_name})</span>` : '';
|
||||
const htmlItem = html`<span class="username-display">${htmlRaw(htmlAvatar)}<span>${item.username}</span>${htmlRaw(htmlFullName)}</span>`;
|
||||
if (selectedUsername.toLowerCase() === item.username.toLowerCase()) selectedUsername = item.username;
|
||||
processedResults.push({value: item.username, name: htmlItem});
|
||||
}
|
||||
resp.results = processedResults;
|
||||
return resp;
|
||||
},
|
||||
});
|
||||
$searchDropdown.dropdown('setting', '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: any) {
|
||||
// remove old dynamic items
|
||||
for (const el of elMenu.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');
|
||||
elMenu.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(() => syncItemFromInput(), 0);
|
||||
};
|
||||
}
|
||||
|
||||
function initPinRemoveButton() {
|
||||
for (const button of document.querySelectorAll('.issue-card-unpin')) {
|
||||
button.addEventListener('click', async (event) => {
|
||||
const el = event.currentTarget as HTMLElement;
|
||||
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: SortableEvent) {
|
||||
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.querySelector<HTMLElement>('#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: (e) => {
|
||||
(async () => {
|
||||
await pinMoveEnd(e);
|
||||
})();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function initRepoIssueList() {
|
||||
if (document.querySelector('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list')) {
|
||||
initRepoIssueListCheckboxes();
|
||||
queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el));
|
||||
initIssuePinSort();
|
||||
} else if (document.querySelector('.page-content.dashboard.issues')) {
|
||||
// user or org home: issue list, pull request list
|
||||
queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {createApp} from 'vue';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {createElementFromHTML} from '../utils/dom.ts';
|
||||
import {registerGlobalEventFunc} from '../modules/observer.ts';
|
||||
|
||||
export function initRepoPullRequestUpdate(el: HTMLElement) {
|
||||
const elDropdown = el.querySelector(':scope > .ui.dropdown');
|
||||
if (!elDropdown) return;
|
||||
const elButton = el.querySelector<HTMLButtonElement>(':scope > button')!;
|
||||
|
||||
fomanticQuery(elDropdown).dropdown({
|
||||
onChange(_text: string, _value: string, $choice: any) {
|
||||
const choiceEl = $choice[0];
|
||||
elButton.textContent = choiceEl.textContent;
|
||||
elButton.setAttribute('data-url', choiceEl.getAttribute('data-update-url'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onCommitStatusChecksToggle(btn: HTMLElement) {
|
||||
const panel = btn.closest('.commit-status-toggle')!.parentElement!;
|
||||
const list = panel.querySelector<HTMLElement>('.commit-status-list')!;
|
||||
list.style.maxHeight = list.style.maxHeight ? '' : '0px'; // toggle
|
||||
btn.textContent = btn.getAttribute(list.style.maxHeight ? 'data-show-all' : 'data-hide-all');
|
||||
}
|
||||
|
||||
async function initRepoPullRequestMergeForm(box: HTMLElement) {
|
||||
const el = box.querySelector('#pull-request-merge-form');
|
||||
if (!el) return;
|
||||
|
||||
const data = JSON.parse(el.getAttribute('data-merge-form-props')!);
|
||||
const {default: PullRequestMergeForm} = await import('../components/PullRequestMergeForm.vue');
|
||||
const view = createApp(PullRequestMergeForm, {mergeFormProps: data});
|
||||
view.mount(el); // TODO: can unmount when reloaded?
|
||||
}
|
||||
|
||||
export function initRepoPullMergeBox(el: HTMLElement) {
|
||||
registerGlobalEventFunc('click', 'onCommitStatusChecksToggle', onCommitStatusChecksToggle);
|
||||
initRepoPullRequestMergeForm(el);
|
||||
|
||||
const reloadingIntervalValue = el.getAttribute('data-pull-merge-box-reloading-interval');
|
||||
if (!reloadingIntervalValue) return;
|
||||
|
||||
const reloadingInterval = parseInt(reloadingIntervalValue);
|
||||
const pullLink = el.getAttribute('data-pull-link');
|
||||
let timerId: number | null;
|
||||
|
||||
let reloadMergeBox: () => Promise<void>;
|
||||
const stopReloading = () => {
|
||||
if (!timerId) return;
|
||||
clearTimeout(timerId);
|
||||
timerId = null;
|
||||
};
|
||||
const startReloading = () => {
|
||||
if (timerId) return;
|
||||
setTimeout(reloadMergeBox, reloadingInterval);
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
stopReloading();
|
||||
} else {
|
||||
startReloading();
|
||||
}
|
||||
};
|
||||
reloadMergeBox = async () => {
|
||||
const resp = await GET(`${pullLink}/merge_box`);
|
||||
stopReloading();
|
||||
if (!resp.ok) {
|
||||
startReloading();
|
||||
return;
|
||||
}
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
const newElem = createElementFromHTML(await resp.text());
|
||||
el.replaceWith(newElem);
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
startReloading();
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {syncIssueMainContentTimelineItems} from './repo-issue-sidebar-combolist.ts';
|
||||
import {createElementFromHTML} from '../utils/dom.ts';
|
||||
|
||||
describe('syncIssueMainContentTimelineItems', () => {
|
||||
test('InsertNew', () => {
|
||||
const oldContent = createElementFromHTML(`
|
||||
<div>
|
||||
<div class="timeline-item">First</div>
|
||||
<div class="timeline-item" id="timeline-comments-end"></div>
|
||||
</div>
|
||||
`);
|
||||
const newContent = createElementFromHTML(`
|
||||
<div>
|
||||
<div class="timeline-item" id="a">New</div>
|
||||
</div>
|
||||
`);
|
||||
syncIssueMainContentTimelineItems(oldContent, newContent);
|
||||
expect(oldContent.innerHTML.replace(/>\s+</g, '><').trim()).toBe(
|
||||
`<div class="timeline-item">First</div>` +
|
||||
`<div class="timeline-item" id="a">New</div>` +
|
||||
`<div class="timeline-item" id="timeline-comments-end"></div>`,
|
||||
);
|
||||
});
|
||||
|
||||
test('Sync', () => {
|
||||
const oldContent = createElementFromHTML(`
|
||||
<div>
|
||||
<div class="timeline-item">First</div>
|
||||
<div class="timeline-item" id="it-1">Item 1</div>
|
||||
<div class="timeline-item event" id="it-2">Item 2</div>
|
||||
<div class="timeline-item" id="it-3">Item 3</div>
|
||||
<div class="timeline-item event" id="it-4">Item 4</div>
|
||||
<div class="timeline-item" id="timeline-comments-end"></div>
|
||||
<div class="timeline-item">Other</div>
|
||||
</div>
|
||||
`);
|
||||
const newContent = createElementFromHTML(`
|
||||
<div>
|
||||
<div class="timeline-item" id="it-1">New 1</div>
|
||||
<div class="timeline-item event" id="it-2">New 2</div>
|
||||
<div class="timeline-item" id="it-x">New X</div>
|
||||
</div>
|
||||
`);
|
||||
syncIssueMainContentTimelineItems(oldContent, newContent);
|
||||
|
||||
// Item 1 won't be replaced because it's not an event
|
||||
// Item 2 will be replaced with New 2
|
||||
// Item 3 will be kept because it's not in new content
|
||||
// Item 4 will be removed because it's not in new content, and it's an event
|
||||
// New X will be inserted at the end of timeline items (before timeline-comments-end)
|
||||
expect(oldContent.innerHTML.replace(/>\s+</g, '><').trim()).toBe(
|
||||
`<div class="timeline-item">First</div>` +
|
||||
`<div class="timeline-item" id="it-1">Item 1</div>` +
|
||||
`<div class="timeline-item event" id="it-2">New 2</div>` +
|
||||
`<div class="timeline-item" id="it-3">Item 3</div>` +
|
||||
`<div class="timeline-item" id="it-x">New X</div>` +
|
||||
`<div class="timeline-item" id="timeline-comments-end"></div>` +
|
||||
`<div class="timeline-item">Other</div>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {addDelegatedEventListener, queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {parseDom} from '../utils.ts';
|
||||
|
||||
export function syncIssueMainContentTimelineItems(oldMainContent: Element, newMainContent: Element) {
|
||||
// find the end of comments timeline by "id=timeline-comments-end" in current main content, and insert new items before it
|
||||
const timelineEnd = oldMainContent.querySelector('.timeline-item[id="timeline-comments-end"]');
|
||||
if (!timelineEnd) return;
|
||||
|
||||
const oldTimelineItems = oldMainContent.querySelectorAll(`.timeline-item[id]`);
|
||||
for (const oldItem of oldTimelineItems) {
|
||||
const oldItemId = oldItem.getAttribute('id')!;
|
||||
const newItem = newMainContent.querySelector(`.timeline-item[id="${CSS.escape(oldItemId)}"]`);
|
||||
if (oldItem.classList.contains('event') && !newItem) {
|
||||
// if the item is not in new content, we want to remove it from old content only if it's an event item, otherwise we keep it
|
||||
oldItem.remove();
|
||||
}
|
||||
}
|
||||
|
||||
const newTimelineItems = newMainContent.querySelectorAll(`.timeline-item[id]`);
|
||||
for (const newItem of newTimelineItems) {
|
||||
const newItemId = newItem.getAttribute('id')!;
|
||||
const oldItem = oldMainContent.querySelector(`.timeline-item[id="${CSS.escape(newItemId)}"]`);
|
||||
if (oldItem) {
|
||||
if (oldItem.classList.contains('event')) {
|
||||
// for event item (e.g.: "add & remove labels"), we want to replace the existing one if exists
|
||||
// because the label operations can be merged into one event item, so the new item might be different from the old one
|
||||
oldItem.replaceWith(newItem);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
timelineEnd.insertAdjacentElement('beforebegin', newItem);
|
||||
}
|
||||
}
|
||||
|
||||
export class IssueSidebarComboList {
|
||||
updateUrl: string;
|
||||
updateAlgo: string;
|
||||
selectionMode: string;
|
||||
elDropdown: HTMLElement;
|
||||
elList: HTMLElement | null;
|
||||
elComboValue: HTMLInputElement;
|
||||
initialValues: string[] = [];
|
||||
container: HTMLElement;
|
||||
|
||||
elIssueMainContent: HTMLElement;
|
||||
elIssueSidebar: HTMLElement;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container;
|
||||
this.updateUrl = container.getAttribute('data-update-url')!;
|
||||
this.updateAlgo = container.getAttribute('data-update-algo')!;
|
||||
this.selectionMode = container.getAttribute('data-selection-mode')!;
|
||||
if (!['single', 'multiple'].includes(this.selectionMode)) throw new Error(`Invalid data-update-on: ${this.selectionMode}`);
|
||||
if (!['diff', 'all'].includes(this.updateAlgo)) throw new Error(`Invalid data-update-algo: ${this.updateAlgo}`);
|
||||
this.elDropdown = container.querySelector<HTMLElement>(':scope > .ui.dropdown')!;
|
||||
this.elList = container.querySelector<HTMLElement>(':scope > .ui.list');
|
||||
this.elComboValue = container.querySelector<HTMLInputElement>(':scope > .combo-value')!;
|
||||
|
||||
this.elIssueMainContent = document.querySelector('.issue-content-left')!;
|
||||
this.elIssueSidebar = document.querySelector('.issue-content-right')!;
|
||||
}
|
||||
|
||||
collectCheckedValues() {
|
||||
return Array.from(this.elDropdown.querySelectorAll('.menu > .item.checked'), (el) => el.getAttribute('data-value')!);
|
||||
}
|
||||
|
||||
updateUiList(changedValues: Array<string>) {
|
||||
if (!this.elList) return;
|
||||
const elEmptyTip = this.elList.querySelector(':scope > .item.empty-list')!;
|
||||
queryElemChildren(this.elList, '.item:not(.empty-list)', (el) => el.remove());
|
||||
for (const value of changedValues) {
|
||||
const el = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
|
||||
if (!el) continue;
|
||||
const listItem = el.cloneNode(true) as HTMLElement;
|
||||
queryElems(listItem, '.item-check-mark, .item-secondary-info', (el) => el.remove());
|
||||
this.elList.append(listItem);
|
||||
}
|
||||
const hasItems = Boolean(this.elList.querySelector('.item:not(.empty-list)'));
|
||||
toggleElem(elEmptyTip, !hasItems);
|
||||
}
|
||||
|
||||
async reloadPagePartially() {
|
||||
const resp = await GET(window.location.href);
|
||||
if (!resp.ok) throw new Error(`Failed to reload page: ${resp.statusText}`);
|
||||
const doc = parseDom(await resp.text(), 'text/html');
|
||||
|
||||
// we can safely replace the whole right part (sidebar) because there are only some dropdowns and lists
|
||||
const newSidebar = doc.querySelector('.issue-content-right')!;
|
||||
this.elIssueSidebar.replaceWith(newSidebar);
|
||||
|
||||
// for the main content (left side), at the moment we only support handling known timeline items
|
||||
const newMainContent = doc.querySelector('.issue-content-left')!;
|
||||
syncIssueMainContentTimelineItems(this.elIssueMainContent, newMainContent);
|
||||
}
|
||||
|
||||
async sendRequestToBackend(changedValues: Array<string>): Promise<Response | null> {
|
||||
let lastResp: Response | null = null;
|
||||
if (this.updateAlgo === 'diff') {
|
||||
for (const value of this.initialValues) {
|
||||
if (!changedValues.includes(value)) {
|
||||
lastResp = await POST(this.updateUrl, {data: new URLSearchParams({action: 'detach', id: value})});
|
||||
if (!lastResp.ok) return lastResp;
|
||||
}
|
||||
}
|
||||
for (const value of changedValues) {
|
||||
if (!this.initialValues.includes(value)) {
|
||||
lastResp = await POST(this.updateUrl, {data: new URLSearchParams({action: 'attach', id: value})});
|
||||
if (!lastResp.ok) return lastResp;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lastResp = await POST(this.updateUrl, {data: new URLSearchParams({id: changedValues.join(',')})});
|
||||
}
|
||||
return lastResp;
|
||||
}
|
||||
|
||||
async updateToBackend(changedValues: Array<string>) {
|
||||
this.elIssueSidebar.classList.add('is-loading');
|
||||
try {
|
||||
const resp = await this.sendRequestToBackend(changedValues);
|
||||
if (!resp) return; // no request sent, no need to reload
|
||||
if (!resp.ok) {
|
||||
showErrorToast(`Failed to update to backend: ${resp.statusText}`);
|
||||
return;
|
||||
}
|
||||
await this.reloadPagePartially();
|
||||
} catch (e) {
|
||||
console.error('Failed to update to backend', e);
|
||||
showErrorToast(`Failed to update to backend: ${errorMessage(e)}`);
|
||||
} finally {
|
||||
this.elIssueSidebar.classList.remove('is-loading');
|
||||
}
|
||||
}
|
||||
|
||||
async doUpdate() {
|
||||
const changedValues = this.collectCheckedValues();
|
||||
if (this.initialValues.join(',') === changedValues.join(',')) return;
|
||||
if (!this.updateUrl) this.updateUiList(changedValues);
|
||||
if (this.updateUrl) await this.updateToBackend(changedValues);
|
||||
this.initialValues = changedValues;
|
||||
}
|
||||
|
||||
async onChange() {
|
||||
if (this.selectionMode === 'single') {
|
||||
await this.doUpdate();
|
||||
fomanticQuery(this.elDropdown).dropdown('hide');
|
||||
}
|
||||
}
|
||||
|
||||
async onItemClick(elItem: HTMLElement, e: Event) {
|
||||
e.preventDefault();
|
||||
if (elItem.hasAttribute('data-can-change') && elItem.getAttribute('data-can-change') !== 'true') return;
|
||||
|
||||
if (elItem.matches('.clear-selection')) {
|
||||
queryElems(this.elDropdown, '.menu > .item', (el) => el.classList.remove('checked'));
|
||||
this.elComboValue.value = '';
|
||||
this.onChange();
|
||||
return;
|
||||
}
|
||||
|
||||
const scope = elItem.getAttribute('data-scope');
|
||||
if (scope) {
|
||||
// scoped items could only be checked one at a time
|
||||
const elSelected = this.elDropdown.querySelector<HTMLElement>(`.menu > .item.checked[data-scope="${CSS.escape(scope)}"]`);
|
||||
if (elSelected === elItem) {
|
||||
elItem.classList.toggle('checked');
|
||||
} else {
|
||||
queryElems(this.elDropdown, `.menu > .item[data-scope="${CSS.escape(scope)}"]`, (el) => el.classList.remove('checked'));
|
||||
elItem.classList.toggle('checked', true);
|
||||
}
|
||||
} else {
|
||||
if (this.selectionMode === 'multiple') {
|
||||
elItem.classList.toggle('checked');
|
||||
} else {
|
||||
queryElems(this.elDropdown, `.menu > .item.checked`, (el) => el.classList.remove('checked'));
|
||||
elItem.classList.toggle('checked', true);
|
||||
}
|
||||
}
|
||||
this.elComboValue.value = this.collectCheckedValues().join(',');
|
||||
this.onChange();
|
||||
}
|
||||
|
||||
async onHide() {
|
||||
if (this.selectionMode === 'multiple') this.doUpdate();
|
||||
}
|
||||
|
||||
init() {
|
||||
// init the checked items from initial value
|
||||
if (this.elComboValue.value && this.elComboValue.value !== '0' && !queryElems(this.elDropdown, `.menu > .item.checked`).length) {
|
||||
const values = this.elComboValue.value.split(',');
|
||||
for (const value of values) {
|
||||
const elItem = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
|
||||
elItem?.classList.add('checked');
|
||||
}
|
||||
if (this.elList && this.elList.getAttribute('data-combo-list-inited') !== 'true') {
|
||||
this.updateUiList(values);
|
||||
}
|
||||
}
|
||||
this.initialValues = this.collectCheckedValues();
|
||||
|
||||
addDelegatedEventListener(this.elDropdown, 'click', '.item', (el, e) => this.onItemClick(el, e));
|
||||
|
||||
fomanticQuery(this.elDropdown).dropdown('setting', {
|
||||
action: 'nothing', // do not hide the menu if user presses Enter
|
||||
fullTextSearch: 'exact',
|
||||
hideDividers: 'empty',
|
||||
onHide: () => this.onHide(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
A sidebar combo (dropdown+list) is like this:
|
||||
|
||||
```html
|
||||
<div class="issue-sidebar-combo" data-selection-mode="..." data-update-url="...">
|
||||
<input class="combo-value" name="..." type="hidden" value="...">
|
||||
<div class="ui dropdown">
|
||||
<div class="menu">
|
||||
<div class="item clear-selection">clear</div>
|
||||
<div class="item" data-value="..." data-scope="...">
|
||||
<span class="item-check-mark">...</span>
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui list">
|
||||
<span class="item empty-list">no item</span>
|
||||
<span class="item">...</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
When the selected items change, the `combo-value` input will be updated.
|
||||
If there is `data-update-url`, it also calls backend to attach/detach the changed items.
|
||||
|
||||
Also, the changed items will be synchronized to the `ui list` items.
|
||||
The menu items must have correct `href`, otherwise the links of synchronized (cloned) items would be wrong.
|
||||
|
||||
The `ui list` is optional, so a single dropdown can also work, to select items and update them to backend.
|
||||
|
||||
Synchronization logic:
|
||||
* On page load:
|
||||
* If the dropdown menu contains checked items, there will be no synchronization.
|
||||
In this case, it's assumed that the dropdown menu is already in sync with the list.
|
||||
* If the dropdown menu doesn't contain checked items, it will use dropdown's value to mark the selected items as checked.
|
||||
And the selected (checked) items will be synchronized to the list.
|
||||
Dropdown's value should be empty if the there is no dropdown item but a pre-defined list item need to be displayed.
|
||||
* On dropdown selection change:
|
||||
* The selected items will be synchronized to the list after the dropdown is hidden
|
||||
|
||||
The items with the same data-scope only allow one selected at a time.
|
||||
|
||||
The dropdown selection could work in 2 modes:
|
||||
* single: only one item could be selected, it updates immediately when the item is selected.
|
||||
* multiple: multiple items could be selected, it defers the update until the dropdown is hidden.
|
||||
|
||||
When using "scrolling menu", the items must be in the same level,
|
||||
otherwise keyboard (ArrowUp/ArrowDown/Enter) won't work.
|
||||
@@ -0,0 +1,119 @@
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {queryElems, toggleElem} from '../utils/dom.ts';
|
||||
import {IssueSidebarComboList} from './repo-issue-sidebar-combolist.ts';
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {parseIssuePageInfo} from '../utils.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {showTemporaryTooltip} from '../modules/tippy.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
function initRepoIssueBranchSelector(elSidebar: HTMLElement) {
|
||||
// TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl"
|
||||
const elSelectBranch = elSidebar.querySelector('.ui.dropdown.select-branch.branch-selector-dropdown');
|
||||
if (!elSelectBranch) return;
|
||||
const urlUpdateIssueRef = elSelectBranch.getAttribute('data-url-update-issueref');
|
||||
const elBranchMenu = elSelectBranch.querySelector('.reference-list-menu')!;
|
||||
queryElems(elBranchMenu, '.item:not(.no-select)', (el) => el.addEventListener('click', async function (e) {
|
||||
e.preventDefault();
|
||||
const selectedValue = this.getAttribute('data-id')!; // eg: "refs/heads/my-branch"
|
||||
const selectedText = this.getAttribute('data-name'); // eg: "my-branch"
|
||||
if (urlUpdateIssueRef) {
|
||||
// for existing issue, send request to update issue ref, and reload page
|
||||
try {
|
||||
await POST(urlUpdateIssueRef, {data: new URLSearchParams({ref: selectedValue})});
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
// for new issue, only update UI&form, do not send request/reload
|
||||
const selectedHiddenSelector = this.getAttribute('data-id-selector')!;
|
||||
document.querySelector<HTMLInputElement>(selectedHiddenSelector)!.value = selectedValue;
|
||||
elSelectBranch.querySelector('.text-branch-name')!.textContent = selectedText;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function initRepoIssueDue(elSidebar: HTMLElement) {
|
||||
const form = elSidebar.querySelector<HTMLFormElement>('.issue-due-form');
|
||||
if (!form) return;
|
||||
const deadline = form.querySelector<HTMLInputElement>('input[name=deadline]')!;
|
||||
elSidebar.querySelector('.issue-due-edit')?.addEventListener('click', () => {
|
||||
toggleElem(form);
|
||||
});
|
||||
elSidebar.querySelector('.issue-due-remove')?.addEventListener('click', () => {
|
||||
deadline.value = '';
|
||||
form.dispatchEvent(new Event('submit', {cancelable: true, bubbles: true}));
|
||||
});
|
||||
}
|
||||
|
||||
export function initRepoIssueSidebarDependency(elSidebar: HTMLElement) {
|
||||
const elDropdown = elSidebar.querySelector('#new-dependency-drop-list');
|
||||
if (!elDropdown) return;
|
||||
|
||||
const issuePageInfo = parseIssuePageInfo();
|
||||
const crossRepoSearch = elDropdown.getAttribute('data-issue-cross-repo-search');
|
||||
let issueSearchUrl = `${issuePageInfo.repoLink}/issues/search?q={query}&type=${issuePageInfo.issueDependencySearchType}`;
|
||||
if (crossRepoSearch === 'true') {
|
||||
issueSearchUrl = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${issuePageInfo.repoId}&type=${issuePageInfo.issueDependencySearchType}`;
|
||||
}
|
||||
fomanticQuery(elDropdown).dropdown({
|
||||
fullTextSearch: true,
|
||||
apiSettings: {
|
||||
cache: false,
|
||||
rawResponse: true,
|
||||
url: issueSearchUrl,
|
||||
onResponse(response: any) {
|
||||
const filteredResponse = {success: true, results: [] as Array<Record<string, any>>};
|
||||
const currIssueId = elDropdown.getAttribute('data-issue-id');
|
||||
// Parse the response from the api to work with our dropdown
|
||||
for (const issue of response) {
|
||||
// Don't list current issue in the dependency list.
|
||||
if (String(issue.id) === currIssueId) continue;
|
||||
filteredResponse.results.push({
|
||||
value: issue.id,
|
||||
name: html`<div class="gt-ellipsis">#${issue.number} ${issue.title}</div><div class="text small tw-break-anywhere">${issue.repository.full_name}</div>`,
|
||||
});
|
||||
}
|
||||
return filteredResponse;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function initRepoPullRequestAllowMaintainerEdit(elSidebar: HTMLElement) {
|
||||
const wrapper = elSidebar.querySelector('#allow-edits-from-maintainers')!;
|
||||
if (!wrapper) return;
|
||||
const checkbox = wrapper.querySelector<HTMLInputElement>('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: String(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 initRepoIssueSidebar() {
|
||||
registerGlobalInitFunc('initRepoIssueSidebar', (elSidebar) => {
|
||||
initRepoIssueBranchSelector(elSidebar);
|
||||
initRepoIssueDue(elSidebar);
|
||||
initRepoIssueSidebarDependency(elSidebar);
|
||||
initRepoPullRequestAllowMaintainerEdit(elSidebar);
|
||||
// init the combo list: a dropdown for selecting items, and a list for showing selected items and related actions
|
||||
queryElems(elSidebar, '.issue-sidebar-combo', (el) => new IssueSidebarComboList(el).init());
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {htmlEscape} from '../utils/html.ts';
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {
|
||||
addDelegatedEventListener,
|
||||
createElementFromHTML,
|
||||
hideElem,
|
||||
queryElems,
|
||||
showElem,
|
||||
toggleElem,
|
||||
} from '../utils/dom.ts';
|
||||
import {setFileFolding} from './file-fold.ts';
|
||||
import {ComboMarkdownEditor, getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
|
||||
import {toAbsoluteUrl} from '../utils.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
function initRepoIssueLabelFilter(elDropdown: HTMLElement) {
|
||||
const url = new URL(window.location.href);
|
||||
const showArchivedLabels = url.searchParams.get('archived_labels') === 'true';
|
||||
const queryLabels = url.searchParams.get('labels') || '';
|
||||
const selectedLabelIds = new Set<string>();
|
||||
for (const id of queryLabels ? queryLabels.split(',') : []) {
|
||||
selectedLabelIds.add(`${Math.abs(parseInt(id))}`); // "labels" contains negative ids, which are excluded
|
||||
}
|
||||
|
||||
const excludeLabel = (e: MouseEvent | KeyboardEvent, item: Element) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const labelId = item.getAttribute('data-label-id')!;
|
||||
let labelIds: string[] = queryLabels ? queryLabels.split(',') : [];
|
||||
labelIds = labelIds.filter((id) => Math.abs(parseInt(id)) !== Math.abs(parseInt(labelId)));
|
||||
labelIds.push(`-${labelId}`);
|
||||
url.searchParams.set('labels', labelIds.join(','));
|
||||
window.location.assign(url);
|
||||
};
|
||||
|
||||
// alt(or option) + click to exclude label
|
||||
queryElems(elDropdown, '.label-filter-query-item', (el) => {
|
||||
el.addEventListener('click', (e: MouseEvent) => {
|
||||
if (e.altKey) excludeLabel(e, el);
|
||||
});
|
||||
});
|
||||
// alt(or option) + enter to exclude selected label
|
||||
elDropdown.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.isComposing) return;
|
||||
if (e.altKey && e.key === 'Enter') {
|
||||
const selectedItem = elDropdown.querySelector('.label-filter-query-item.selected');
|
||||
if (selectedItem) excludeLabel(e, selectedItem);
|
||||
}
|
||||
});
|
||||
// no "labels" query parameter means "all issues"
|
||||
elDropdown.querySelector('.label-filter-query-default')!.classList.toggle('selected', queryLabels === '');
|
||||
// "labels=0" query parameter means "issues without label"
|
||||
elDropdown.querySelector('.label-filter-query-not-set')!.classList.toggle('selected', queryLabels === '0');
|
||||
|
||||
// prepare to process "archived" labels
|
||||
const elShowArchivedLabel = elDropdown.querySelector('.label-filter-archived-toggle');
|
||||
if (!elShowArchivedLabel) return;
|
||||
const elShowArchivedInput = elShowArchivedLabel.querySelector<HTMLInputElement>('input')!;
|
||||
elShowArchivedInput.checked = showArchivedLabels;
|
||||
const archivedLabels = elDropdown.querySelectorAll('.item[data-is-archived]');
|
||||
// if no archived labels, hide the toggle and return
|
||||
if (!archivedLabels.length) {
|
||||
hideElem(elShowArchivedLabel);
|
||||
return;
|
||||
}
|
||||
|
||||
// show the archived labels if the toggle is checked or the label is selected
|
||||
for (const label of archivedLabels) {
|
||||
toggleElem(label, showArchivedLabels || selectedLabelIds.has(label.getAttribute('data-label-id')!));
|
||||
}
|
||||
// update the url when the toggle is changed and reload
|
||||
elShowArchivedInput.addEventListener('input', () => {
|
||||
if (elShowArchivedInput.checked) {
|
||||
url.searchParams.set('archived_labels', 'true');
|
||||
} else {
|
||||
url.searchParams.delete('archived_labels');
|
||||
}
|
||||
window.location.assign(url);
|
||||
});
|
||||
}
|
||||
|
||||
export function initRepoIssueFilterItemLabel() {
|
||||
// the "label-filter" is used in 2 templates: projects/view, issue/filter_list (issue list page including the milestone page)
|
||||
queryElems(document, '.ui.dropdown.label-filter', initRepoIssueLabelFilter);
|
||||
}
|
||||
|
||||
export function initRepoIssueCommentDelete() {
|
||||
// Delete comment
|
||||
document.addEventListener('click', async (e) => {
|
||||
if (!(e.target as HTMLElement).matches('.delete-comment')) return;
|
||||
e.preventDefault();
|
||||
|
||||
const deleteButton = e.target as HTMLElement;
|
||||
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', String(num));
|
||||
counter.textContent = String(num);
|
||||
}
|
||||
|
||||
document.querySelector(`#${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');
|
||||
|
||||
// the conversation holder could appear either on the "Conversation" page, or the "Files Changed" page
|
||||
// on the Conversation page, there is no parent "tr", so no need to do anything for "add-code-comment"
|
||||
if (lineType) {
|
||||
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 initRepoIssueCodeCommentCancel() {
|
||||
// Cancel inline code comment
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!(e.target as HTMLElement).matches('.cancel-code-comment')) return;
|
||||
|
||||
const form = (e.target as HTMLElement).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 initRepoIssueComments() {
|
||||
if (!document.querySelector('.repository.view.issue .timeline')) return;
|
||||
|
||||
document.addEventListener('click', (e: Event) => {
|
||||
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 as HTMLElement).closest(`#${urlTargetId}`)) {
|
||||
// if the user clicks outside the comment, remove the hash from the url
|
||||
// use empty hash and state to avoid scrolling
|
||||
window.location.hash = ' ';
|
||||
window.history.pushState(null, '', ' ');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleReply(el: HTMLElement) {
|
||||
const form = el.closest('.comment-code-cloud')!.querySelector('.comment-form')!;
|
||||
const textarea = form.querySelector('textarea');
|
||||
|
||||
hideElem(el);
|
||||
showElem(form);
|
||||
const editor = getComboMarkdownEditor(textarea) ?? await initComboMarkdownEditor(form.querySelector('.combo-markdown-editor')!);
|
||||
editor.focus();
|
||||
return editor;
|
||||
}
|
||||
|
||||
export function initRepoPullRequestReview() {
|
||||
if (window.location.hash && window.location.hash.startsWith('#issuecomment-')) {
|
||||
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?.startsWith('code-comments-')) {
|
||||
const id = groupID.slice(14);
|
||||
const ancestorDiffBox = commentDiv.closest<HTMLElement>('.diff-file-box');
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
// 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';
|
||||
// wait for a while because some elements (eg: image, editor, etc.) may change the viewport's height.
|
||||
setTimeout(() => commentDiv.scrollIntoView({block: 'start'}), 100);
|
||||
}
|
||||
}
|
||||
|
||||
addDelegatedEventListener(document, 'click', '.show-outdated', (el, e) => {
|
||||
e.preventDefault();
|
||||
const id = el.getAttribute('data-comment');
|
||||
hideElem(el);
|
||||
showElem(`#code-comments-${id}`);
|
||||
showElem(`#code-preview-${id}`);
|
||||
showElem(`#hide-outdated-${id}`);
|
||||
});
|
||||
|
||||
addDelegatedEventListener(document, 'click', '.hide-outdated', (el, e) => {
|
||||
e.preventDefault();
|
||||
const id = el.getAttribute('data-comment');
|
||||
hideElem(el);
|
||||
hideElem(`#code-comments-${id}`);
|
||||
hideElem(`#code-preview-${id}`);
|
||||
showElem(`#show-outdated-${id}`);
|
||||
});
|
||||
|
||||
addDelegatedEventListener(document, 'click', 'button.comment-form-reply', (el, e) => {
|
||||
e.preventDefault();
|
||||
handleReply(el);
|
||||
});
|
||||
|
||||
// The following part is only for diff views
|
||||
if (!document.querySelector('.repository.pull.diff')) return;
|
||||
|
||||
const elReviewBtn = document.querySelector('.js-btn-review');
|
||||
const elReviewPanel = document.querySelector('.review-box-panel.tippy-target');
|
||||
if (elReviewBtn && elReviewPanel) {
|
||||
const tippy = createTippy(elReviewBtn, {
|
||||
content: elReviewPanel,
|
||||
theme: 'default',
|
||||
placement: 'bottom',
|
||||
trigger: 'click',
|
||||
maxWidth: 'none',
|
||||
interactive: true,
|
||||
hideOnClick: true,
|
||||
});
|
||||
elReviewPanel.querySelector('.close')!.addEventListener('click', () => tippy.hide());
|
||||
}
|
||||
|
||||
addDelegatedEventListener(document, 'click', '.add-code-comment', async (el, e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const isSplit = el.closest('.code-diff')?.classList.contains('code-diff-split');
|
||||
const side = el.getAttribute('data-side')!;
|
||||
const idx = el.getAttribute('data-idx')!;
|
||||
const path = el.closest('[data-path]')?.getAttribute('data-path');
|
||||
const tr = el.closest('tr')!;
|
||||
const lineType = tr.getAttribute('data-line-type')!;
|
||||
|
||||
let ntr = tr.nextElementSibling;
|
||||
if (!ntr?.classList.contains('add-comment')) {
|
||||
ntr = createElementFromHTML(`
|
||||
<tr class="add-comment" data-line-type="${htmlEscape(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.querySelector(`.add-comment-${side}`)!;
|
||||
const commentCloud = td.querySelector('.comment-code-cloud');
|
||||
if (!commentCloud && !ntr.querySelector('button[name="pending_review"]')) {
|
||||
const response = await GET(el.closest('[data-new-comment-url]')?.getAttribute('data-new-comment-url') ?? '');
|
||||
td.innerHTML = await response.text();
|
||||
td.querySelector<HTMLInputElement>("input[name='line']")!.value = idx;
|
||||
td.querySelector<HTMLInputElement>("input[name='side']")!.value = (side === 'left' ? 'previous' : 'proposed');
|
||||
td.querySelector<HTMLInputElement>("input[name='path']")!.value = String(path);
|
||||
const editor = await initComboMarkdownEditor(td.querySelector<HTMLElement>('.combo-markdown-editor')!);
|
||||
editor.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function initRepoIssueReferenceIssue() {
|
||||
const elDropdown = document.querySelector('.issue_reference_repository_search');
|
||||
if (!elDropdown) return;
|
||||
const form = elDropdown.closest('form')!;
|
||||
fomanticQuery(elDropdown).dropdown({
|
||||
fullTextSearch: true,
|
||||
apiSettings: {
|
||||
cache: false,
|
||||
rawResponse: true,
|
||||
url: `${appSubUrl}/repo/search?q={query}&limit=20`,
|
||||
onResponse(response: any) {
|
||||
const filteredResponse = {success: true, results: [] as Array<Record<string, any>>};
|
||||
for (const repo of response.data) {
|
||||
filteredResponse.results.push({
|
||||
name: htmlEscape(repo.repository.full_name),
|
||||
value: repo.repository.full_name,
|
||||
});
|
||||
}
|
||||
return filteredResponse;
|
||||
},
|
||||
},
|
||||
onChange(_value: string, _text: string, _$choice: any) {
|
||||
form.setAttribute('action', `${appSubUrl}/${_text}/issues/new`);
|
||||
},
|
||||
});
|
||||
|
||||
// Reference issue
|
||||
addDelegatedEventListener(document, 'click', '.reference-issue', (el, e) => {
|
||||
e.preventDefault();
|
||||
const target = el.getAttribute('data-target');
|
||||
const content = document.querySelector(`#${target}`)?.textContent ?? '';
|
||||
const poster = el.getAttribute('data-poster-username');
|
||||
const reference = toAbsoluteUrl(el.getAttribute('data-reference')!);
|
||||
const modalSelector = el.getAttribute('data-modal')!;
|
||||
const modal = document.querySelector(modalSelector)!;
|
||||
const textarea = modal.querySelector<HTMLTextAreaElement>('textarea[name="content"]')!;
|
||||
textarea.value = `${content}\n\n_Originally posted by @${poster} in ${reference}_`;
|
||||
showFomanticModal(modal);
|
||||
});
|
||||
}
|
||||
|
||||
export function initRepoIssueWipNewTitle() {
|
||||
// Toggle WIP for new PR
|
||||
queryElems(document, '.title_wip_desc > a', (el) => el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const wipPrefixes = JSON.parse(el.closest('.title_wip_desc')!.getAttribute('data-wip-prefixes')!);
|
||||
const titleInput = document.querySelector<HTMLInputElement>('#issue_title')!;
|
||||
const titleValue = titleInput.value;
|
||||
for (const prefix of wipPrefixes) {
|
||||
if (titleValue.startsWith(prefix.toUpperCase())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
titleInput.value = `${wipPrefixes[0]} ${titleValue}`;
|
||||
}));
|
||||
}
|
||||
|
||||
export function initRepoIssueWipToggle() {
|
||||
// Toggle WIP for existing PR
|
||||
registerGlobalInitFunc('initPullRequestWipToggle', (toggleWip) => toggleWip.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const title = toggleWip.getAttribute('data-title');
|
||||
const wipPrefix = toggleWip.getAttribute('data-wip-prefix')!;
|
||||
const updateUrl = toggleWip.getAttribute('data-update-url')!;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`);
|
||||
const response = await POST(updateUrl, {data: params});
|
||||
if (!response.ok) {
|
||||
showErrorToast(`Failed to toggle 'work in progress' status`);
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
}));
|
||||
}
|
||||
|
||||
export function initRepoIssueTitleEdit() {
|
||||
const issueTitleDisplay = document.querySelector('#issue-title-display')!;
|
||||
const issueTitleEditor = document.querySelector<HTMLFormElement>('#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')!;
|
||||
issueTitleEditor.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
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: String(newTargetBranch)})});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to update PR target branch: ${resp.statusText}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
ignoreAreYouSure(issueTitleEditor);
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showErrorToast(errorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function initRepoIssueBranchSelect() {
|
||||
document.querySelector<HTMLElement>('#branch-select')?.addEventListener('click', (e: Event) => {
|
||||
const el = (e.target as HTMLElement).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);
|
||||
});
|
||||
}
|
||||
|
||||
async function initSingleCommentEditor(commentForm: HTMLFormElement) {
|
||||
// pages:
|
||||
// * normal new issue/pr page: no status-button, no comment-button (there is only a normal submit button which can submit empty content)
|
||||
// * issue/pr view page: with comment form, has status-button and comment-button
|
||||
const editor = await initComboMarkdownEditor(commentForm.querySelector('.combo-markdown-editor')!);
|
||||
const statusButton = document.querySelector<HTMLButtonElement>('#status-button');
|
||||
const commentButton = document.querySelector<HTMLButtonElement>('#comment-button');
|
||||
const syncUiState = () => {
|
||||
const editorText = editor.value().trim(), isUploading = editor.isUploading();
|
||||
if (statusButton) {
|
||||
const statusText = statusButton.getAttribute(editorText ? 'data-status-and-comment' : 'data-status');
|
||||
statusButton.querySelector<HTMLElement>('.status-button-text')!.textContent = statusText;
|
||||
statusButton.disabled = isUploading;
|
||||
}
|
||||
if (commentButton) {
|
||||
commentButton.disabled = !editorText || isUploading;
|
||||
}
|
||||
};
|
||||
editor.container.addEventListener(ComboMarkdownEditor.EventUploadStateChanged, syncUiState);
|
||||
editor.container.addEventListener(ComboMarkdownEditor.EventEditorContentChanged, syncUiState);
|
||||
syncUiState();
|
||||
}
|
||||
|
||||
function initIssueTemplateCommentEditors(commentForm: HTMLFormElement) {
|
||||
// pages:
|
||||
// * new issue with issue template
|
||||
const comboFields = commentForm.querySelectorAll<HTMLElement>('.combo-editor-dropzone');
|
||||
|
||||
const initCombo = async (elCombo: HTMLElement) => {
|
||||
const fieldTextarea = elCombo.querySelector<HTMLTextAreaElement>('.form-field-real')!;
|
||||
const dropzoneContainer = elCombo.querySelector<HTMLElement>('.form-field-dropzone')!;
|
||||
const markdownEditor = elCombo.querySelector<HTMLElement>('.combo-markdown-editor')!;
|
||||
|
||||
const editor = await initComboMarkdownEditor(markdownEditor);
|
||||
editor.container.addEventListener(ComboMarkdownEditor.EventEditorContentChanged, () => fieldTextarea.value = editor.value());
|
||||
|
||||
fieldTextarea.addEventListener('focus', async () => {
|
||||
// deactivate all markdown editors
|
||||
showElem(commentForm.querySelectorAll('.combo-editor-dropzone .form-field-real'));
|
||||
hideElem(commentForm.querySelectorAll('.combo-editor-dropzone .combo-markdown-editor'));
|
||||
queryElems(commentForm, '.combo-editor-dropzone .form-field-dropzone', (dropzoneContainer) => {
|
||||
// if "form-field-dropzone" exists, then "dropzone" must also exist
|
||||
const dropzone = dropzoneContainer.querySelector<HTMLElement>('.dropzone')!.dropzone;
|
||||
const hasUploadedFiles = dropzone.files.length !== 0;
|
||||
toggleElem(dropzoneContainer, hasUploadedFiles);
|
||||
});
|
||||
|
||||
// activate this markdown editor
|
||||
hideElem(fieldTextarea);
|
||||
showElem(markdownEditor);
|
||||
showElem(dropzoneContainer);
|
||||
|
||||
await editor.switchToUserPreference();
|
||||
editor.focus();
|
||||
});
|
||||
};
|
||||
|
||||
for (const el of comboFields) {
|
||||
initCombo(el);
|
||||
}
|
||||
}
|
||||
|
||||
export function initRepoCommentFormAndSidebar() {
|
||||
initRepoIssueSidebar();
|
||||
|
||||
const commentForm = document.querySelector<HTMLFormElement>('.comment.form');
|
||||
if (!commentForm) return;
|
||||
|
||||
if (commentForm.querySelector('.field.combo-editor-dropzone')) {
|
||||
// at the moment, if a form has multiple combo-markdown-editors, it must be an issue template form
|
||||
initIssueTemplateCommentEditors(commentForm);
|
||||
} else if (commentForm.querySelector('.combo-markdown-editor')) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {
|
||||
initRepoCommentFormAndSidebar,
|
||||
initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete,
|
||||
initRepoIssueComments, initRepoIssueReferenceIssue,
|
||||
initRepoIssueTitleEdit, initRepoIssueWipNewTitle, initRepoIssueWipToggle,
|
||||
} from './repo-issue.ts';
|
||||
import {initUnicodeEscapeButton} from './repo-unicode-escape.ts';
|
||||
import {initRepoCloneButtons} from './repo-common.ts';
|
||||
import {initCitationFileCopyContent} from './citation.ts';
|
||||
import {initCompLabelEdit} from './comp/LabelEdit.ts';
|
||||
import {initCompReactionSelector} from './comp/ReactionSelector.ts';
|
||||
import {initRepoSettings} from './repo-settings.ts';
|
||||
import {hideElem, queryElemChildren, queryElems, showElem} from '../utils/dom.ts';
|
||||
import {initRepoIssueCommentEdit} from './repo-issue-edit.ts';
|
||||
import {initRepoMilestone} from './repo-milestone.ts';
|
||||
import {initRepoNew} from './repo-new.ts';
|
||||
import {createApp} from 'vue';
|
||||
import RepoBranchTagSelector from '../components/RepoBranchTagSelector.vue';
|
||||
import {initRepoPullMergeBox, initRepoPullRequestUpdate} from './repo-issue-pull.ts';
|
||||
|
||||
function initRepoBranchTagSelector() {
|
||||
registerGlobalInitFunc('initRepoBranchTagSelector', async (elRoot: HTMLInputElement) => {
|
||||
createApp(RepoBranchTagSelector, {elRoot}).mount(elRoot);
|
||||
});
|
||||
}
|
||||
|
||||
export function initBranchSelectorTabs() {
|
||||
const elSelectBranches = document.querySelectorAll('.ui.dropdown.select-branch');
|
||||
for (const elSelectBranch of elSelectBranches) {
|
||||
queryElems(elSelectBranch, '.reference.column', (el) => el.addEventListener('click', () => {
|
||||
hideElem(elSelectBranch.querySelectorAll('.scrolling.reference-list-menu'));
|
||||
showElem(el.getAttribute('data-target')!);
|
||||
queryElemChildren(el.parentNode!, '.branch-tag-item', (el) => el.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export function initRepository() {
|
||||
registerGlobalInitFunc('initRepoPullMergeBox', initRepoPullMergeBox);
|
||||
registerGlobalInitFunc('initRepoPullRequestUpdate', initRepoPullRequestUpdate);
|
||||
|
||||
const pageContent = document.querySelector('.page-content.repository');
|
||||
if (!pageContent) return;
|
||||
|
||||
initRepoBranchTagSelector();
|
||||
initRepoCommentFormAndSidebar();
|
||||
|
||||
// Labels
|
||||
initCompLabelEdit('.page-content.repository.labels');
|
||||
initRepoMilestone();
|
||||
initRepoNew();
|
||||
|
||||
initRepoCloneButtons();
|
||||
initCitationFileCopyContent();
|
||||
initRepoSettings();
|
||||
initRepoIssueWipNewTitle();
|
||||
|
||||
// Issues
|
||||
if (pageContent.matches('.page-content.repository.view.issue')) {
|
||||
initRepoIssueCommentEdit();
|
||||
|
||||
initRepoIssueBranchSelect();
|
||||
initRepoIssueTitleEdit();
|
||||
initRepoIssueWipToggle();
|
||||
initRepoIssueComments();
|
||||
|
||||
initRepoIssueReferenceIssue();
|
||||
|
||||
initRepoIssueCommentDelete();
|
||||
initRepoIssueCodeCommentCancel();
|
||||
initCompReactionSelector();
|
||||
}
|
||||
|
||||
initUnicodeEscapeButton();
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {hideElem, showElem} from '../utils/dom.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
|
||||
export function initRepoMigrationStatusChecker() {
|
||||
const repoMigrating = document.querySelector('#repo_migrating');
|
||||
if (!repoMigrating) return;
|
||||
|
||||
document.querySelector<HTMLButtonElement>('#repo_migrating_retry')?.addEventListener('click', doMigrationRetry);
|
||||
|
||||
const repoLink = repoMigrating.getAttribute('data-migrating-repo-link');
|
||||
|
||||
// returns true if the refresh still needs to be called after a while
|
||||
const refresh = async () => {
|
||||
const res = await GET(`${repoLink}/-/migrate/status`);
|
||||
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.querySelector('#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.querySelector('#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: Event) {
|
||||
await POST((e.target as HTMLElement).getAttribute('data-migrating-task-retry-url')!);
|
||||
window.location.reload();
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {hideElem, showElem, toggleElem} from '../utils/dom.ts';
|
||||
import {sanitizeRepoName} from './repo-common.ts';
|
||||
|
||||
const service = document.querySelector<HTMLInputElement>('#service_type');
|
||||
const user = document.querySelector<HTMLInputElement>('#auth_username');
|
||||
const pass = document.querySelector<HTMLInputElement>('#auth_password');
|
||||
const token = document.querySelector<HTMLInputElement>('#auth_token');
|
||||
const mirror = document.querySelector<HTMLInputElement>('#mirror');
|
||||
const lfs = document.querySelector<HTMLInputElement>('#lfs');
|
||||
const lfsSettings = document.querySelector<HTMLElement>('#lfs_settings')!;
|
||||
const lfsEndpoint = document.querySelector<HTMLElement>('#lfs_endpoint')!;
|
||||
const items = document.querySelectorAll<HTMLInputElement>('#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.querySelector('#lfs_settings_show')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showElem(lfsEndpoint);
|
||||
});
|
||||
lfs?.addEventListener('change', setLFSSettingsVisibility);
|
||||
|
||||
const elCloneAddr = document.querySelector<HTMLInputElement>('#clone_addr');
|
||||
const elRepoName = document.querySelector<HTMLInputElement>('#repo_name');
|
||||
if (elCloneAddr && elRepoName) {
|
||||
let repoNameChanged = false;
|
||||
elRepoName.addEventListener('input', () => {repoNameChanged = true});
|
||||
elCloneAddr.addEventListener('input', () => {
|
||||
if (repoNameChanged) return;
|
||||
let repoNameFromUrl = elCloneAddr.value.split(/[?#]/)[0];
|
||||
const parts = /^(.*\/)?((.+?)\/?)$/.exec(repoNameFromUrl);
|
||||
if (!parts || parts.length < 4) {
|
||||
elRepoName.value = '';
|
||||
return;
|
||||
}
|
||||
repoNameFromUrl = parts[3].split(/[?#]/)[0];
|
||||
elRepoName.value = sanitizeRepoName(repoNameFromUrl);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function checkAuth() {
|
||||
if (!service) return;
|
||||
const serviceType = Number(service.value);
|
||||
|
||||
checkItems(serviceType !== 1);
|
||||
}
|
||||
|
||||
function checkItems(tokenAuth: boolean) {
|
||||
let enableItems: boolean;
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function initRepoMilestone() {
|
||||
const page = document.querySelector('.repository.new.milestone');
|
||||
if (!page) return;
|
||||
|
||||
const deadline = page.querySelector<HTMLInputElement>('form input[name=deadline]')!;
|
||||
document.querySelector('#milestone-clear-deadline')!.addEventListener('click', () => {
|
||||
deadline.value = '';
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {hideElem, querySingleVisibleElem, showElem, toggleElem} from '../utils/dom.ts';
|
||||
import {htmlEscape} from '../utils/html.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {sanitizeRepoName} from './repo-common.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
function initRepoNewTemplateSearch(form: HTMLFormElement) {
|
||||
const elSubmitButton = querySingleVisibleElem<HTMLInputElement>(form, '.ui.primary.button')!;
|
||||
const elCreateRepoErrorMessage = form.querySelector('#create-repo-error-message')!;
|
||||
const elRepoOwnerDropdown = form.querySelector('#repo_owner_dropdown')!;
|
||||
const elRepoTemplateDropdown = form.querySelector<HTMLInputElement>('#repo_template_search')!;
|
||||
const inputRepoTemplate = form.querySelector<HTMLInputElement>('#repo_template')!;
|
||||
const elTemplateUnits = form.querySelector('#template_units')!;
|
||||
const elNonTemplate = form.querySelector('#non_template')!;
|
||||
const checkTemplate = function () {
|
||||
const hasSelectedTemplate = inputRepoTemplate.value !== '' && inputRepoTemplate.value !== '0';
|
||||
toggleElem(elTemplateUnits, hasSelectedTemplate);
|
||||
toggleElem(elNonTemplate, !hasSelectedTemplate);
|
||||
};
|
||||
inputRepoTemplate.addEventListener('change', checkTemplate);
|
||||
checkTemplate();
|
||||
|
||||
const $repoOwnerDropdown = fomanticQuery(elRepoOwnerDropdown);
|
||||
const $repoTemplateDropdown = fomanticQuery(elRepoTemplateDropdown);
|
||||
const onChangeOwner = function () {
|
||||
const ownerId = $repoOwnerDropdown.dropdown('get value');
|
||||
const $ownerItem = $repoOwnerDropdown.dropdown('get item', ownerId);
|
||||
hideElem(elCreateRepoErrorMessage);
|
||||
elSubmitButton.disabled = false;
|
||||
if ($ownerItem?.length) {
|
||||
const elOwnerItem = $ownerItem[0];
|
||||
elCreateRepoErrorMessage.textContent = elOwnerItem.getAttribute('data-create-repo-disallowed-prompt') ?? '';
|
||||
const hasError = Boolean(elCreateRepoErrorMessage.textContent);
|
||||
toggleElem(elCreateRepoErrorMessage, hasError);
|
||||
elSubmitButton.disabled = hasError;
|
||||
}
|
||||
$repoTemplateDropdown.dropdown('setting', {
|
||||
apiSettings: {
|
||||
url: `${appSubUrl}/repo/search?q={query}&template=true&priority_owner_id=${ownerId}`,
|
||||
onResponse(response: any) {
|
||||
const results = [];
|
||||
results.push({name: '', value: ''}); // empty item means not using template
|
||||
for (const tmplRepo of response.data) {
|
||||
results.push({
|
||||
name: htmlEscape(tmplRepo.repository.full_name),
|
||||
value: String(tmplRepo.repository.id),
|
||||
});
|
||||
}
|
||||
$repoTemplateDropdown.fomanticExt.onResponseKeepSelectedItem($repoTemplateDropdown, inputRepoTemplate.value);
|
||||
return {results};
|
||||
},
|
||||
cache: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
$repoOwnerDropdown.dropdown('setting', 'onChange', onChangeOwner);
|
||||
onChangeOwner();
|
||||
}
|
||||
|
||||
export function initRepoNew() {
|
||||
const pageContent = document.querySelector('.page-content.repository.new-repo');
|
||||
if (!pageContent) return;
|
||||
|
||||
const form = document.querySelector<HTMLFormElement>('.new-repo-form')!;
|
||||
const inputGitIgnores = form.querySelector<HTMLInputElement>('input[name="gitignores"]')!;
|
||||
const inputLicense = form.querySelector<HTMLInputElement>('input[name="license"]')!;
|
||||
const inputAutoInit = form.querySelector<HTMLInputElement>('input[name="auto_init"]')!;
|
||||
const updateUiAutoInit = () => {
|
||||
inputAutoInit.checked = Boolean(inputGitIgnores.value || inputLicense.value);
|
||||
};
|
||||
inputGitIgnores.addEventListener('change', updateUiAutoInit);
|
||||
inputLicense.addEventListener('change', updateUiAutoInit);
|
||||
updateUiAutoInit();
|
||||
|
||||
const inputRepoName = form.querySelector<HTMLInputElement>('input[name="repo_name"]')!;
|
||||
const inputPrivate = form.querySelector<HTMLInputElement>('input[name="private"]')!;
|
||||
const updateUiRepoName = () => {
|
||||
const helps = form.querySelectorAll(`.help[data-help-for-repo-name]`);
|
||||
hideElem(helps);
|
||||
let help = form.querySelector(`.help[data-help-for-repo-name="${CSS.escape(inputRepoName.value)}"]`);
|
||||
if (!help) help = form.querySelector(`.help[data-help-for-repo-name=""]`)!;
|
||||
showElem(help);
|
||||
const repoNamePreferPrivate: Record<string, boolean> = {'.profile': false, '.profile-private': true};
|
||||
const preferPrivate = repoNamePreferPrivate[inputRepoName.value];
|
||||
// inputPrivate might be disabled because site admin "force private"
|
||||
if (preferPrivate !== undefined && !inputPrivate.closest('.disabled, [disabled]')) {
|
||||
inputPrivate.checked = preferPrivate;
|
||||
}
|
||||
};
|
||||
inputRepoName.addEventListener('input', updateUiRepoName);
|
||||
inputRepoName.addEventListener('change', () => {
|
||||
inputRepoName.value = sanitizeRepoName(inputRepoName.value);
|
||||
updateUiRepoName();
|
||||
});
|
||||
updateUiRepoName();
|
||||
|
||||
initRepoNewTemplateSearch(form);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import {contrastColor} from '../utils/color.ts';
|
||||
import {createSortable} from '../modules/sortable.ts';
|
||||
import {POST, request} from '../modules/fetch.ts';
|
||||
import {hideFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
|
||||
import type {SortableEvent} from 'sortablejs';
|
||||
import {toggleFullScreen} from '../utils.ts';
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {localUserSettings} from '../modules/user-settings.ts';
|
||||
|
||||
function updateIssueCount(card: HTMLElement): void {
|
||||
const parent = card.parentElement!;
|
||||
const count = parent.querySelectorAll('.issue-card').length;
|
||||
parent.querySelector('.project-column-issue-count')!.textContent = String(count);
|
||||
}
|
||||
|
||||
async function moveIssue({item, from, to, oldIndex}: SortableEvent): Promise<void> {
|
||||
const columnCards = to.querySelectorAll('.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);
|
||||
if (oldIndex !== undefined) {
|
||||
from.insertBefore(item, from.children[oldIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function initRepoProjectSortable(): Promise<void> {
|
||||
// the HTML layout is: #project-board.board > .project-column .cards > .issue-card
|
||||
const mainBoard = document.querySelector<HTMLElement>('#project-board')!;
|
||||
let boardColumns = mainBoard.querySelectorAll<HTMLElement>('.project-column');
|
||||
createSortable(mainBoard, {
|
||||
group: 'project-column',
|
||||
draggable: '.project-column',
|
||||
handle: '.project-column-header',
|
||||
delayOnTouchOnly: true,
|
||||
delay: 500,
|
||||
onSort: async () => { // eslint-disable-line @typescript-eslint/no-misused-promises
|
||||
boardColumns = mainBoard.querySelectorAll<HTMLElement>('.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.querySelector<HTMLElement>('.cards')!;
|
||||
createSortable(boardCardList, {
|
||||
group: 'shared',
|
||||
onAdd: moveIssue, // eslint-disable-line @typescript-eslint/no-misused-promises
|
||||
onUpdate: moveIssue, // eslint-disable-line @typescript-eslint/no-misused-promises
|
||||
delayOnTouchOnly: true,
|
||||
delay: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
|
||||
const elModal = document.querySelector<HTMLElement>('.ui.modal#project-column-modal-edit')!;
|
||||
const elForm = elModal.querySelector<HTMLFormElement>('form')!;
|
||||
|
||||
const elColumnId = elForm.querySelector<HTMLInputElement>('input[name="id"]')!;
|
||||
const elColumnTitle = elForm.querySelector<HTMLInputElement>('input[name="title"]')!;
|
||||
const elColumnColor = elForm.querySelector<HTMLInputElement>('input[name="color"]')!;
|
||||
|
||||
const attrDataColumnId = 'data-modal-project-column-id';
|
||||
const attrDataColumnTitle = 'data-modal-project-column-title-input';
|
||||
const attrDataColumnColor = 'data-modal-project-column-color-input';
|
||||
|
||||
// the "new" button is not in project board, so need to query from document
|
||||
queryElems(document, '.show-project-column-modal-edit', (el) => {
|
||||
el.addEventListener('click', () => {
|
||||
elColumnId.value = el.getAttribute(attrDataColumnId)!;
|
||||
elColumnTitle.value = el.getAttribute(attrDataColumnTitle)!;
|
||||
elColumnColor.value = el.getAttribute(attrDataColumnColor)!;
|
||||
elColumnColor.dispatchEvent(new Event('input', {bubbles: true})); // trigger the color picker
|
||||
});
|
||||
});
|
||||
|
||||
elForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const columnId = elColumnId.value;
|
||||
const actionBaseLink = elForm.getAttribute('data-action-base-link');
|
||||
|
||||
const formData = new FormData(elForm);
|
||||
const formLink = columnId ? `${actionBaseLink}/${columnId}` : `${actionBaseLink}/columns/new`;
|
||||
const formMethod = columnId ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
elForm.classList.add('is-loading');
|
||||
await request(formLink, {method: formMethod, data: formData});
|
||||
if (!columnId) {
|
||||
window.location.reload(); // newly added column, need to reload the page
|
||||
return;
|
||||
}
|
||||
|
||||
// update the newly saved column title and color in the project board (to avoid reload)
|
||||
const elEditButton = writableProjectBoard.querySelector<HTMLButtonElement>(`.show-project-column-modal-edit[${attrDataColumnId}="${columnId}"]`)!;
|
||||
elEditButton.setAttribute(attrDataColumnTitle, elColumnTitle.value);
|
||||
elEditButton.setAttribute(attrDataColumnColor, elColumnColor.value);
|
||||
|
||||
const elBoardColumn = writableProjectBoard.querySelector<HTMLElement>(`.project-column[data-id="${columnId}"]`)!;
|
||||
const elBoardColumnTitle = elBoardColumn.querySelector<HTMLElement>(`.project-column-title-text`)!;
|
||||
elBoardColumnTitle.textContent = elColumnTitle.value;
|
||||
if (elColumnColor.value) {
|
||||
const textColor = contrastColor(elColumnColor.value);
|
||||
elBoardColumn.style.setProperty('background', elColumnColor.value, 'important');
|
||||
elBoardColumn.style.setProperty('color', textColor, 'important');
|
||||
queryElemChildren(elBoardColumn, '.divider', (divider: HTMLElement) => divider.style.color = textColor);
|
||||
} else {
|
||||
elBoardColumn.style.removeProperty('background');
|
||||
elBoardColumn.style.removeProperty('color');
|
||||
queryElemChildren(elBoardColumn, '.divider', (divider: HTMLElement) => divider.style.removeProperty('color'));
|
||||
}
|
||||
|
||||
hideFomanticModal(elModal);
|
||||
} finally {
|
||||
elForm.classList.remove('is-loading');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initRepoProjectToggleFullScreen(elProjectsView: HTMLElement): void {
|
||||
const enterFullscreenBtn = document.querySelector('.screen-full');
|
||||
const exitFullscreenBtn = document.querySelector('.screen-normal');
|
||||
if (!enterFullscreenBtn || !exitFullscreenBtn) return;
|
||||
|
||||
const settingKey = 'projects-view-options';
|
||||
type ProjectsViewOptions = {
|
||||
fullScreen: boolean;
|
||||
};
|
||||
const opts = localUserSettings.getJsonObject<ProjectsViewOptions>(settingKey, {fullScreen: false});
|
||||
const toggleFullscreenState = (isFullScreen: boolean) => {
|
||||
toggleFullScreen(elProjectsView, isFullScreen);
|
||||
toggleElem(enterFullscreenBtn, !isFullScreen);
|
||||
toggleElem(exitFullscreenBtn, isFullScreen);
|
||||
|
||||
opts.fullScreen = isFullScreen;
|
||||
localUserSettings.setJsonObject(settingKey, opts);
|
||||
};
|
||||
|
||||
enterFullscreenBtn.addEventListener('click', () => toggleFullscreenState(true));
|
||||
exitFullscreenBtn.addEventListener('click', () => toggleFullscreenState(false));
|
||||
if (opts.fullScreen) {
|
||||
// a temporary solution to remember the full screen state, not perfect,
|
||||
// just make UX better than before, especially for users who need to change the label filter frequently and want to keep full screen mode.
|
||||
toggleFullscreenState(true);
|
||||
}
|
||||
}
|
||||
|
||||
export function initRepoProjectsView(): void {
|
||||
registerGlobalInitFunc('initRepoProjectsView', (elProjectsView) => {
|
||||
initRepoProjectToggleFullScreen(elProjectsView);
|
||||
|
||||
const writableProjectBoard = document.querySelector('#project-board[data-project-board-writable="true"]');
|
||||
if (!writableProjectBoard) return;
|
||||
|
||||
initRepoProjectSortable(); // no await
|
||||
initRepoProjectColumnEdit(writableProjectBoard);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import {guessPreviousReleaseTag} from './repo-release.ts';
|
||||
|
||||
test('guessPreviousReleaseTag', async () => {
|
||||
expect(guessPreviousReleaseTag('v0.9', ['v1.0', 'v1.2', 'v1.4', 'v1.6'])).toBe('');
|
||||
expect(guessPreviousReleaseTag('1.3', ['v1.0', 'v1.2', 'v1.4', 'v1.6'])).toBe('v1.2');
|
||||
expect(guessPreviousReleaseTag('rel/1.3', ['v1.0', 'v1.2', 'v1.4', 'v1.6'])).toBe('v1.2');
|
||||
expect(guessPreviousReleaseTag('v1.3', ['rel/1.0', 'rel/1.2', 'rel/1.4', 'rel/1.6'])).toBe('rel/1.2');
|
||||
expect(guessPreviousReleaseTag('v2.0', ['v1.0', 'v1.2', 'v1.4', 'v1.6'])).toBe('v1.6');
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
|
||||
import {getComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
|
||||
import {hideElem} from '../utils/dom.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {htmlEscape} from '../utils/html.ts';
|
||||
import {compareVersions} from 'compare-versions';
|
||||
|
||||
export function initRepoReleaseNew() {
|
||||
registerGlobalEventFunc('click', 'onReleaseEditAttachmentDelete', (el) => {
|
||||
const uuid = el.getAttribute('data-uuid')!;
|
||||
const id = el.getAttribute('data-id')!;
|
||||
document.querySelector<HTMLInputElement>(`input[name='attachment-del-${uuid}']`)!.value = 'true';
|
||||
hideElem(`#attachment-${id}`);
|
||||
});
|
||||
registerGlobalInitFunc('initReleaseEditForm', (elForm: HTMLFormElement) => {
|
||||
initTagNameEditor(elForm);
|
||||
initGenerateReleaseNotes(elForm);
|
||||
});
|
||||
}
|
||||
|
||||
function getReleaseFormExistingTags(elForm: HTMLFormElement): Array<string> {
|
||||
return JSON.parse(elForm.getAttribute('data-existing-tags')!);
|
||||
}
|
||||
|
||||
function initTagNameEditor(elForm: HTMLFormElement) {
|
||||
const tagNameInput = elForm.querySelector<HTMLInputElement>('input[type=text][name=tag_name]');
|
||||
if (!tagNameInput) return; // only init if tag name input exists (the tag name is editable)
|
||||
|
||||
const existingTags = getReleaseFormExistingTags(elForm);
|
||||
const defaultTagHelperText = elForm.getAttribute('data-tag-helper');
|
||||
const newTagHelperText = elForm.getAttribute('data-tag-helper-new');
|
||||
const existingTagHelperText = elForm.getAttribute('data-tag-helper-existing');
|
||||
|
||||
const hideTargetInput = function(tagNameInput: HTMLInputElement) {
|
||||
const value = tagNameInput.value;
|
||||
const tagHelper = elForm.querySelector('.tag-name-helper')!;
|
||||
// Old behavior: if the tag already exists, hide the target branch selector and show a helper text to indicate the tag already exists.
|
||||
// However, it is not right: when creating from an existing tag (not a draft or release yet), it still needs the "target branch"
|
||||
// So the new logic here: don't hide the target branch selector.
|
||||
if (existingTags.includes(value)) {
|
||||
tagHelper.textContent = existingTagHelperText;
|
||||
} else {
|
||||
tagHelper.textContent = value ? newTagHelperText : defaultTagHelperText;
|
||||
}
|
||||
};
|
||||
hideTargetInput(tagNameInput); // update on page load because the input may have a value
|
||||
tagNameInput.addEventListener('input', (e) => {
|
||||
hideTargetInput(e.target as HTMLInputElement);
|
||||
});
|
||||
}
|
||||
|
||||
export function guessPreviousReleaseTag(tagName: string, existingTags: Array<string>): string {
|
||||
let guessedPreviousTag = '', guessedPreviousVer = '';
|
||||
|
||||
const cleanup = (s: string) => {
|
||||
const pos = s.lastIndexOf('/');
|
||||
if (pos >= 0) s = s.substring(pos + 1);
|
||||
if (s.substring(0, 1).toLowerCase() === 'v') s = s.substring(1);
|
||||
return s;
|
||||
};
|
||||
|
||||
const newVer = cleanup(tagName);
|
||||
for (const s of existingTags) {
|
||||
const existingVer = cleanup(s);
|
||||
try {
|
||||
if (compareVersions(existingVer, newVer) >= 0) continue;
|
||||
if (!guessedPreviousTag || compareVersions(existingVer, guessedPreviousVer) > 0) {
|
||||
guessedPreviousTag = s;
|
||||
guessedPreviousVer = existingVer;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return guessedPreviousTag;
|
||||
}
|
||||
|
||||
function initGenerateReleaseNotes(elForm: HTMLFormElement) {
|
||||
const buttonShowModal = elForm.querySelector<HTMLButtonElement>('.button.generate-release-notes')!;
|
||||
const tagNameInput = elForm.querySelector<HTMLInputElement>('input[name=tag_name]')!;
|
||||
const targetInput = elForm.querySelector<HTMLInputElement>('input[name=tag_target]')!;
|
||||
|
||||
const textMissingTag = buttonShowModal.getAttribute('data-text-missing-tag')!;
|
||||
const generateUrl = buttonShowModal.getAttribute('data-generate-url')!;
|
||||
|
||||
const elModal = document.querySelector('#generate-release-notes-modal')!;
|
||||
|
||||
const doSubmit = async (tagName: string) => {
|
||||
const elPreviousTag = elModal.querySelector<HTMLSelectElement>('[name=previous_tag]')!;
|
||||
const comboEditor = getComboMarkdownEditor(elForm.querySelector<HTMLElement>('.combo-markdown-editor'))!;
|
||||
|
||||
const form = new URLSearchParams();
|
||||
form.set('tag_name', tagName);
|
||||
form.set('tag_target', targetInput.value);
|
||||
form.set('previous_tag', elPreviousTag.value);
|
||||
|
||||
elModal.classList.add('loading', 'disabled');
|
||||
try {
|
||||
const resp = await POST(generateUrl, {data: form});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
showErrorToast(data.errorMessage || resp.statusText);
|
||||
return;
|
||||
}
|
||||
const oldValue = comboEditor.value().trim();
|
||||
if (oldValue) {
|
||||
// Don't overwrite existing content. Maybe in the future we can let users decide: overwrite or append or copy-to-clipboard
|
||||
// GitHub just disables the button if the content is not empty
|
||||
comboEditor.value(`${oldValue}\n\n${data.content}`);
|
||||
} else {
|
||||
comboEditor.value(data.content);
|
||||
}
|
||||
} finally {
|
||||
elModal.classList.remove('loading', 'disabled');
|
||||
hideFomanticModal(elModal);
|
||||
comboEditor.focus();
|
||||
}
|
||||
};
|
||||
|
||||
let inited = false;
|
||||
const doShowModal = () => {
|
||||
hideToastsAll();
|
||||
const tagName = tagNameInput.value.trim();
|
||||
if (!tagName) {
|
||||
showErrorToast(textMissingTag, {duration: 3000});
|
||||
tagNameInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const existingTags = getReleaseFormExistingTags(elForm);
|
||||
const $dropdown = fomanticQuery(elModal.querySelector('[name=previous_tag]')!);
|
||||
if (!inited) {
|
||||
inited = true;
|
||||
const values = [];
|
||||
for (const tagName of existingTags) {
|
||||
values.push({name: htmlEscape(tagName), value: tagName}); // ATTENTION: dropdown takes the "name" input as raw HTML
|
||||
}
|
||||
$dropdown.dropdown('change values', values);
|
||||
}
|
||||
$dropdown.dropdown('set selected', guessPreviousReleaseTag(tagName, existingTags));
|
||||
|
||||
showFomanticModal(elModal, {
|
||||
onApprove: () => {
|
||||
doSubmit(tagName); // don't await, need to return false to keep the modal
|
||||
return false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
buttonShowModal.addEventListener('click', doShowModal);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export function initRepositorySearch() {
|
||||
const repositorySearchForm = document.querySelector<HTMLFormElement>('#repo-search-form');
|
||||
if (!repositorySearchForm) return;
|
||||
|
||||
repositorySearchForm.addEventListener('change', (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of new FormData(repositorySearchForm).entries()) {
|
||||
params.set(key, value as string);
|
||||
}
|
||||
if ((e.target as HTMLInputElement).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();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {createSortable} from '../modules/sortable.ts';
|
||||
import type {SortableEvent} from 'sortablejs';
|
||||
|
||||
vi.mock('../modules/fetch.ts', () => ({
|
||||
POST: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../modules/sortable.ts', () => ({
|
||||
createSortable: vi.fn(),
|
||||
}));
|
||||
|
||||
const branchesHTML = `
|
||||
<div id="protected-branches-list" data-update-priority-url="some/repo/branches/priority">
|
||||
<div class="item" data-id="1">
|
||||
<div class="drag-handle"></div>
|
||||
</div>
|
||||
<div class="item" data-id="2">
|
||||
<div class="drag-handle"></div>
|
||||
</div>
|
||||
<div class="item" data-id="3">
|
||||
<div class="drag-handle"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
describe('Repository Branch Settings', () => {
|
||||
test('should initialize sortable for protected branches list', () => {
|
||||
document.body.innerHTML = branchesHTML;
|
||||
const callsBefore = vi.mocked(createSortable).mock.calls.length;
|
||||
initRepoSettingsBranchesDrag();
|
||||
const newCalls = vi.mocked(createSortable).mock.calls.slice(callsBefore);
|
||||
expect(newCalls).toHaveLength(1);
|
||||
expect(newCalls[0][0]).toBe(document.querySelector('#protected-branches-list'));
|
||||
expect(newCalls[0][1]).toMatchObject({handle: '.drag-handle', animation: 150});
|
||||
});
|
||||
|
||||
test('should not initialize if protected branches list is not present', () => {
|
||||
document.querySelector('#protected-branches-list')?.remove();
|
||||
const callsBefore = vi.mocked(createSortable).mock.calls.length;
|
||||
initRepoSettingsBranchesDrag();
|
||||
expect(vi.mocked(createSortable).mock.calls.length).toBe(callsBefore);
|
||||
});
|
||||
|
||||
test('should post new order after sorting', () => {
|
||||
document.body.innerHTML = branchesHTML;
|
||||
vi.mocked(POST).mockResolvedValue({ok: true} as Response);
|
||||
const callsBefore = vi.mocked(createSortable).mock.calls.length;
|
||||
initRepoSettingsBranchesDrag();
|
||||
const onEnd = vi.mocked(createSortable).mock.calls[callsBefore][1]!.onEnd!;
|
||||
onEnd(new Event('SortableEvent') as SortableEvent);
|
||||
expect(POST).toHaveBeenCalledWith(
|
||||
'some/repo/branches/priority',
|
||||
expect.objectContaining({data: {ids: [1, 2, 3]}}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import {createSortable} from '../modules/sortable.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {queryElemChildren} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
|
||||
export function initRepoSettingsBranchesDrag() {
|
||||
const protectedBranchesList = document.querySelector<HTMLElement>('#protected-branches-list');
|
||||
if (!protectedBranchesList) return;
|
||||
|
||||
createSortable(protectedBranchesList, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
|
||||
onEnd: () => {
|
||||
(async () => {
|
||||
const itemElems = queryElemChildren(protectedBranchesList, '.item[data-id]');
|
||||
const itemIds = Array.from(itemElems, (el) => parseInt(el.getAttribute('data-id')!));
|
||||
|
||||
try {
|
||||
await POST(protectedBranchesList.getAttribute('data-update-priority-url')!, {
|
||||
data: {
|
||||
ids: itemIds,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
showErrorToast(`Failed to update branch protection rule priority: ${errorMessage(err)}`);
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import {createCodeEditor} from '../modules/codeeditor/main.ts';
|
||||
import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {attachSearchBox} from '../modules/search.ts';
|
||||
import {globMatch} from '../utils/glob.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
function initRepoSettingsCollaboration() {
|
||||
// Change collaborator access mode
|
||||
for (const dropdownEl of queryElems(document, '.page-content.repository .ui.dropdown.access-mode')) {
|
||||
const textEl = dropdownEl.querySelector(':scope > .text')!;
|
||||
const $dropdown = fomanticQuery(dropdownEl);
|
||||
$dropdown.dropdown({
|
||||
async action(text: string, value: string) {
|
||||
dropdownEl.classList.add('is-loading', 'loading-icon-2px');
|
||||
const lastValue = dropdownEl.getAttribute('data-last-value')!;
|
||||
$dropdown.dropdown('hide');
|
||||
try {
|
||||
const uid = dropdownEl.getAttribute('data-uid')!;
|
||||
await POST(dropdownEl.getAttribute('data-url')!, {data: new URLSearchParams({uid, 'mode': value})});
|
||||
textEl.textContent = text;
|
||||
dropdownEl.setAttribute('data-last-value', value);
|
||||
} catch {
|
||||
textEl.textContent = '(error)'; // prevent from misleading users when error occurs
|
||||
dropdownEl.setAttribute('data-last-value', lastValue);
|
||||
} finally {
|
||||
dropdownEl.classList.remove('is-loading');
|
||||
}
|
||||
},
|
||||
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', dropdownEl.getAttribute('data-last-value'));
|
||||
if ($item) {
|
||||
$dropdown.dropdown('set selected', dropdownEl.getAttribute('data-last-value'));
|
||||
} else {
|
||||
textEl.textContent = '(none)'; // prevent from misleading users when the access mode is undefined
|
||||
}
|
||||
}, 0);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type TeamSearchResponse = {data: Array<{name: string; permission: string}>};
|
||||
|
||||
function initRepoSettingsSearchTeamBox() {
|
||||
const box = document.querySelector<HTMLElement>('#search-team-box');
|
||||
if (!box) return;
|
||||
|
||||
const url = `${appSubUrl}/org/${box.getAttribute('data-org-name')}/teams/-/search?q={query}`;
|
||||
attachSearchBox(box, url, (response: TeamSearchResponse) => response.data.map((item) => ({
|
||||
title: item.name,
|
||||
description: `${item.permission} access`, // TODO: translate this string
|
||||
})));
|
||||
}
|
||||
|
||||
function initRepoSettingsGitHook() {
|
||||
if (!document.querySelector('.page-content.repository.settings.edit.githook')) return;
|
||||
createCodeEditor(document.querySelector<HTMLTextAreaElement>('#content')!);
|
||||
}
|
||||
|
||||
function initRepoSettingsBranches() {
|
||||
if (!document.querySelector('.repository.settings.branches')) return;
|
||||
|
||||
for (const el of document.querySelectorAll<HTMLInputElement>('.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.querySelectorAll<HTMLInputElement>('.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.querySelector<HTMLInputElement>('#dismiss_stale_approvals')?.addEventListener('change', function () {
|
||||
document.querySelector('#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.querySelector<HTMLTextAreaElement>('#status_check_contexts')!.value || '').split(/[\r\n]+/);
|
||||
const validPatterns = patterns.map((item) => item.trim()).filter(Boolean as unknown as <T>(x: T | boolean) => x is T);
|
||||
const marks = document.querySelectorAll('.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 (globMatch(statusCheck, pattern, '/')) {
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
toggleElem(el, matched);
|
||||
}
|
||||
};
|
||||
markMatchedStatusChecks();
|
||||
document.querySelector('#status_check_contexts')!.addEventListener('input', onInputDebounce(markMatchedStatusChecks));
|
||||
}
|
||||
|
||||
function initRepoSettingsOptions() {
|
||||
const pageContent = document.querySelector('.page-content.repository.settings.options');
|
||||
if (!pageContent) return;
|
||||
|
||||
// toggle related panels for the checkbox/radio inputs, the "selector" may not exist
|
||||
const toggleTargetContextPanel = (selector: string, enabled: boolean) => {
|
||||
if (!selector) return;
|
||||
queryElems(document, selector, (el) => el.classList.toggle('disabled', !enabled));
|
||||
};
|
||||
queryElems<HTMLInputElement>(pageContent, '.enable-system', (el) => el.addEventListener('change', () => {
|
||||
toggleTargetContextPanel(el.getAttribute('data-target')!, el.checked);
|
||||
toggleTargetContextPanel(el.getAttribute('data-context')!, !el.checked);
|
||||
}));
|
||||
queryElems<HTMLInputElement>(pageContent, '.enable-system-radio', (el) => el.addEventListener('change', () => {
|
||||
toggleTargetContextPanel(el.getAttribute('data-target')!, el.value === 'true');
|
||||
toggleTargetContextPanel(el.getAttribute('data-context')!, el.value === 'false');
|
||||
}));
|
||||
|
||||
queryElems<HTMLInputElement>(pageContent, '.js-tracker-issue-style', (el) => el.addEventListener('change', () => {
|
||||
const checkedVal = el.value;
|
||||
pageContent.querySelector('#tracker-issue-style-regex-box')!.classList.toggle('disabled', checkedVal !== 'regexp');
|
||||
}));
|
||||
}
|
||||
|
||||
export function initRepoSettings() {
|
||||
if (!document.querySelector('.page-content.repository.settings')) return;
|
||||
initRepoSettingsOptions();
|
||||
initRepoSettingsBranches();
|
||||
initRepoSettingsCollaboration();
|
||||
initRepoSettingsSearchTeamBox();
|
||||
initRepoSettingsGitHook();
|
||||
initRepoSettingsBranchesDrag();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {addDelegatedEventListener, hideElem, queryElemSiblings, showElem, toggleElem} from '../utils/dom.ts';
|
||||
|
||||
export function initUnicodeEscapeButton() {
|
||||
// buttons might appear on these pages: file view (code, rendered markdown), diff (commit, pr conversation, pr diff), blame, wiki
|
||||
addDelegatedEventListener(document, 'click', '.escape-button, .unescape-button, .toggle-escape-button', (btn, e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const unicodeContentSelector = btn.getAttribute('data-unicode-content-selector');
|
||||
const container = unicodeContentSelector ?
|
||||
document.querySelector(unicodeContentSelector)! :
|
||||
btn.closest('.file-content, .non-diff-file-content')!;
|
||||
const fileView = container.querySelector('.file-code, .file-view') ?? container;
|
||||
if (btn.matches('.escape-button')) {
|
||||
fileView.classList.add('unicode-escaped');
|
||||
hideElem(btn);
|
||||
showElem(queryElemSiblings(btn, '.unescape-button'));
|
||||
} else if (btn.matches('.unescape-button')) {
|
||||
fileView.classList.remove('unicode-escaped');
|
||||
hideElem(btn);
|
||||
showElem(queryElemSiblings(btn, '.escape-button'));
|
||||
} else if (btn.matches('.toggle-escape-button')) {
|
||||
const isEscaped = fileView.classList.contains('unicode-escaped');
|
||||
fileView.classList.toggle('unicode-escaped', !isEscaped);
|
||||
toggleElem(container.querySelectorAll('.unescape-button'), !isEscaped);
|
||||
toggleElem(container.querySelectorAll('.escape-button'), isEscaped);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {createApp} from 'vue';
|
||||
import {toggleElem} from '../utils/dom.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import ViewFileTree from '../components/ViewFileTree.vue';
|
||||
import {registerGlobalEventFunc} from '../modules/observer.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
function isUserSignedIn() {
|
||||
return Boolean(document.querySelector('#navbar .user-menu'));
|
||||
}
|
||||
|
||||
async function toggleSidebar(btn: HTMLElement) {
|
||||
const elToggleShow = document.querySelector('.repo-view-file-tree-toggle[data-toggle-action="show"]')!;
|
||||
const elFileTreeContainer = document.querySelector('.repo-view-file-tree-container')!;
|
||||
const shouldShow = btn.getAttribute('data-toggle-action') === 'show';
|
||||
toggleElem(elFileTreeContainer, shouldShow);
|
||||
toggleElem(elToggleShow, !shouldShow);
|
||||
|
||||
// FIXME: need to remove "full height" style from parent element
|
||||
|
||||
if (!isUserSignedIn()) return;
|
||||
await POST(`${appSubUrl}/user/settings/update_preferences`, {
|
||||
data: {codeViewShowFileTree: shouldShow},
|
||||
});
|
||||
}
|
||||
|
||||
export async function initRepoViewFileTree() {
|
||||
const sidebar = document.querySelector<HTMLElement>('.repo-view-file-tree-container');
|
||||
const repoViewContent = document.querySelector('.repo-view-content');
|
||||
if (!sidebar || !repoViewContent) return;
|
||||
|
||||
registerGlobalEventFunc('click', 'onRepoViewFileTreeToggle', toggleSidebar);
|
||||
|
||||
const fileTree = sidebar.querySelector('#view-file-tree')!;
|
||||
createApp(ViewFileTree, {
|
||||
repoLink: fileTree.getAttribute('data-repo-link'),
|
||||
treePath: fileTree.getAttribute('data-tree-path'),
|
||||
currentRefNameSubURL: fileTree.getAttribute('data-current-ref-name-sub-url'),
|
||||
}).mount(fileTree);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
|
||||
import {fomanticMobileScreen} from '../modules/fomantic.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import type {ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
|
||||
import {html, htmlRaw} from '../utils/html.ts';
|
||||
|
||||
async function initRepoWikiFormEditor() {
|
||||
const editArea = document.querySelector<HTMLTextAreaElement>('.repository.wiki .combo-markdown-editor textarea');
|
||||
if (!editArea) return;
|
||||
|
||||
const form = document.querySelector('.repository.wiki.new .ui.form')!;
|
||||
const editorContainer = form.querySelector<HTMLElement>('.combo-markdown-editor')!;
|
||||
let editor: ComboMarkdownEditor;
|
||||
|
||||
let renderRequesting = false;
|
||||
let lastContent: string = '';
|
||||
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);
|
||||
try {
|
||||
const response = await POST(editor.previewUrl, {data: formData});
|
||||
const data = await response.text();
|
||||
lastContent = newContent;
|
||||
previewTarget.innerHTML = html`<div class="render-content markup ui segment">${htmlRaw(data)}</div>`;
|
||||
} catch (error) {
|
||||
console.error('Error rendering preview:', error);
|
||||
} finally {
|
||||
renderRequesting = false;
|
||||
setTimeout(renderEasyMDEPreview, 1000);
|
||||
}
|
||||
} else {
|
||||
setTimeout(renderEasyMDEPreview, 1000);
|
||||
}
|
||||
};
|
||||
renderEasyMDEPreview();
|
||||
|
||||
editor = await initComboMarkdownEditor(editorContainer, {
|
||||
// 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)'},
|
||||
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',
|
||||
] as any, // to use custom toolbar buttons
|
||||
},
|
||||
});
|
||||
|
||||
form.addEventListener('submit', (e) => {
|
||||
if (!validateTextareaNonEmpty(editArea)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function collapseWikiTocForMobile(collapse: boolean) {
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function initSshKeyFormParser() {
|
||||
// Parse SSH Key
|
||||
document.querySelector<HTMLTextAreaElement>('#ssh-key-content')?.addEventListener('input', function () {
|
||||
const arrays = this.value.split(' ');
|
||||
const title = document.querySelector<HTMLInputElement>('#ssh-key-title')!;
|
||||
if (!title.value && arrays.length === 3 && arrays[2] !== '') {
|
||||
title.value = arrays[2];
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {hideElem, queryElems, showElem} from '../utils/dom.ts';
|
||||
import {UserEventsSharedWorker} from '../modules/worker.ts';
|
||||
|
||||
const {appSubUrl, notificationSettings, enableTimeTracking} = window.config;
|
||||
|
||||
export function initStopwatch() {
|
||||
if (!enableTimeTracking) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stopwatchEls = document.querySelectorAll('.active-stopwatch');
|
||||
const stopwatchPopup = document.querySelector('.active-stopwatch-popup');
|
||||
|
||||
if (!stopwatchEls.length || !stopwatchPopup) {
|
||||
return;
|
||||
}
|
||||
|
||||
// global stop watch (in the head_navbar), it should always work in any case either the EventSource or the PeriodicPoller is used.
|
||||
const seconds = stopwatchEls[0]?.getAttribute('data-seconds');
|
||||
if (seconds) {
|
||||
updateStopwatchTime(parseInt(seconds));
|
||||
}
|
||||
|
||||
for (const stopwatchEl of stopwatchEls) {
|
||||
stopwatchEl.removeAttribute('href'); // intended for noscript mode only
|
||||
|
||||
createTippy(stopwatchEl, {
|
||||
content: stopwatchPopup.cloneNode(true) as Element,
|
||||
placement: 'bottom-end',
|
||||
trigger: 'click',
|
||||
maxWidth: 'none',
|
||||
interactive: true,
|
||||
hideOnClick: true,
|
||||
theme: 'default',
|
||||
});
|
||||
}
|
||||
|
||||
let usingPeriodicPoller = false;
|
||||
const startPeriodicPoller = (timeout: number) => {
|
||||
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 UserEventsSharedWorker('stopwatch-worker');
|
||||
worker.addMessageEventListener((event) => {
|
||||
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 === 'stopwatches') {
|
||||
updateStopwatchData(JSON.parse(event.data.data));
|
||||
}
|
||||
});
|
||||
worker.startPort();
|
||||
return;
|
||||
}
|
||||
|
||||
startPeriodicPoller(notificationSettings.MinTimeout);
|
||||
}
|
||||
|
||||
async function updateStopwatchWithCallback(callback: (timeout: number) => void, timeout: number) {
|
||||
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: any) {
|
||||
const watch = data[0];
|
||||
const btnEls = document.querySelectorAll('.active-stopwatch');
|
||||
if (!watch) {
|
||||
hideElem(btnEls);
|
||||
} 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/stop`);
|
||||
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(btnEls);
|
||||
}
|
||||
return Boolean(data.length);
|
||||
}
|
||||
|
||||
// TODO: This flickers on page load, we could avoid this by making a custom element to render time periods.
|
||||
function updateStopwatchTime(seconds: number) {
|
||||
const hours = seconds / 3600 || 0;
|
||||
const minutes = seconds / 60 || 0;
|
||||
const timeText = hours >= 1 ? `${Math.round(hours)}h` : `${Math.round(minutes)}m`;
|
||||
queryElems(document, '.header-stopwatch-dot', (el) => el.textContent = timeText);
|
||||
}
|
||||
@@ -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: string, revSort: string, isDefault: string) {
|
||||
if (!normSort) return;
|
||||
if (!revSort) revSort = '';
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
let urlSort = url.searchParams.get('sort');
|
||||
if (!urlSort && isDefault) urlSort = normSort;
|
||||
|
||||
url.searchParams.set('sort', urlSort !== normSort ? normSort : revSort);
|
||||
window.location.replace(url.href);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {emojiKeys, emojiHTML, emojiString} from './emoji.ts';
|
||||
import {html, htmlRaw} from '../utils/html.ts';
|
||||
import {fetchMentions} from '../utils/match.ts';
|
||||
import type {TributeCollection} from 'tributejs';
|
||||
import type {Mention} from '../types.ts';
|
||||
|
||||
export async function attachTribute(element: HTMLElement) {
|
||||
const {default: Tribute} = await import('tributejs');
|
||||
const mentionsUrl = element.closest('[data-mentions-url]')?.getAttribute('data-mentions-url');
|
||||
|
||||
const emojiCollection: TributeCollection<string> = { // emojis
|
||||
trigger: ':',
|
||||
requireLeadingSpace: true,
|
||||
values: (query: string, cb: (matches: Array<string>) => void) => {
|
||||
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 '';
|
||||
return emojiString(item.original) ?? '';
|
||||
},
|
||||
menuItemTemplate: (item) => {
|
||||
return html`<div class="tribute-item">${htmlRaw(emojiHTML(item.original))}<span>${item.original}</span></div>`;
|
||||
},
|
||||
};
|
||||
|
||||
const mentionCollection: TributeCollection<Mention> = {
|
||||
values: async (_query: string, cb: (matches: Mention[]) => void) => { // eslint-disable-line @typescript-eslint/no-misused-promises
|
||||
cb(mentionsUrl ? await fetchMentions(mentionsUrl) : []);
|
||||
},
|
||||
requireLeadingSpace: true,
|
||||
menuItemTemplate: (item) => {
|
||||
const fullNameHtml = item.original.fullname && item.original.fullname !== '' ? html`<span class="fullname">${item.original.fullname}</span>` : '';
|
||||
return html`
|
||||
<div class="tribute-item">
|
||||
<img alt src="${item.original.avatar}" width="21" height="21"/>
|
||||
<span class="name">${item.original.name}</span>
|
||||
${htmlRaw(fullNameHtml)}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
};
|
||||
|
||||
const tribute = new Tribute({
|
||||
collection: [
|
||||
emojiCollection,
|
||||
mentionCollection,
|
||||
] as TributeCollection<any>[],
|
||||
noMatchTemplate: () => '',
|
||||
});
|
||||
tribute.attach(element);
|
||||
return tribute;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.ts';
|
||||
import {hideElem, showElem} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
/** One of the possible values for the `data-webauthn-error-msg` attribute on the webauthn error message element */
|
||||
type ErrorType = 'general' | 'insecure' | 'browser' | 'unable-to-process' | 'duplicated' | 'unknown';
|
||||
|
||||
export async function initUserAuthWebAuthn() {
|
||||
const elPrompt = document.querySelector('.user.signin.webauthn-prompt');
|
||||
const elSignInPasskeyBtn = document.querySelector('.signin-passkey');
|
||||
if (!elPrompt && !elSignInPasskeyBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorType = detectWebAuthnSupport();
|
||||
if (errorType) {
|
||||
if (elSignInPasskeyBtn) hideElem(elSignInPasskeyBtn);
|
||||
return;
|
||||
}
|
||||
|
||||
if (elSignInPasskeyBtn) {
|
||||
elSignInPasskeyBtn.addEventListener('click', loginPasskey);
|
||||
}
|
||||
|
||||
if (elPrompt) {
|
||||
login2FA();
|
||||
}
|
||||
}
|
||||
|
||||
async function loginPasskey() {
|
||||
const res = await GET(`${appSubUrl}/user/webauthn/passkey/assertion`);
|
||||
if (!res.ok) {
|
||||
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,
|
||||
}) as PublicKeyCredential;
|
||||
const credResp = credential.response as AuthenticatorAssertionResponse;
|
||||
|
||||
// Move data into Arrays in case it is super long
|
||||
const authData = new Uint8Array(credResp.authenticatorData);
|
||||
const clientDataJSON = new Uint8Array(credResp.clientDataJSON);
|
||||
const rawId = new Uint8Array(credential.rawId);
|
||||
const sig = new Uint8Array(credResp.signature);
|
||||
const userHandle = new Uint8Array(credResp.userHandle ?? []);
|
||||
|
||||
const res = await POST(`${appSubUrl}/user/webauthn/passkey/login`, {
|
||||
data: {
|
||||
id: credential.id,
|
||||
rawId: encodeURLEncodedBase64(rawId),
|
||||
type: credential.type,
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
response: {
|
||||
authenticatorData: encodeURLEncodedBase64(authData),
|
||||
clientDataJSON: encodeURLEncodedBase64(clientDataJSON),
|
||||
signature: encodeURLEncodedBase64(sig),
|
||||
userHandle: encodeURLEncodedBase64(userHandle),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (res.status === 500) {
|
||||
webAuthnError('unknown');
|
||||
return;
|
||||
} else if (!res.ok) {
|
||||
webAuthnError('unable-to-process');
|
||||
return;
|
||||
}
|
||||
const reply = await res.json();
|
||||
|
||||
window.location.href = reply?.redirect ?? `${appSubUrl}/`;
|
||||
} catch (err) {
|
||||
webAuthnError('general', errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function login2FA() {
|
||||
const res = await GET(`${appSubUrl}/user/webauthn/assertion`);
|
||||
if (!res.ok) {
|
||||
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', errorMessage(err));
|
||||
return;
|
||||
}
|
||||
delete options.publicKey.extensions.appid;
|
||||
try {
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: options.publicKey,
|
||||
});
|
||||
await verifyAssertion(credential);
|
||||
} catch (err) {
|
||||
webAuthnError('general', errorMessage(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAssertion(assertedCredential: any) { // TODO: Credential type does not work
|
||||
// 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.ok) {
|
||||
webAuthnError('unable-to-process');
|
||||
return;
|
||||
}
|
||||
const reply = await res.json();
|
||||
|
||||
window.location.href = reply?.redirect ?? `${appSubUrl}/`;
|
||||
}
|
||||
|
||||
async function webauthnRegistered(newCredential: any) { // TODO: Credential type does not work
|
||||
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: ErrorType, message:string = '') {
|
||||
const elErrorMsg = document.querySelector(`#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');
|
||||
}
|
||||
|
||||
/** Returns the error type or `null` when there was no error. */
|
||||
function detectWebAuthnSupport(): ErrorType | null {
|
||||
if (!window.isSecureContext) {
|
||||
return 'insecure';
|
||||
}
|
||||
|
||||
if (typeof window.PublicKeyCredential !== 'function') {
|
||||
return 'browser';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function initUserAuthWebAuthnRegister() {
|
||||
const elRegister = document.querySelector<HTMLInputElement>('#register-webauthn');
|
||||
if (!elRegister) return;
|
||||
|
||||
const errorType = detectWebAuthnSupport();
|
||||
if (errorType) {
|
||||
webAuthnError(errorType);
|
||||
elRegister.disabled = true;
|
||||
return;
|
||||
}
|
||||
elRegister.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
await webAuthnRegisterRequest();
|
||||
});
|
||||
}
|
||||
|
||||
async function webAuthnRegisterRequest() {
|
||||
const elNickname = document.querySelector<HTMLInputElement>('#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.ok) {
|
||||
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', errorMessage(err));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {checkAppUrl, checkAppUrlScheme} from './common-page.ts';
|
||||
|
||||
export function initUserCheckAppUrl() {
|
||||
if (!document.querySelector('.page-content.user.signin, .page-content.user.signup, .page-content.user.link-account')) return;
|
||||
checkAppUrlScheme();
|
||||
}
|
||||
|
||||
export function initUserExternalLogins() {
|
||||
const container = document.querySelector('#external-login-navigator');
|
||||
if (!container) return;
|
||||
|
||||
// whether the auth method requires app url check (need consistent ROOT_URL with visited URL)
|
||||
let needCheckAppUrl = false;
|
||||
for (const link of container.querySelectorAll('.external-login-link')) {
|
||||
needCheckAppUrl = needCheckAppUrl || link.getAttribute('data-require-appurl-check') === 'true';
|
||||
link.addEventListener('click', () => {
|
||||
container.classList.add('is-loading');
|
||||
setTimeout(() => {
|
||||
// recover previous content to let user try again, usually redirection will be performed before this action
|
||||
container.classList.remove('is-loading');
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
if (needCheckAppUrl) {
|
||||
checkAppUrl();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {hideElem, showElem} from '../utils/dom.ts';
|
||||
|
||||
export function initUserSettings() {
|
||||
if (!document.querySelector('.user.settings.profile')) return;
|
||||
|
||||
const usernameInput = document.querySelector<HTMLInputElement>('#username');
|
||||
if (!usernameInput) return;
|
||||
usernameInput.addEventListener('input', function () {
|
||||
const prompt = document.querySelector('#name-change-prompt')!;
|
||||
const promptRedirect = document.querySelector('#name-change-redirect-prompt')!;
|
||||
if (this.value.toLowerCase() !== this.getAttribute('data-name')!.toLowerCase()) {
|
||||
showElem(prompt);
|
||||
showElem(promptRedirect);
|
||||
} else {
|
||||
hideElem(prompt);
|
||||
hideElem(promptRedirect);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user