forked from forgejo/forgejo
Merge pull request '[gitea] cherry-pick' (#2375) from earl-warren/forgejo:wip-gitea-cherry-pick into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/2375 Reviewed-by: Otto <otto@codeberg.org>
This commit is contained in:
commit
6c9c0aca76
67 changed files with 3542 additions and 880 deletions
|
@ -413,6 +413,13 @@ ol.ui.list li,
|
|||
color: var(--color-text-light-2);
|
||||
}
|
||||
|
||||
/* extend fomantic style '.ui.dropdown > .text > img' to include svg.img */
|
||||
.ui.dropdown > .text > .img {
|
||||
margin-left: 0;
|
||||
float: none;
|
||||
margin-right: 0.78571429rem;
|
||||
}
|
||||
|
||||
.ui.dropdown > .text > .description,
|
||||
.ui.dropdown .menu > .item > .description {
|
||||
color: var(--color-text-light-2);
|
||||
|
|
|
@ -7,6 +7,10 @@ extends:
|
|||
- plugin:vue/vue3-recommended
|
||||
- plugin:vue-scoped-css/vue3-recommended
|
||||
|
||||
parserOptions:
|
||||
sourceType: module
|
||||
ecmaVersion: latest
|
||||
|
||||
env:
|
||||
browser: true
|
||||
|
||||
|
|
443
web_src/js/components/RepoContributors.vue
Normal file
443
web_src/js/components/RepoContributors.vue
Normal file
|
@ -0,0 +1,443 @@
|
|||
<script>
|
||||
import {SvgIcon} from '../svg.js';
|
||||
import {
|
||||
Chart,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler,
|
||||
} from 'chart.js';
|
||||
import {GET} from '../modules/fetch.js';
|
||||
import zoomPlugin from 'chartjs-plugin-zoom';
|
||||
import {Line as ChartLine} from 'vue-chartjs';
|
||||
import {
|
||||
startDaysBetween,
|
||||
firstStartDateAfterDate,
|
||||
fillEmptyStartDaysWithZeroes,
|
||||
} from '../utils/time.js';
|
||||
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
||||
import $ from 'jquery';
|
||||
|
||||
const {pageData} = window.config;
|
||||
|
||||
const colors = {
|
||||
text: '--color-text',
|
||||
border: '--color-secondary-alpha-60',
|
||||
commits: '--color-primary-alpha-60',
|
||||
additions: '--color-green',
|
||||
deletions: '--color-red',
|
||||
title: '--color-secondary-dark-4',
|
||||
};
|
||||
|
||||
const styles = window.getComputedStyle(document.documentElement);
|
||||
const getColor = (name) => styles.getPropertyValue(name).trim();
|
||||
|
||||
for (const [key, value] of Object.entries(colors)) {
|
||||
colors[key] = getColor(value);
|
||||
}
|
||||
|
||||
const customEventListener = {
|
||||
id: 'customEventListener',
|
||||
afterEvent: (chart, args, opts) => {
|
||||
// event will be replayed from chart.update when reset zoom,
|
||||
// so we need to check whether args.replay is true to avoid call loops
|
||||
if (args.event.type === 'dblclick' && opts.chartType === 'main' && !args.replay) {
|
||||
chart.resetZoom();
|
||||
opts.instance.updateOtherCharts(args.event, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Chart.defaults.color = colors.text;
|
||||
Chart.defaults.borderColor = colors.border;
|
||||
|
||||
Chart.register(
|
||||
TimeScale,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler,
|
||||
zoomPlugin,
|
||||
customEventListener,
|
||||
);
|
||||
|
||||
export default {
|
||||
components: {ChartLine, SvgIcon},
|
||||
props: {
|
||||
locale: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: () => ({
|
||||
isLoading: false,
|
||||
errorText: '',
|
||||
totalStats: {},
|
||||
sortedContributors: {},
|
||||
repoLink: pageData.repoLink || [],
|
||||
type: pageData.contributionType,
|
||||
contributorsStats: [],
|
||||
xAxisStart: null,
|
||||
xAxisEnd: null,
|
||||
xAxisMin: null,
|
||||
xAxisMax: null,
|
||||
}),
|
||||
mounted() {
|
||||
this.fetchGraphData();
|
||||
|
||||
$('#repo-contributors').dropdown({
|
||||
onChange: (val) => {
|
||||
this.xAxisMin = this.xAxisStart;
|
||||
this.xAxisMax = this.xAxisEnd;
|
||||
this.type = val;
|
||||
this.sortContributors();
|
||||
}
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
sortContributors() {
|
||||
const contributors = this.filterContributorWeeksByDateRange();
|
||||
const criteria = `total_${this.type}`;
|
||||
this.sortedContributors = Object.values(contributors)
|
||||
.filter((contributor) => contributor[criteria] !== 0)
|
||||
.sort((a, b) => a[criteria] > b[criteria] ? -1 : a[criteria] === b[criteria] ? 0 : 1)
|
||||
.slice(0, 100);
|
||||
},
|
||||
|
||||
async fetchGraphData() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
let response;
|
||||
do {
|
||||
response = await GET(`${this.repoLink}/activity/contributors/data`);
|
||||
if (response.status === 202) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000)); // wait for 1 second before retrying
|
||||
}
|
||||
} while (response.status === 202);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const {total, ...rest} = data;
|
||||
// below line might be deleted if we are sure go produces map always sorted by keys
|
||||
total.weeks = Object.fromEntries(Object.entries(total.weeks).sort());
|
||||
|
||||
const weekValues = Object.values(total.weeks);
|
||||
this.xAxisStart = weekValues[0].week;
|
||||
this.xAxisEnd = firstStartDateAfterDate(new Date());
|
||||
const startDays = startDaysBetween(new Date(this.xAxisStart), new Date(this.xAxisEnd));
|
||||
total.weeks = fillEmptyStartDaysWithZeroes(startDays, total.weeks);
|
||||
this.xAxisMin = this.xAxisStart;
|
||||
this.xAxisMax = this.xAxisEnd;
|
||||
this.contributorsStats = {};
|
||||
for (const [email, user] of Object.entries(rest)) {
|
||||
user.weeks = fillEmptyStartDaysWithZeroes(startDays, user.weeks);
|
||||
this.contributorsStats[email] = user;
|
||||
}
|
||||
this.sortContributors();
|
||||
this.totalStats = total;
|
||||
this.errorText = '';
|
||||
} else {
|
||||
this.errorText = response.statusText;
|
||||
}
|
||||
} catch (err) {
|
||||
this.errorText = err.message;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
filterContributorWeeksByDateRange() {
|
||||
const filteredData = {};
|
||||
const data = this.contributorsStats;
|
||||
for (const key of Object.keys(data)) {
|
||||
const user = data[key];
|
||||
user.total_commits = 0;
|
||||
user.total_additions = 0;
|
||||
user.total_deletions = 0;
|
||||
user.max_contribution_type = 0;
|
||||
const filteredWeeks = user.weeks.filter((week) => {
|
||||
const oneWeek = 7 * 24 * 60 * 60 * 1000;
|
||||
if (week.week >= this.xAxisMin - oneWeek && week.week <= this.xAxisMax + oneWeek) {
|
||||
user.total_commits += week.commits;
|
||||
user.total_additions += week.additions;
|
||||
user.total_deletions += week.deletions;
|
||||
if (week[this.type] > user.max_contribution_type) {
|
||||
user.max_contribution_type = week[this.type];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
// this line is required. See https://github.com/sahinakkaya/gitea/pull/3#discussion_r1396495722
|
||||
// for details.
|
||||
user.max_contribution_type += 1;
|
||||
|
||||
filteredData[key] = {...user, weeks: filteredWeeks};
|
||||
}
|
||||
|
||||
return filteredData;
|
||||
},
|
||||
|
||||
maxMainGraph() {
|
||||
// This method calculates maximum value for Y value of the main graph. If the number
|
||||
// of maximum contributions for selected contribution type is 15.955 it is probably
|
||||
// better to round it up to 20.000.This method is responsible for doing that.
|
||||
// Normally, chartjs handles this automatically, but it will resize the graph when you
|
||||
// zoom, pan etc. I think resizing the graph makes it harder to compare things visually.
|
||||
const maxValue = Math.max(
|
||||
...this.totalStats.weeks.map((o) => o[this.type])
|
||||
);
|
||||
const [coefficient, exp] = maxValue.toExponential().split('e').map(Number);
|
||||
if (coefficient % 1 === 0) return maxValue;
|
||||
return (1 - (coefficient % 1)) * 10 ** exp + maxValue;
|
||||
},
|
||||
|
||||
maxContributorGraph() {
|
||||
// Similar to maxMainGraph method this method calculates maximum value for Y value
|
||||
// for contributors' graph. If I let chartjs do this for me, it will choose different
|
||||
// maxY value for each contributors' graph which again makes it harder to compare.
|
||||
const maxValue = Math.max(
|
||||
...this.sortedContributors.map((c) => c.max_contribution_type)
|
||||
);
|
||||
const [coefficient, exp] = maxValue.toExponential().split('e').map(Number);
|
||||
if (coefficient % 1 === 0) return maxValue;
|
||||
return (1 - (coefficient % 1)) * 10 ** exp + maxValue;
|
||||
},
|
||||
|
||||
toGraphData(data) {
|
||||
return {
|
||||
datasets: [
|
||||
{
|
||||
data: data.map((i) => ({x: i.week, y: i[this.type]})),
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 0,
|
||||
fill: 'start',
|
||||
backgroundColor: colors[this.type],
|
||||
borderWidth: 0,
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
updateOtherCharts(event, reset) {
|
||||
const minVal = event.chart.options.scales.x.min;
|
||||
const maxVal = event.chart.options.scales.x.max;
|
||||
if (reset) {
|
||||
this.xAxisMin = this.xAxisStart;
|
||||
this.xAxisMax = this.xAxisEnd;
|
||||
this.sortContributors();
|
||||
} else if (minVal) {
|
||||
this.xAxisMin = minVal;
|
||||
this.xAxisMax = maxVal;
|
||||
this.sortContributors();
|
||||
}
|
||||
},
|
||||
|
||||
getOptions(type) {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove', 'dblclick'],
|
||||
plugins: {
|
||||
title: {
|
||||
display: type === 'main',
|
||||
text: 'drag: zoom, shift+drag: pan, double click: reset zoom',
|
||||
color: colors.title,
|
||||
position: 'top',
|
||||
align: 'center',
|
||||
},
|
||||
customEventListener: {
|
||||
chartType: type,
|
||||
instance: this,
|
||||
},
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
zoom: {
|
||||
pan: {
|
||||
enabled: true,
|
||||
modifierKey: 'shift',
|
||||
mode: 'x',
|
||||
threshold: 20,
|
||||
onPanComplete: this.updateOtherCharts,
|
||||
},
|
||||
limits: {
|
||||
x: {
|
||||
// Check https://www.chartjs.org/chartjs-plugin-zoom/latest/guide/options.html#scale-limits
|
||||
// to know what each option means
|
||||
min: 'original',
|
||||
max: 'original',
|
||||
|
||||
// number of milliseconds in 2 weeks. Minimum x range will be 2 weeks when you zoom on the graph
|
||||
minRange: 2 * 7 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
zoom: {
|
||||
drag: {
|
||||
enabled: type === 'main',
|
||||
},
|
||||
pinch: {
|
||||
enabled: type === 'main',
|
||||
},
|
||||
mode: 'x',
|
||||
onZoomComplete: this.updateOtherCharts,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
min: this.xAxisMin,
|
||||
max: this.xAxisMax,
|
||||
type: 'time',
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
time: {
|
||||
minUnit: 'month',
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
maxTicksLimit: type === 'main' ? 12 : 6,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
min: 0,
|
||||
max: type === 'main' ? this.maxMainGraph() : this.maxContributorGraph(),
|
||||
ticks: {
|
||||
maxTicksLimit: type === 'main' ? 6 : 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="ui header gt-df gt-ac gt-sb">
|
||||
<div>
|
||||
<relative-time
|
||||
v-if="xAxisMin > 0"
|
||||
format="datetime"
|
||||
year="numeric"
|
||||
month="short"
|
||||
day="numeric"
|
||||
weekday=""
|
||||
:datetime="new Date(xAxisMin)"
|
||||
>
|
||||
{{ new Date(xAxisMin) }}
|
||||
</relative-time>
|
||||
{{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: "-" }}
|
||||
<relative-time
|
||||
v-if="xAxisMax > 0"
|
||||
format="datetime"
|
||||
year="numeric"
|
||||
month="short"
|
||||
day="numeric"
|
||||
weekday=""
|
||||
:datetime="new Date(xAxisMax)"
|
||||
>
|
||||
{{ new Date(xAxisMax) }}
|
||||
</relative-time>
|
||||
</div>
|
||||
<div>
|
||||
<!-- Contribution type -->
|
||||
<div class="ui dropdown jump" id="repo-contributors">
|
||||
<div class="ui basic compact button">
|
||||
<span class="text">
|
||||
{{ locale.filterLabel }} <strong>{{ locale.contributionType[type] }}</strong>
|
||||
<svg-icon name="octicon-triangle-down" :size="14"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="menu">
|
||||
<div :class="['item', {'active': type === 'commits'}]">
|
||||
{{ locale.contributionType.commits }}
|
||||
</div>
|
||||
<div :class="['item', {'active': type === 'additions'}]">
|
||||
{{ locale.contributionType.additions }}
|
||||
</div>
|
||||
<div :class="['item', {'active': type === 'deletions'}]">
|
||||
{{ locale.contributionType.deletions }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
<div class="gt-df ui segment main-graph">
|
||||
<div v-if="isLoading || errorText !== ''" class="gt-tc gt-m-auto">
|
||||
<div v-if="isLoading">
|
||||
<SvgIcon name="octicon-sync" class="gt-mr-3 job-status-rotate"/>
|
||||
{{ locale.loadingInfo }}
|
||||
</div>
|
||||
<div v-else class="text red">
|
||||
<SvgIcon name="octicon-x-circle-fill"/>
|
||||
{{ errorText }}
|
||||
</div>
|
||||
</div>
|
||||
<ChartLine
|
||||
v-memo="[totalStats.weeks, type]" v-if="Object.keys(totalStats).length !== 0"
|
||||
:data="toGraphData(totalStats.weeks)" :options="getOptions('main')"
|
||||
/>
|
||||
</div>
|
||||
<div class="contributor-grid">
|
||||
<div
|
||||
v-for="(contributor, index) in sortedContributors" :key="index" class="stats-table"
|
||||
v-memo="[sortedContributors, type]"
|
||||
>
|
||||
<div class="ui top attached header gt-df gt-f1">
|
||||
<b class="ui right">#{{ index + 1 }}</b>
|
||||
<a :href="contributor.home_link">
|
||||
<img class="ui avatar gt-vm" height="40" width="40" :src="contributor.avatar_link">
|
||||
</a>
|
||||
<div class="gt-ml-3">
|
||||
<a v-if="contributor.home_link !== ''" :href="contributor.home_link"><h4>{{ contributor.name }}</h4></a>
|
||||
<h4 v-else class="contributor-name">
|
||||
{{ contributor.name }}
|
||||
</h4>
|
||||
<p class="gt-font-12 gt-df gt-gap-2">
|
||||
<strong v-if="contributor.total_commits">{{ contributor.total_commits.toLocaleString() }} {{ locale.contributionType.commits }}</strong>
|
||||
<strong v-if="contributor.total_additions" class="text green">{{ contributor.total_additions.toLocaleString() }}++ </strong>
|
||||
<strong v-if="contributor.total_deletions" class="text red">
|
||||
{{ contributor.total_deletions.toLocaleString() }}--</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui attached segment">
|
||||
<div>
|
||||
<ChartLine
|
||||
:data="toGraphData(contributor.weeks)"
|
||||
:options="getOptions('contributor')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.main-graph {
|
||||
height: 260px;
|
||||
}
|
||||
.contributor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.contributor-name {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
|
@ -1,14 +1,13 @@
|
|||
import $ from 'jquery';
|
||||
import {initCompLabelEdit} from './comp/LabelEdit.js';
|
||||
import {toggleElem} from '../utils/dom.js';
|
||||
|
||||
export function initCommonOrganization() {
|
||||
if ($('.organization').length === 0) {
|
||||
if (!document.querySelectorAll('.organization').length) {
|
||||
return;
|
||||
}
|
||||
|
||||
$('.organization.settings.options #org_name').on('input', function () {
|
||||
const nameChanged = $(this).val().toLowerCase() !== $(this).attr('data-org-name').toLowerCase();
|
||||
document.querySelector('.organization.settings.options #org_name')?.addEventListener('input', function () {
|
||||
const nameChanged = this.value.toLowerCase() !== this.getAttribute('data-org-name').toLowerCase();
|
||||
toggleElem('#org-name-change-prompt', nameChanged);
|
||||
});
|
||||
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import $ from 'jquery';
|
||||
|
||||
export function handleGlobalEnterQuickSubmit(target) {
|
||||
const form = target.closest('form');
|
||||
if (form) {
|
||||
|
@ -8,14 +6,9 @@ export function handleGlobalEnterQuickSubmit(target) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (form.classList.contains('form-fetch-action')) {
|
||||
form.dispatchEvent(new SubmitEvent('submit', {bubbles: true, cancelable: true}));
|
||||
return;
|
||||
}
|
||||
|
||||
// here use the event to trigger the submit event (instead of calling `submit()` method directly)
|
||||
// otherwise the `areYouSure` handler won't be executed, then there will be an annoying "confirm to leave" dialog
|
||||
$(form).trigger('submit');
|
||||
form.dispatchEvent(new SubmitEvent('submit', {bubbles: true, cancelable: true}));
|
||||
} else {
|
||||
// if no form, then the editor is for an AJAX request, dispatch an event to the target, let the target's event handler to do the AJAX request.
|
||||
// the 'ce-' prefix means this is a CustomEvent
|
||||
|
|
|
@ -1,43 +1,41 @@
|
|||
import $ from 'jquery';
|
||||
import {POST} from '../../modules/fetch.js';
|
||||
import {hideElem, showElem, toggleElem} from '../../utils/dom.js';
|
||||
|
||||
const {csrfToken} = window.config;
|
||||
|
||||
export function initCompWebHookEditor() {
|
||||
if ($('.new.webhook').length === 0) {
|
||||
if (!document.querySelectorAll('.new.webhook').length) {
|
||||
return;
|
||||
}
|
||||
|
||||
$('.events.checkbox input').on('change', function () {
|
||||
if ($(this).is(':checked')) {
|
||||
showElem($('.events.fields'));
|
||||
}
|
||||
});
|
||||
$('.non-events.checkbox input').on('change', function () {
|
||||
if ($(this).is(':checked')) {
|
||||
hideElem($('.events.fields'));
|
||||
}
|
||||
});
|
||||
for (const input of document.querySelectorAll('.events.checkbox input')) {
|
||||
input.addEventListener('change', function () {
|
||||
if (this.checked) {
|
||||
showElem('.events.fields');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const input of document.querySelectorAll('.non-events.checkbox input')) {
|
||||
input.addEventListener('change', function () {
|
||||
if (this.checked) {
|
||||
hideElem('.events.fields');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const updateContentType = function () {
|
||||
const visible = $('#http_method').val() === 'POST';
|
||||
toggleElem($('#content_type').parent().parent(), visible);
|
||||
const visible = document.getElementById('http_method').value === 'POST';
|
||||
toggleElem(document.getElementById('content_type').parentNode.parentNode, visible);
|
||||
};
|
||||
updateContentType();
|
||||
$('#http_method').on('change', () => {
|
||||
updateContentType();
|
||||
});
|
||||
|
||||
document.getElementById('http_method').addEventListener('change', updateContentType);
|
||||
|
||||
// Test delivery
|
||||
$('#test-delivery').on('click', function () {
|
||||
const $this = $(this);
|
||||
$this.addClass('loading disabled');
|
||||
$.post($this.data('link'), {
|
||||
_csrf: csrfToken
|
||||
}).done(
|
||||
setTimeout(() => {
|
||||
window.location.href = $this.data('redirect');
|
||||
}, 5000)
|
||||
);
|
||||
document.getElementById('test-delivery')?.addEventListener('click', async function () {
|
||||
this.classList.add('loading', 'disabled');
|
||||
await POST(this.getAttribute('data-link'));
|
||||
setTimeout(() => {
|
||||
window.location.href = this.getAttribute('data-redirect');
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
import $ from 'jquery';
|
||||
import {createApp} from 'vue';
|
||||
import ContextPopup from '../components/ContextPopup.vue';
|
||||
import {parseIssueHref} from '../utils.js';
|
||||
import {createTippy} from '../modules/tippy.js';
|
||||
|
||||
export function initContextPopups() {
|
||||
const refIssues = $('.ref-issue');
|
||||
const refIssues = document.querySelectorAll('.ref-issue');
|
||||
attachRefIssueContextPopup(refIssues);
|
||||
}
|
||||
|
||||
|
|
28
web_src/js/features/contributors.js
Normal file
28
web_src/js/features/contributors.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
import {createApp} from 'vue';
|
||||
|
||||
export async function initRepoContributors() {
|
||||
const el = document.getElementById('repo-contributors-chart');
|
||||
if (!el) return;
|
||||
|
||||
const {default: RepoContributors} = await import(/* webpackChunkName: "contributors-graph" */'../components/RepoContributors.vue');
|
||||
try {
|
||||
const View = createApp(RepoContributors, {
|
||||
locale: {
|
||||
filterLabel: el.getAttribute('data-locale-filter-label'),
|
||||
contributionType: {
|
||||
commits: el.getAttribute('data-locale-contribution-type-commits'),
|
||||
additions: el.getAttribute('data-locale-contribution-type-additions'),
|
||||
deletions: el.getAttribute('data-locale-contribution-type-deletions'),
|
||||
},
|
||||
|
||||
loadingTitle: el.getAttribute('data-locale-loading-title'),
|
||||
loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'),
|
||||
loadingInfo: el.getAttribute('data-locale-loading-info'),
|
||||
}
|
||||
});
|
||||
View.mount(el);
|
||||
} catch (err) {
|
||||
console.error('RepoContributors failed to load', err);
|
||||
el.textContent = el.getAttribute('data-locale-component-failed-to-load');
|
||||
}
|
||||
}
|
|
@ -194,7 +194,7 @@ export function initRepoCodeView() {
|
|||
const blob = await $.get(`${url}?${query}&anchor=${anchor}`);
|
||||
currentTarget.closest('tr').outerHTML = blob;
|
||||
});
|
||||
$(document).on('click', '.copy-line-permalink', async (e) => {
|
||||
await clippie(toAbsoluteUrl(e.currentTarget.getAttribute('data-url')));
|
||||
$(document).on('click', '.copy-line-permalink', async ({currentTarget}) => {
|
||||
await clippie(toAbsoluteUrl(currentTarget.getAttribute('data-url')));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -69,16 +69,12 @@ function initRepoIssueListCheckboxes() {
|
|||
}
|
||||
}
|
||||
|
||||
updateIssuesMeta(
|
||||
url,
|
||||
action,
|
||||
issueIDs,
|
||||
elementId,
|
||||
).then(() => {
|
||||
try {
|
||||
await updateIssuesMeta(url, action, issueIDs, elementId);
|
||||
window.location.reload();
|
||||
}).catch((reason) => {
|
||||
showErrorToast(reason.responseJSON.error);
|
||||
});
|
||||
} catch (err) {
|
||||
showErrorToast(err.responseJSON?.error ?? err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -344,19 +344,15 @@ export async function updateIssuesMeta(url, action, issueIds, elementId) {
|
|||
export function initRepoIssueComments() {
|
||||
if ($('.repository.view.issue .timeline').length === 0) return;
|
||||
|
||||
$('.re-request-review').on('click', function (e) {
|
||||
$('.re-request-review').on('click', async function (e) {
|
||||
e.preventDefault();
|
||||
const url = $(this).data('update-url');
|
||||
const issueId = $(this).data('issue-id');
|
||||
const id = $(this).data('id');
|
||||
const isChecked = $(this).hasClass('checked');
|
||||
|
||||
updateIssuesMeta(
|
||||
url,
|
||||
isChecked ? 'detach' : 'attach',
|
||||
issueId,
|
||||
id,
|
||||
).then(() => window.location.reload());
|
||||
await updateIssuesMeta(url, isChecked ? 'detach' : 'attach', issueId, id);
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
$(document).on('click', (event) => {
|
||||
|
|
|
@ -205,12 +205,15 @@ export function initRepoCommentForm() {
|
|||
$listMenu.find('.no-select.item').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
if (hasUpdateAction) {
|
||||
updateIssuesMeta(
|
||||
$listMenu.data('update-url'),
|
||||
'clear',
|
||||
$listMenu.data('issue-id'),
|
||||
'',
|
||||
).then(reloadConfirmDraftComment);
|
||||
(async () => {
|
||||
await updateIssuesMeta(
|
||||
$listMenu.data('update-url'),
|
||||
'clear',
|
||||
$listMenu.data('issue-id'),
|
||||
'',
|
||||
);
|
||||
reloadConfirmDraftComment();
|
||||
})();
|
||||
}
|
||||
|
||||
$(this).parent().find('.item').each(function () {
|
||||
|
@ -248,12 +251,15 @@ export function initRepoCommentForm() {
|
|||
|
||||
$(this).addClass('selected active');
|
||||
if (hasUpdateAction) {
|
||||
updateIssuesMeta(
|
||||
$menu.data('update-url'),
|
||||
'',
|
||||
$menu.data('issue-id'),
|
||||
$(this).data('id'),
|
||||
).then(reloadConfirmDraftComment);
|
||||
(async () => {
|
||||
await updateIssuesMeta(
|
||||
$menu.data('update-url'),
|
||||
'',
|
||||
$menu.data('issue-id'),
|
||||
$(this).data('id'),
|
||||
);
|
||||
reloadConfirmDraftComment();
|
||||
})();
|
||||
}
|
||||
|
||||
let icon = '';
|
||||
|
@ -281,12 +287,15 @@ export function initRepoCommentForm() {
|
|||
});
|
||||
|
||||
if (hasUpdateAction) {
|
||||
updateIssuesMeta(
|
||||
$menu.data('update-url'),
|
||||
'',
|
||||
$menu.data('issue-id'),
|
||||
$(this).data('id'),
|
||||
).then(reloadConfirmDraftComment);
|
||||
(async () => {
|
||||
await updateIssuesMeta(
|
||||
$menu.data('update-url'),
|
||||
'',
|
||||
$menu.data('issue-id'),
|
||||
$(this).data('id'),
|
||||
);
|
||||
reloadConfirmDraftComment();
|
||||
})();
|
||||
}
|
||||
|
||||
$list.find('.selected').html('');
|
||||
|
|
|
@ -1,12 +1,10 @@
|
|||
import $ from 'jquery';
|
||||
|
||||
export function initSshKeyFormParser() {
|
||||
// Parse SSH Key
|
||||
$('#ssh-key-content').on('change paste keyup', function () {
|
||||
const arrays = $(this).val().split(' ');
|
||||
const $title = $('#ssh-key-title');
|
||||
if ($title.val() === '' && arrays.length === 3 && arrays[2] !== '') {
|
||||
$title.val(arrays[2]);
|
||||
// Parse SSH Key
|
||||
document.getElementById('ssh-key-content')?.addEventListener('input', function () {
|
||||
const arrays = this.value.split(' ');
|
||||
const title = document.getElementById('ssh-key-title');
|
||||
if (!title.value && arrays.length === 3 && arrays[2] !== '') {
|
||||
title.value = arrays[2];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,18 +1,19 @@
|
|||
import $ from 'jquery';
|
||||
import {hideElem, showElem} from '../utils/dom.js';
|
||||
|
||||
export function initUserSettings() {
|
||||
if ($('.user.settings.profile').length > 0) {
|
||||
$('#username').on('keyup', function () {
|
||||
const $prompt = $('#name-change-prompt');
|
||||
const $prompt_redirect = $('#name-change-redirect-prompt');
|
||||
if ($(this).val().toString().toLowerCase() !== $(this).data('name').toString().toLowerCase()) {
|
||||
showElem($prompt);
|
||||
showElem($prompt_redirect);
|
||||
} else {
|
||||
hideElem($prompt);
|
||||
hideElem($prompt_redirect);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (document.querySelectorAll('.user.settings.profile').length === 0) return;
|
||||
|
||||
const usernameInput = document.getElementById('username');
|
||||
if (!usernameInput) return;
|
||||
usernameInput.addEventListener('input', function () {
|
||||
const prompt = document.getElementById('name-change-prompt');
|
||||
const promptRedirect = document.getElementById('name-change-redirect-prompt');
|
||||
if (this.value.toLowerCase() !== this.getAttribute('data-name').toLowerCase()) {
|
||||
showElem(prompt);
|
||||
showElem(promptRedirect);
|
||||
} else {
|
||||
hideElem(prompt);
|
||||
hideElem(promptRedirect);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
import * as htmx from 'htmx.org';
|
||||
import {showErrorToast} from './modules/toast.js';
|
||||
|
||||
// https://github.com/bigskysoftware/idiomorph#htmx
|
||||
import 'idiomorph/dist/idiomorph-ext.js';
|
||||
|
||||
// https://htmx.org/reference/#config
|
||||
htmx.config.requestClass = 'is-loading';
|
||||
htmx.config.scrollIntoViewOnBoost = false;
|
||||
|
|
|
@ -83,6 +83,7 @@ import {initGiteaFomantic} from './modules/fomantic.js';
|
|||
import {onDomReady} from './utils/dom.js';
|
||||
import {initRepoIssueList} from './features/repo-issue-list.js';
|
||||
import {initCommonIssueListQuickGoto} from './features/common-issue-list.js';
|
||||
import {initRepoContributors} from './features/contributors.js';
|
||||
import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.js';
|
||||
import {initDirAuto} from './modules/dirauto.js';
|
||||
|
||||
|
@ -172,6 +173,7 @@ onDomReady(() => {
|
|||
initRepoWikiForm();
|
||||
initRepository();
|
||||
initRepositoryActionView();
|
||||
initRepoContributors();
|
||||
|
||||
initCommitStatuses();
|
||||
initCaptcha();
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import $ from 'jquery';
|
||||
import {POST} from '../modules/fetch.js';
|
||||
|
||||
const preventListener = (e) => e.preventDefault();
|
||||
|
||||
|
@ -55,12 +55,11 @@ export function initMarkupTasklist() {
|
|||
const updateUrl = editContentZone.getAttribute('data-update-url');
|
||||
const context = editContentZone.getAttribute('data-context');
|
||||
|
||||
await $.post(updateUrl, {
|
||||
ignore_attachments: true,
|
||||
_csrf: window.config.csrfToken,
|
||||
content: newContent,
|
||||
context
|
||||
});
|
||||
const requestBody = new FormData();
|
||||
requestBody.append('ignore_attachments', 'true');
|
||||
requestBody.append('content', newContent);
|
||||
requestBody.append('context', context);
|
||||
await POST(updateUrl, {data: requestBody});
|
||||
|
||||
rawContent.textContent = newContent;
|
||||
} catch (err) {
|
||||
|
|
|
@ -8,19 +8,17 @@ const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
|
|||
// fetch wrapper, use below method name functions and the `data` option to pass in data
|
||||
// which will automatically set an appropriate headers. For json content, only object
|
||||
// and array types are currently supported.
|
||||
export function request(url, {method = 'GET', headers = {}, data, body, ...other} = {}) {
|
||||
let contentType;
|
||||
if (!body) {
|
||||
if (data instanceof FormData || data instanceof URLSearchParams) {
|
||||
body = data;
|
||||
} else if (isObject(data) || Array.isArray(data)) {
|
||||
contentType = 'application/json';
|
||||
body = JSON.stringify(data);
|
||||
}
|
||||
export function request(url, {method = 'GET', data, headers = {}, ...other} = {}) {
|
||||
let body, contentType;
|
||||
if (data instanceof FormData || data instanceof URLSearchParams) {
|
||||
body = data;
|
||||
} else if (isObject(data) || Array.isArray(data)) {
|
||||
contentType = 'application/json';
|
||||
body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
const headersMerged = new Headers({
|
||||
...(!safeMethods.has(method.toUpperCase()) && {'x-csrf-token': csrfToken}),
|
||||
...(!safeMethods.has(method) && {'x-csrf-token': csrfToken}),
|
||||
...(contentType && {'content-type': contentType}),
|
||||
});
|
||||
|
||||
|
@ -31,8 +29,8 @@ export function request(url, {method = 'GET', headers = {}, data, body, ...other
|
|||
return fetch(url, {
|
||||
method,
|
||||
headers: headersMerged,
|
||||
...(body && {body}),
|
||||
...other,
|
||||
...(body && {body}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
46
web_src/js/utils/time.js
Normal file
46
web_src/js/utils/time.js
Normal file
|
@ -0,0 +1,46 @@
|
|||
import dayjs from 'dayjs';
|
||||
|
||||
// Returns an array of millisecond-timestamps of start-of-week days (Sundays)
|
||||
export function startDaysBetween(startDate, endDate) {
|
||||
// Ensure the start date is a Sunday
|
||||
while (startDate.getDay() !== 0) {
|
||||
startDate.setDate(startDate.getDate() + 1);
|
||||
}
|
||||
|
||||
const start = dayjs(startDate);
|
||||
const end = dayjs(endDate);
|
||||
const startDays = [];
|
||||
|
||||
let current = start;
|
||||
while (current.isBefore(end)) {
|
||||
startDays.push(current.valueOf());
|
||||
// we are adding 7 * 24 hours instead of 1 week because we don't want
|
||||
// date library to use local time zone to calculate 1 week from now.
|
||||
// local time zone is problematic because of daylight saving time (dst)
|
||||
// used on some countries
|
||||
current = current.add(7 * 24, 'hour');
|
||||
}
|
||||
|
||||
return startDays;
|
||||
}
|
||||
|
||||
export function firstStartDateAfterDate(inputDate) {
|
||||
if (!(inputDate instanceof Date)) {
|
||||
throw new Error('Invalid date');
|
||||
}
|
||||
const dayOfWeek = inputDate.getDay();
|
||||
const daysUntilSunday = 7 - dayOfWeek;
|
||||
const resultDate = new Date(inputDate.getTime());
|
||||
resultDate.setDate(resultDate.getDate() + daysUntilSunday);
|
||||
return resultDate.valueOf();
|
||||
}
|
||||
|
||||
export function fillEmptyStartDaysWithZeroes(startDays, data) {
|
||||
const result = {};
|
||||
|
||||
for (const startDay of startDays) {
|
||||
result[startDay] = data[startDay] || {'week': startDay, 'additions': 0, 'deletions': 0, 'commits': 0};
|
||||
}
|
||||
|
||||
return Object.values(result);
|
||||
}
|
15
web_src/js/utils/time.test.js
Normal file
15
web_src/js/utils/time.test.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
import {startDaysBetween} from './time.js';
|
||||
|
||||
test('startDaysBetween', () => {
|
||||
expect(startDaysBetween(new Date('2024-02-15'), new Date('2024-04-18'))).toEqual([
|
||||
1708214400000,
|
||||
1708819200000,
|
||||
1709424000000,
|
||||
1710028800000,
|
||||
1710633600000,
|
||||
1711238400000,
|
||||
1711843200000,
|
||||
1712448000000,
|
||||
1713052800000,
|
||||
]);
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue