mirror of https://github.com/go-gitea/gitea.git
Migrate vue components to setup (#32329)
Migrated a handful Vue components to the `setup` syntax using composition api as it has better Typescript support and is becoming the new default in the Vue ecosystem. - [x] ActionRunStatus.vue - [x] ActivityHeatmap.vue - [x] ContextPopup.vue - [x] DiffFileList.vue - [x] DiffFileTree.vue - [x] DiffFileTreeItem.vue - [x] PullRequestMergeForm.vue - [x] RepoActivityTopAuthors.vue - [x] RepoCodeFrequency.vue - [x] RepoRecentCommits.vue - [x] ScopedAccessTokenSelector.vue Left some larger components untouched for now to not go to crazy in this single PR: - [ ] DiffCommitSelector.vue - [ ] RepoActionView.vue - [ ] RepoContributors.vue - [ ] DashboardRepoList.vue - [ ] RepoBranchTagSelector.vue
This commit is contained in:
parent
a920fcfd91
commit
348d1d0f32
|
@ -2,31 +2,21 @@
|
||||||
Please also update the template file above if this vue is modified.
|
Please also update the template file above if this vue is modified.
|
||||||
action status accepted: success, skipped, waiting, blocked, running, failure, cancelled, unknown
|
action status accepted: success, skipped, waiting, blocked, running, failure, cancelled, unknown
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import {SvgIcon} from '../svg.ts';
|
import {SvgIcon} from '../svg.ts';
|
||||||
|
|
||||||
export default {
|
withDefaults(defineProps<{
|
||||||
components: {SvgIcon},
|
status: '',
|
||||||
props: {
|
size?: number,
|
||||||
status: {
|
className?: string,
|
||||||
type: String,
|
localeStatus?: string,
|
||||||
required: true,
|
}>(), {
|
||||||
},
|
size: 16,
|
||||||
size: {
|
className: undefined,
|
||||||
type: Number,
|
localeStatus: undefined,
|
||||||
default: 16,
|
});
|
||||||
},
|
|
||||||
className: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
localeStatus: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span class="tw-flex tw-items-center" :data-tooltip-content="localeStatus" v-if="status">
|
<span class="tw-flex tw-items-center" :data-tooltip-content="localeStatus" v-if="status">
|
||||||
<SvgIcon name="octicon-check-circle-fill" class="text green" :size="size" :class-name="className" v-if="status === 'success'"/>
|
<SvgIcon name="octicon-check-circle-fill" class="text green" :size="size" :class-name="className" v-if="status === 'success'"/>
|
||||||
|
|
|
@ -1,58 +1,56 @@
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
// TODO: Switch to upstream after https://github.com/razorness/vue3-calendar-heatmap/pull/34 is merged
|
// TODO: Switch to upstream after https://github.com/razorness/vue3-calendar-heatmap/pull/34 is merged
|
||||||
import {CalendarHeatmap} from '@silverwind/vue3-calendar-heatmap';
|
import {CalendarHeatmap} from '@silverwind/vue3-calendar-heatmap';
|
||||||
|
import {onMounted, ref} from 'vue';
|
||||||
|
import type {Value as HeatmapValue, Locale as HeatmapLocale} from '@silverwind/vue3-calendar-heatmap';
|
||||||
|
|
||||||
export default {
|
defineProps<{
|
||||||
components: {CalendarHeatmap},
|
values?: HeatmapValue[];
|
||||||
props: {
|
locale: {
|
||||||
values: {
|
textTotalContributions: string;
|
||||||
type: Array,
|
heatMapLocale: Partial<HeatmapLocale>;
|
||||||
default: () => [],
|
noDataText: string;
|
||||||
},
|
tooltipUnit: string;
|
||||||
locale: {
|
};
|
||||||
type: Object,
|
}>();
|
||||||
default: () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: () => ({
|
|
||||||
colorRange: [
|
|
||||||
'var(--color-secondary-alpha-60)',
|
|
||||||
'var(--color-secondary-alpha-60)',
|
|
||||||
'var(--color-primary-light-4)',
|
|
||||||
'var(--color-primary-light-2)',
|
|
||||||
'var(--color-primary)',
|
|
||||||
'var(--color-primary-dark-2)',
|
|
||||||
'var(--color-primary-dark-4)',
|
|
||||||
],
|
|
||||||
endDate: new Date(),
|
|
||||||
}),
|
|
||||||
mounted() {
|
|
||||||
// work around issue with first legend color being rendered twice and legend cut off
|
|
||||||
const legend = document.querySelector('.vch__external-legend-wrapper');
|
|
||||||
legend.setAttribute('viewBox', '12 0 80 10');
|
|
||||||
legend.style.marginRight = '-12px';
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
handleDayClick(e) {
|
|
||||||
// Reset filter if same date is clicked
|
|
||||||
const params = new URLSearchParams(document.location.search);
|
|
||||||
const queryDate = params.get('date');
|
|
||||||
// Timezone has to be stripped because toISOString() converts to UTC
|
|
||||||
const clickedDate = new Date(e.date - (e.date.getTimezoneOffset() * 60000)).toISOString().substring(0, 10);
|
|
||||||
|
|
||||||
if (queryDate && queryDate === clickedDate) {
|
const colorRange = [
|
||||||
params.delete('date');
|
'var(--color-secondary-alpha-60)',
|
||||||
} else {
|
'var(--color-secondary-alpha-60)',
|
||||||
params.set('date', clickedDate);
|
'var(--color-primary-light-4)',
|
||||||
}
|
'var(--color-primary-light-2)',
|
||||||
|
'var(--color-primary)',
|
||||||
|
'var(--color-primary-dark-2)',
|
||||||
|
'var(--color-primary-dark-4)',
|
||||||
|
];
|
||||||
|
|
||||||
params.delete('page');
|
const endDate = ref(new Date());
|
||||||
|
|
||||||
const newSearch = params.toString();
|
onMounted(() => {
|
||||||
window.location.search = newSearch.length ? `?${newSearch}` : '';
|
// work around issue with first legend color being rendered twice and legend cut off
|
||||||
},
|
const legend = document.querySelector<HTMLElement>('.vch__external-legend-wrapper');
|
||||||
},
|
legend.setAttribute('viewBox', '12 0 80 10');
|
||||||
};
|
legend.style.marginRight = '-12px';
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleDayClick(e: Event & {date: Date}) {
|
||||||
|
// Reset filter if same date is clicked
|
||||||
|
const params = new URLSearchParams(document.location.search);
|
||||||
|
const queryDate = params.get('date');
|
||||||
|
// Timezone has to be stripped because toISOString() converts to UTC
|
||||||
|
const clickedDate = new Date(e.date.getTime() - (e.date.getTimezoneOffset() * 60000)).toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
if (queryDate && queryDate === clickedDate) {
|
||||||
|
params.delete('date');
|
||||||
|
} else {
|
||||||
|
params.set('date', clickedDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.delete('page');
|
||||||
|
|
||||||
|
const newSearch = params.toString();
|
||||||
|
window.location.search = newSearch.length ? `?${newSearch}` : '';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="total-contributions">
|
<div class="total-contributions">
|
||||||
|
|
|
@ -1,100 +1,96 @@
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import {SvgIcon} from '../svg.ts';
|
import {SvgIcon} from '../svg.ts';
|
||||||
import {GET} from '../modules/fetch.ts';
|
import {GET} from '../modules/fetch.ts';
|
||||||
|
import {computed, onMounted, ref} from 'vue';
|
||||||
|
import type {Issue} from '../types';
|
||||||
|
|
||||||
const {appSubUrl, i18n} = window.config;
|
const {appSubUrl, i18n} = window.config;
|
||||||
|
|
||||||
export default {
|
const loading = ref(false);
|
||||||
components: {SvgIcon},
|
const issue = ref(null);
|
||||||
data: () => ({
|
const renderedLabels = ref('');
|
||||||
loading: false,
|
const i18nErrorOccurred = i18n.error_occurred;
|
||||||
issue: null,
|
const i18nErrorMessage = ref(null);
|
||||||
renderedLabels: '',
|
|
||||||
i18nErrorOccurred: i18n.error_occurred,
|
|
||||||
i18nErrorMessage: null,
|
|
||||||
}),
|
|
||||||
computed: {
|
|
||||||
createdAt() {
|
|
||||||
return new Date(this.issue.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'});
|
|
||||||
},
|
|
||||||
|
|
||||||
body() {
|
const createdAt = computed(() => new Date(issue.value.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'}));
|
||||||
const body = this.issue.body.replace(/\n+/g, ' ');
|
const body = computed(() => {
|
||||||
if (body.length > 85) {
|
const body = issue.value.body.replace(/\n+/g, ' ');
|
||||||
return `${body.substring(0, 85)}…`;
|
if (body.length > 85) {
|
||||||
}
|
return `${body.substring(0, 85)}…`;
|
||||||
return body;
|
}
|
||||||
},
|
return body;
|
||||||
|
});
|
||||||
|
|
||||||
icon() {
|
function getIssueIcon(issue: Issue) {
|
||||||
if (this.issue.pull_request !== null) {
|
if (issue.pull_request) {
|
||||||
if (this.issue.state === 'open') {
|
if (issue.state === 'open') {
|
||||||
if (this.issue.pull_request.draft === true) {
|
if (issue.pull_request.draft === true) {
|
||||||
return 'octicon-git-pull-request-draft'; // WIP PR
|
return 'octicon-git-pull-request-draft'; // WIP PR
|
||||||
}
|
|
||||||
return 'octicon-git-pull-request'; // Open PR
|
|
||||||
} else if (this.issue.pull_request.merged === true) {
|
|
||||||
return 'octicon-git-merge'; // Merged PR
|
|
||||||
}
|
|
||||||
return 'octicon-git-pull-request'; // Closed PR
|
|
||||||
} else if (this.issue.state === 'open') {
|
|
||||||
return 'octicon-issue-opened'; // Open Issue
|
|
||||||
}
|
}
|
||||||
return 'octicon-issue-closed'; // Closed Issue
|
return 'octicon-git-pull-request'; // Open PR
|
||||||
},
|
} else if (issue.pull_request.merged === true) {
|
||||||
|
return 'octicon-git-merge'; // Merged PR
|
||||||
|
}
|
||||||
|
return 'octicon-git-pull-request'; // Closed PR
|
||||||
|
} else if (issue.state === 'open') {
|
||||||
|
return 'octicon-issue-opened'; // Open Issue
|
||||||
|
}
|
||||||
|
return 'octicon-issue-closed'; // Closed Issue
|
||||||
|
}
|
||||||
|
|
||||||
color() {
|
function getIssueColor(issue: Issue) {
|
||||||
if (this.issue.pull_request !== null) {
|
if (issue.pull_request) {
|
||||||
if (this.issue.pull_request.draft === true) {
|
if (issue.pull_request.draft === true) {
|
||||||
return 'grey'; // WIP PR
|
return 'grey'; // WIP PR
|
||||||
} else if (this.issue.pull_request.merged === true) {
|
} else if (issue.pull_request.merged === true) {
|
||||||
return 'purple'; // Merged PR
|
return 'purple'; // Merged PR
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.issue.state === 'open') {
|
if (issue.state === 'open') {
|
||||||
return 'green'; // Open Issue
|
return 'green'; // Open Issue
|
||||||
}
|
}
|
||||||
return 'red'; // Closed Issue
|
return 'red'; // Closed Issue
|
||||||
},
|
}
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.$refs.root.addEventListener('ce-load-context-popup', (e) => {
|
|
||||||
const data = e.detail;
|
|
||||||
if (!this.loading && this.issue === null) {
|
|
||||||
this.load(data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async load(data) {
|
|
||||||
this.loading = true;
|
|
||||||
this.i18nErrorMessage = null;
|
|
||||||
|
|
||||||
try {
|
const root = ref<HTMLElement | null>(null);
|
||||||
const response = await GET(`${appSubUrl}/${data.owner}/${data.repo}/issues/${data.index}/info`); // backend: GetIssueInfo
|
|
||||||
const respJson = await response.json();
|
onMounted(() => {
|
||||||
if (!response.ok) {
|
root.value.addEventListener('ce-load-context-popup', (e: CustomEvent) => {
|
||||||
this.i18nErrorMessage = respJson.message ?? i18n.network_error;
|
const data = e.detail;
|
||||||
return;
|
if (!loading.value && issue.value === null) {
|
||||||
}
|
load(data);
|
||||||
this.issue = respJson.convertedIssue;
|
}
|
||||||
this.renderedLabels = respJson.renderedLabels;
|
});
|
||||||
} catch {
|
});
|
||||||
this.i18nErrorMessage = i18n.network_error;
|
|
||||||
} finally {
|
async function load(data) {
|
||||||
this.loading = false;
|
loading.value = true;
|
||||||
}
|
i18nErrorMessage.value = null;
|
||||||
},
|
|
||||||
},
|
try {
|
||||||
};
|
const response = await GET(`${appSubUrl}/${data.owner}/${data.repo}/issues/${data.index}/info`); // backend: GetIssueInfo
|
||||||
|
const respJson = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
i18nErrorMessage.value = respJson.message ?? i18n.network_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
issue.value = respJson.convertedIssue;
|
||||||
|
renderedLabels.value = respJson.renderedLabels;
|
||||||
|
} catch {
|
||||||
|
i18nErrorMessage.value = i18n.network_error;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="root">
|
<div ref="root">
|
||||||
<div v-if="loading" class="tw-h-12 tw-w-12 is-loading"/>
|
<div v-if="loading" class="tw-h-12 tw-w-12 is-loading"/>
|
||||||
<div v-if="!loading && issue !== null" class="tw-flex tw-flex-col tw-gap-2">
|
<div v-if="!loading && issue !== null" class="tw-flex tw-flex-col tw-gap-2">
|
||||||
<div class="tw-text-12">{{ issue.repository.full_name }} on {{ createdAt }}</div>
|
<div class="tw-text-12">{{ issue.repository.full_name }} on {{ createdAt }}</div>
|
||||||
<div class="flex-text-block">
|
<div class="flex-text-block">
|
||||||
<svg-icon :name="icon" :class="['text', color]"/>
|
<svg-icon :name="getIssueIcon(issue)" :class="['text', getIssueColor(issue)]"/>
|
||||||
<span class="issue-title tw-font-semibold tw-break-anywhere">
|
<span class="issue-title tw-font-semibold tw-break-anywhere">
|
||||||
{{ issue.title }}
|
{{ issue.title }}
|
||||||
<span class="index">#{{ issue.number }}</span>
|
<span class="index">#{{ issue.number }}</span>
|
||||||
|
|
|
@ -1,40 +1,42 @@
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
|
import {onMounted, onUnmounted} from 'vue';
|
||||||
import {loadMoreFiles} from '../features/repo-diff.ts';
|
import {loadMoreFiles} from '../features/repo-diff.ts';
|
||||||
import {diffTreeStore} from '../modules/stores.ts';
|
import {diffTreeStore} from '../modules/stores.ts';
|
||||||
|
|
||||||
export default {
|
const store = diffTreeStore();
|
||||||
data: () => {
|
|
||||||
return {store: diffTreeStore()};
|
onMounted(() => {
|
||||||
},
|
document.querySelector('#show-file-list-btn').addEventListener('click', toggleFileList);
|
||||||
mounted() {
|
});
|
||||||
document.querySelector('#show-file-list-btn').addEventListener('click', this.toggleFileList);
|
|
||||||
},
|
onUnmounted(() => {
|
||||||
unmounted() {
|
document.querySelector('#show-file-list-btn').removeEventListener('click', toggleFileList);
|
||||||
document.querySelector('#show-file-list-btn').removeEventListener('click', this.toggleFileList);
|
});
|
||||||
},
|
|
||||||
methods: {
|
function toggleFileList() {
|
||||||
toggleFileList() {
|
store.fileListIsVisible = !store.fileListIsVisible;
|
||||||
this.store.fileListIsVisible = !this.store.fileListIsVisible;
|
}
|
||||||
},
|
|
||||||
diffTypeToString(pType) {
|
function diffTypeToString(pType) {
|
||||||
const diffTypes = {
|
const diffTypes = {
|
||||||
1: 'add',
|
1: 'add',
|
||||||
2: 'modify',
|
2: 'modify',
|
||||||
3: 'del',
|
3: 'del',
|
||||||
4: 'rename',
|
4: 'rename',
|
||||||
5: 'copy',
|
5: 'copy',
|
||||||
};
|
};
|
||||||
return diffTypes[pType];
|
return diffTypes[pType];
|
||||||
},
|
}
|
||||||
diffStatsWidth(adds, dels) {
|
|
||||||
return `${adds / (adds + dels) * 100}%`;
|
function diffStatsWidth(adds, dels) {
|
||||||
},
|
return `${adds / (adds + dels) * 100}%`;
|
||||||
loadMoreData() {
|
}
|
||||||
loadMoreFiles(this.store.linkLoadMore);
|
|
||||||
},
|
function loadMoreData() {
|
||||||
},
|
loadMoreFiles(store.linkLoadMore);
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ol class="diff-stats tw-m-0" ref="root" v-if="store.fileListIsVisible">
|
<ol class="diff-stats tw-m-0" ref="root" v-if="store.fileListIsVisible">
|
||||||
<li v-for="file in store.files" :key="file.NameHash">
|
<li v-for="file in store.files" :key="file.NameHash">
|
||||||
|
|
|
@ -1,130 +1,137 @@
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import DiffFileTreeItem from './DiffFileTreeItem.vue';
|
import DiffFileTreeItem from './DiffFileTreeItem.vue';
|
||||||
import {loadMoreFiles} from '../features/repo-diff.ts';
|
import {loadMoreFiles} from '../features/repo-diff.ts';
|
||||||
import {toggleElem} from '../utils/dom.ts';
|
import {toggleElem} from '../utils/dom.ts';
|
||||||
import {diffTreeStore} from '../modules/stores.ts';
|
import {diffTreeStore} from '../modules/stores.ts';
|
||||||
import {setFileFolding} from '../features/file-fold.ts';
|
import {setFileFolding} from '../features/file-fold.ts';
|
||||||
|
import {computed, onMounted, onUnmounted} from 'vue';
|
||||||
|
|
||||||
const LOCAL_STORAGE_KEY = 'diff_file_tree_visible';
|
const LOCAL_STORAGE_KEY = 'diff_file_tree_visible';
|
||||||
|
|
||||||
export default {
|
const store = diffTreeStore();
|
||||||
components: {DiffFileTreeItem},
|
|
||||||
data: () => {
|
|
||||||
return {store: diffTreeStore()};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
fileTree() {
|
|
||||||
const result = [];
|
|
||||||
for (const file of this.store.files) {
|
|
||||||
// Split file into directories
|
|
||||||
const splits = file.Name.split('/');
|
|
||||||
let index = 0;
|
|
||||||
let parent = null;
|
|
||||||
let isFile = false;
|
|
||||||
for (const split of splits) {
|
|
||||||
index += 1;
|
|
||||||
// reached the end
|
|
||||||
if (index === splits.length) {
|
|
||||||
isFile = true;
|
|
||||||
}
|
|
||||||
let newParent = {
|
|
||||||
name: split,
|
|
||||||
children: [],
|
|
||||||
isFile,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isFile === true) {
|
const fileTree = computed(() => {
|
||||||
newParent.file = file;
|
const result = [];
|
||||||
}
|
for (const file of store.files) {
|
||||||
|
// Split file into directories
|
||||||
if (parent) {
|
const splits = file.Name.split('/');
|
||||||
// check if the folder already exists
|
let index = 0;
|
||||||
const existingFolder = parent.children.find(
|
let parent = null;
|
||||||
(x) => x.name === split,
|
let isFile = false;
|
||||||
);
|
for (const split of splits) {
|
||||||
if (existingFolder) {
|
index += 1;
|
||||||
newParent = existingFolder;
|
// reached the end
|
||||||
} else {
|
if (index === splits.length) {
|
||||||
parent.children.push(newParent);
|
isFile = true;
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const existingFolder = result.find((x) => x.name === split);
|
|
||||||
if (existingFolder) {
|
|
||||||
newParent = existingFolder;
|
|
||||||
} else {
|
|
||||||
result.push(newParent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parent = newParent;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const mergeChildIfOnlyOneDir = (entries) => {
|
let newParent = {
|
||||||
for (const entry of entries) {
|
name: split,
|
||||||
if (entry.children) {
|
children: [],
|
||||||
mergeChildIfOnlyOneDir(entry.children);
|
isFile,
|
||||||
}
|
} as {
|
||||||
if (entry.children.length === 1 && entry.children[0].isFile === false) {
|
name: string,
|
||||||
// Merge it to the parent
|
children: any[],
|
||||||
entry.name = `${entry.name}/${entry.children[0].name}`;
|
isFile: boolean,
|
||||||
entry.children = entry.children[0].children;
|
file?: any,
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
// Merge folders with just a folder as children in order to
|
|
||||||
// reduce the depth of our tree.
|
|
||||||
mergeChildIfOnlyOneDir(result);
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
// Default to true if unset
|
|
||||||
this.store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false';
|
|
||||||
document.querySelector('.diff-toggle-file-tree-button').addEventListener('click', this.toggleVisibility);
|
|
||||||
|
|
||||||
this.hashChangeListener = () => {
|
if (isFile === true) {
|
||||||
this.store.selectedItem = window.location.hash;
|
newParent.file = file;
|
||||||
this.expandSelectedFile();
|
|
||||||
};
|
|
||||||
this.hashChangeListener();
|
|
||||||
window.addEventListener('hashchange', this.hashChangeListener);
|
|
||||||
},
|
|
||||||
unmounted() {
|
|
||||||
document.querySelector('.diff-toggle-file-tree-button').removeEventListener('click', this.toggleVisibility);
|
|
||||||
window.removeEventListener('hashchange', this.hashChangeListener);
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
expandSelectedFile() {
|
|
||||||
// expand file if the selected file is folded
|
|
||||||
if (this.store.selectedItem) {
|
|
||||||
const box = document.querySelector(this.store.selectedItem);
|
|
||||||
const folded = box?.getAttribute('data-folded') === 'true';
|
|
||||||
if (folded) setFileFolding(box, box.querySelector('.fold-file'), false);
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
toggleVisibility() {
|
if (parent) {
|
||||||
this.updateVisibility(!this.store.fileTreeIsVisible);
|
// check if the folder already exists
|
||||||
},
|
const existingFolder = parent.children.find(
|
||||||
updateVisibility(visible) {
|
(x) => x.name === split,
|
||||||
this.store.fileTreeIsVisible = visible;
|
);
|
||||||
localStorage.setItem(LOCAL_STORAGE_KEY, this.store.fileTreeIsVisible);
|
if (existingFolder) {
|
||||||
this.updateState(this.store.fileTreeIsVisible);
|
newParent = existingFolder;
|
||||||
},
|
} else {
|
||||||
updateState(visible) {
|
parent.children.push(newParent);
|
||||||
const btn = document.querySelector('.diff-toggle-file-tree-button');
|
}
|
||||||
const [toShow, toHide] = btn.querySelectorAll('.icon');
|
} else {
|
||||||
const tree = document.querySelector('#diff-file-tree');
|
const existingFolder = result.find((x) => x.name === split);
|
||||||
const newTooltip = btn.getAttribute(visible ? 'data-hide-text' : 'data-show-text');
|
if (existingFolder) {
|
||||||
btn.setAttribute('data-tooltip-content', newTooltip);
|
newParent = existingFolder;
|
||||||
toggleElem(tree, visible);
|
} else {
|
||||||
toggleElem(toShow, !visible);
|
result.push(newParent);
|
||||||
toggleElem(toHide, visible);
|
}
|
||||||
},
|
}
|
||||||
loadMoreData() {
|
parent = newParent;
|
||||||
loadMoreFiles(this.store.linkLoadMore);
|
}
|
||||||
},
|
}
|
||||||
},
|
const mergeChildIfOnlyOneDir = (entries) => {
|
||||||
};
|
for (const entry of entries) {
|
||||||
|
if (entry.children) {
|
||||||
|
mergeChildIfOnlyOneDir(entry.children);
|
||||||
|
}
|
||||||
|
if (entry.children.length === 1 && entry.children[0].isFile === false) {
|
||||||
|
// Merge it to the parent
|
||||||
|
entry.name = `${entry.name}/${entry.children[0].name}`;
|
||||||
|
entry.children = entry.children[0].children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Merge folders with just a folder as children in order to
|
||||||
|
// reduce the depth of our tree.
|
||||||
|
mergeChildIfOnlyOneDir(result);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Default to true if unset
|
||||||
|
store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false';
|
||||||
|
document.querySelector('.diff-toggle-file-tree-button').addEventListener('click', toggleVisibility);
|
||||||
|
|
||||||
|
hashChangeListener();
|
||||||
|
window.addEventListener('hashchange', hashChangeListener);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.querySelector('.diff-toggle-file-tree-button').removeEventListener('click', toggleVisibility);
|
||||||
|
window.removeEventListener('hashchange', hashChangeListener);
|
||||||
|
});
|
||||||
|
|
||||||
|
function hashChangeListener() {
|
||||||
|
store.selectedItem = window.location.hash;
|
||||||
|
expandSelectedFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandSelectedFile() {
|
||||||
|
// expand file if the selected file is folded
|
||||||
|
if (store.selectedItem) {
|
||||||
|
const box = document.querySelector(store.selectedItem);
|
||||||
|
const folded = box?.getAttribute('data-folded') === 'true';
|
||||||
|
if (folded) setFileFolding(box, box.querySelector('.fold-file'), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleVisibility() {
|
||||||
|
updateVisibility(!store.fileTreeIsVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVisibility(visible) {
|
||||||
|
store.fileTreeIsVisible = visible;
|
||||||
|
localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible);
|
||||||
|
updateState(store.fileTreeIsVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateState(visible) {
|
||||||
|
const btn = document.querySelector('.diff-toggle-file-tree-button');
|
||||||
|
const [toShow, toHide] = btn.querySelectorAll('.icon');
|
||||||
|
const tree = document.querySelector('#diff-file-tree');
|
||||||
|
const newTooltip = btn.getAttribute(visible ? 'data-hide-text' : 'data-show-text');
|
||||||
|
btn.setAttribute('data-tooltip-content', newTooltip);
|
||||||
|
toggleElem(tree, visible);
|
||||||
|
toggleElem(toShow, !visible);
|
||||||
|
toggleElem(toHide, visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMoreData() {
|
||||||
|
loadMoreFiles(store.linkLoadMore);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="store.fileTreeIsVisible" class="diff-file-tree-items">
|
<div v-if="store.fileTreeIsVisible" class="diff-file-tree-items">
|
||||||
<!-- only render the tree if we're visible. in many cases this is something that doesn't change very often -->
|
<!-- only render the tree if we're visible. in many cases this is something that doesn't change very often -->
|
||||||
|
@ -134,6 +141,7 @@ export default {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.diff-file-tree-items {
|
.diff-file-tree-items {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -1,33 +1,41 @@
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import {SvgIcon} from '../svg.ts';
|
import {SvgIcon} from '../svg.ts';
|
||||||
import {diffTreeStore} from '../modules/stores.ts';
|
import {diffTreeStore} from '../modules/stores.ts';
|
||||||
|
import {ref} from 'vue';
|
||||||
|
|
||||||
export default {
|
type File = {
|
||||||
components: {SvgIcon},
|
Name: string;
|
||||||
props: {
|
NameHash: string;
|
||||||
item: {
|
Type: number;
|
||||||
type: Object,
|
IsViewed: boolean;
|
||||||
required: true,
|
}
|
||||||
},
|
|
||||||
},
|
type Item = {
|
||||||
data: () => ({
|
name: string;
|
||||||
store: diffTreeStore(),
|
isFile: boolean;
|
||||||
collapsed: false,
|
file?: File;
|
||||||
}),
|
children?: Item[];
|
||||||
methods: {
|
|
||||||
getIconForDiffType(pType) {
|
|
||||||
const diffTypes = {
|
|
||||||
1: {name: 'octicon-diff-added', classes: ['text', 'green']},
|
|
||||||
2: {name: 'octicon-diff-modified', classes: ['text', 'yellow']},
|
|
||||||
3: {name: 'octicon-diff-removed', classes: ['text', 'red']},
|
|
||||||
4: {name: 'octicon-diff-renamed', classes: ['text', 'teal']},
|
|
||||||
5: {name: 'octicon-diff-renamed', classes: ['text', 'green']}, // there is no octicon for copied, so renamed should be ok
|
|
||||||
};
|
|
||||||
return diffTypes[pType];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
item: Item,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const store = diffTreeStore();
|
||||||
|
const collapsed = ref(false);
|
||||||
|
|
||||||
|
function getIconForDiffType(pType) {
|
||||||
|
const diffTypes = {
|
||||||
|
1: {name: 'octicon-diff-added', classes: ['text', 'green']},
|
||||||
|
2: {name: 'octicon-diff-modified', classes: ['text', 'yellow']},
|
||||||
|
3: {name: 'octicon-diff-removed', classes: ['text', 'red']},
|
||||||
|
4: {name: 'octicon-diff-renamed', classes: ['text', 'teal']},
|
||||||
|
5: {name: 'octicon-diff-renamed', classes: ['text', 'green']}, // there is no octicon for copied, so renamed should be ok
|
||||||
|
};
|
||||||
|
return diffTypes[pType];
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!--title instead of tooltip above as the tooltip needs too much work with the current methods, i.e. not being loaded or staying open for "too long"-->
|
<!--title instead of tooltip above as the tooltip needs too much work with the current methods, i.e. not being loaded or staying open for "too long"-->
|
||||||
<a
|
<a
|
||||||
|
|
|
@ -1,84 +1,83 @@
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
|
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
|
||||||
import {SvgIcon} from '../svg.ts';
|
import {SvgIcon} from '../svg.ts';
|
||||||
import {toggleElem} from '../utils/dom.ts';
|
import {toggleElem} from '../utils/dom.ts';
|
||||||
|
|
||||||
const {csrfToken, pageData} = window.config;
|
const {csrfToken, pageData} = window.config;
|
||||||
|
|
||||||
export default {
|
const mergeForm = ref(pageData.pullRequestMergeForm);
|
||||||
components: {SvgIcon},
|
|
||||||
data: () => ({
|
|
||||||
csrfToken,
|
|
||||||
mergeForm: pageData.pullRequestMergeForm,
|
|
||||||
|
|
||||||
mergeTitleFieldValue: '',
|
const mergeTitleFieldValue = ref('');
|
||||||
mergeMessageFieldValue: '',
|
const mergeMessageFieldValue = ref('');
|
||||||
deleteBranchAfterMerge: false,
|
const deleteBranchAfterMerge = ref(false);
|
||||||
autoMergeWhenSucceed: false,
|
const autoMergeWhenSucceed = ref(false);
|
||||||
|
|
||||||
mergeStyle: '',
|
const mergeStyle = ref('');
|
||||||
mergeStyleDetail: { // dummy only, these values will come from one of the mergeForm.mergeStyles
|
const mergeStyleDetail = ref({
|
||||||
hideMergeMessageTexts: false,
|
hideMergeMessageTexts: false,
|
||||||
textDoMerge: '',
|
textDoMerge: '',
|
||||||
mergeTitleFieldText: '',
|
mergeTitleFieldText: '',
|
||||||
mergeMessageFieldText: '',
|
mergeMessageFieldText: '',
|
||||||
hideAutoMerge: false,
|
hideAutoMerge: false,
|
||||||
},
|
});
|
||||||
mergeStyleAllowedCount: 0,
|
|
||||||
|
|
||||||
showMergeStyleMenu: false,
|
const mergeStyleAllowedCount = ref(0);
|
||||||
showActionForm: false,
|
|
||||||
}),
|
|
||||||
computed: {
|
|
||||||
mergeButtonStyleClass() {
|
|
||||||
if (this.mergeForm.allOverridableChecksOk) return 'primary';
|
|
||||||
return this.autoMergeWhenSucceed ? 'primary' : 'red';
|
|
||||||
},
|
|
||||||
forceMerge() {
|
|
||||||
return this.mergeForm.canMergeNow && !this.mergeForm.allOverridableChecksOk;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
mergeStyle(val) {
|
|
||||||
this.mergeStyleDetail = this.mergeForm.mergeStyles.find((e) => e.name === val);
|
|
||||||
for (const elem of document.querySelectorAll('[data-pull-merge-style]')) {
|
|
||||||
toggleElem(elem, elem.getAttribute('data-pull-merge-style') === val);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
created() {
|
|
||||||
this.mergeStyleAllowedCount = this.mergeForm.mergeStyles.reduce((v, msd) => v + (msd.allowed ? 1 : 0), 0);
|
|
||||||
|
|
||||||
let mergeStyle = this.mergeForm.mergeStyles.find((e) => e.allowed && e.name === this.mergeForm.defaultMergeStyle)?.name;
|
const showMergeStyleMenu = ref(false);
|
||||||
if (!mergeStyle) mergeStyle = this.mergeForm.mergeStyles.find((e) => e.allowed)?.name;
|
const showActionForm = ref(false);
|
||||||
this.switchMergeStyle(mergeStyle, !this.mergeForm.canMergeNow);
|
|
||||||
},
|
const mergeButtonStyleClass = computed(() => {
|
||||||
mounted() {
|
if (mergeForm.value.allOverridableChecksOk) return 'primary';
|
||||||
document.addEventListener('mouseup', this.hideMergeStyleMenu);
|
return autoMergeWhenSucceed.value ? 'primary' : 'red';
|
||||||
},
|
});
|
||||||
unmounted() {
|
|
||||||
document.removeEventListener('mouseup', this.hideMergeStyleMenu);
|
const forceMerge = computed(() => {
|
||||||
},
|
return mergeForm.value.canMergeNow && !mergeForm.value.allOverridableChecksOk;
|
||||||
methods: {
|
});
|
||||||
hideMergeStyleMenu() {
|
|
||||||
this.showMergeStyleMenu = false;
|
watch(mergeStyle, (val) => {
|
||||||
},
|
mergeStyleDetail.value = mergeForm.value.mergeStyles.find((e) => e.name === val);
|
||||||
toggleActionForm(show) {
|
for (const elem of document.querySelectorAll('[data-pull-merge-style]')) {
|
||||||
this.showActionForm = show;
|
toggleElem(elem, elem.getAttribute('data-pull-merge-style') === val);
|
||||||
if (!show) return;
|
}
|
||||||
this.deleteBranchAfterMerge = this.mergeForm.defaultDeleteBranchAfterMerge;
|
});
|
||||||
this.mergeTitleFieldValue = this.mergeStyleDetail.mergeTitleFieldText;
|
|
||||||
this.mergeMessageFieldValue = this.mergeStyleDetail.mergeMessageFieldText;
|
onMounted(() => {
|
||||||
},
|
mergeStyleAllowedCount.value = mergeForm.value.mergeStyles.reduce((v, msd) => v + (msd.allowed ? 1 : 0), 0);
|
||||||
switchMergeStyle(name, autoMerge = false) {
|
|
||||||
this.mergeStyle = name;
|
let mergeStyle = mergeForm.value.mergeStyles.find((e) => e.allowed && e.name === mergeForm.value.defaultMergeStyle)?.name;
|
||||||
this.autoMergeWhenSucceed = autoMerge;
|
if (!mergeStyle) mergeStyle = mergeForm.value.mergeStyles.find((e) => e.allowed)?.name;
|
||||||
},
|
switchMergeStyle(mergeStyle, !mergeForm.value.canMergeNow);
|
||||||
clearMergeMessage() {
|
|
||||||
this.mergeMessageFieldValue = this.mergeForm.defaultMergeMessage;
|
document.addEventListener('mouseup', hideMergeStyleMenu);
|
||||||
},
|
});
|
||||||
},
|
|
||||||
};
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mouseup', hideMergeStyleMenu);
|
||||||
|
});
|
||||||
|
|
||||||
|
function hideMergeStyleMenu() {
|
||||||
|
showMergeStyleMenu.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleActionForm(show: boolean) {
|
||||||
|
showActionForm.value = show;
|
||||||
|
if (!show) return;
|
||||||
|
deleteBranchAfterMerge.value = mergeForm.value.defaultDeleteBranchAfterMerge;
|
||||||
|
mergeTitleFieldValue.value = mergeStyleDetail.value.mergeTitleFieldText;
|
||||||
|
mergeMessageFieldValue.value = mergeStyleDetail.value.mergeMessageFieldText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchMergeStyle(name, autoMerge = false) {
|
||||||
|
mergeStyle.value = name;
|
||||||
|
autoMergeWhenSucceed.value = autoMerge;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMergeMessage() {
|
||||||
|
mergeMessageFieldValue.value = mergeForm.value.defaultMergeMessage;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!--
|
<!--
|
||||||
if this component is shown, either the user is an admin (can do a merge without checks), or they are a writer who has the permission to do a merge
|
if this component is shown, either the user is an admin (can do a merge without checks), or they are a writer who has the permission to do a merge
|
||||||
|
@ -186,6 +185,7 @@ export default {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* to keep UI the same, at the moment we are still using some Fomantic UI styles, but we do not use their scripts, so we need to fine tune some styles */
|
/* to keep UI the same, at the moment we are still using some Fomantic UI styles, but we do not use their scripts, so we need to fine tune some styles */
|
||||||
.ui.dropdown .menu.show {
|
.ui.dropdown .menu.show {
|
||||||
|
|
|
@ -1,68 +1,62 @@
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import {VueBarGraph} from 'vue-bar-graph';
|
import {VueBarGraph} from 'vue-bar-graph';
|
||||||
import {createApp} from 'vue';
|
import {computed, onMounted, ref} from 'vue';
|
||||||
|
|
||||||
const sfc = {
|
const colors = ref({
|
||||||
components: {VueBarGraph},
|
barColor: 'green',
|
||||||
data: () => ({
|
textColor: 'black',
|
||||||
colors: {
|
textAltColor: 'white',
|
||||||
barColor: 'green',
|
});
|
||||||
textColor: 'black',
|
|
||||||
textAltColor: 'white',
|
|
||||||
},
|
|
||||||
|
|
||||||
// possible keys:
|
// possible keys:
|
||||||
// * avatar_link: (...)
|
// * avatar_link: (...)
|
||||||
// * commits: (...)
|
// * commits: (...)
|
||||||
// * home_link: (...)
|
// * home_link: (...)
|
||||||
// * login: (...)
|
// * login: (...)
|
||||||
// * name: (...)
|
// * name: (...)
|
||||||
activityTopAuthors: window.config.pageData.repoActivityTopAuthors || [],
|
const activityTopAuthors = window.config.pageData.repoActivityTopAuthors || [];
|
||||||
}),
|
|
||||||
computed: {
|
|
||||||
graphPoints() {
|
|
||||||
return this.activityTopAuthors.map((item) => {
|
|
||||||
return {
|
|
||||||
value: item.commits,
|
|
||||||
label: item.name,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
graphAuthors() {
|
|
||||||
return this.activityTopAuthors.map((item, idx) => {
|
|
||||||
return {
|
|
||||||
position: idx + 1,
|
|
||||||
...item,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
graphWidth() {
|
|
||||||
return this.activityTopAuthors.length * 40;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
const refStyle = window.getComputedStyle(this.$refs.style);
|
|
||||||
const refAltStyle = window.getComputedStyle(this.$refs.altStyle);
|
|
||||||
|
|
||||||
this.colors.barColor = refStyle.backgroundColor;
|
const graphPoints = computed(() => {
|
||||||
this.colors.textColor = refStyle.color;
|
return activityTopAuthors.value.map((item) => {
|
||||||
this.colors.textAltColor = refAltStyle.color;
|
return {
|
||||||
},
|
value: item.commits,
|
||||||
};
|
label: item.name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
export function initRepoActivityTopAuthorsChart() {
|
const graphAuthors = computed(() => {
|
||||||
const el = document.querySelector('#repo-activity-top-authors-chart');
|
return activityTopAuthors.value.map((item, idx) => {
|
||||||
if (el) {
|
return {
|
||||||
createApp(sfc).mount(el);
|
position: idx + 1,
|
||||||
}
|
...item,
|
||||||
}
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
export default sfc; // activate the IDE's Vue plugin
|
const graphWidth = computed(() => {
|
||||||
|
return activityTopAuthors.value.length * 40;
|
||||||
|
});
|
||||||
|
|
||||||
|
const styleElement = ref<HTMLElement | null>(null);
|
||||||
|
const altStyleElement = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const refStyle = window.getComputedStyle(styleElement.value);
|
||||||
|
const refAltStyle = window.getComputedStyle(altStyleElement.value);
|
||||||
|
|
||||||
|
colors.value = {
|
||||||
|
barColor: refStyle.backgroundColor,
|
||||||
|
textColor: refStyle.color,
|
||||||
|
textAltColor: refAltStyle.color,
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="activity-bar-graph" ref="style" style="width: 0; height: 0;"/>
|
<div class="activity-bar-graph" ref="styleElement" style="width: 0; height: 0;"/>
|
||||||
<div class="activity-bar-graph-alt" ref="altStyle" style="width: 0; height: 0;"/>
|
<div class="activity-bar-graph-alt" ref="altStyleElement" style="width: 0; height: 0;"/>
|
||||||
<vue-bar-graph
|
<vue-bar-graph
|
||||||
:points="graphPoints"
|
:points="graphPoints"
|
||||||
:show-x-axis="true"
|
:show-x-axis="true"
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import {SvgIcon} from '../svg.ts';
|
import {SvgIcon} from '../svg.ts';
|
||||||
import {
|
import {
|
||||||
Chart,
|
Chart,
|
||||||
|
@ -15,10 +15,12 @@ import {
|
||||||
startDaysBetween,
|
startDaysBetween,
|
||||||
firstStartDateAfterDate,
|
firstStartDateAfterDate,
|
||||||
fillEmptyStartDaysWithZeroes,
|
fillEmptyStartDaysWithZeroes,
|
||||||
|
type DayData,
|
||||||
} from '../utils/time.ts';
|
} from '../utils/time.ts';
|
||||||
import {chartJsColors} from '../utils/color.ts';
|
import {chartJsColors} from '../utils/color.ts';
|
||||||
import {sleep} from '../utils.ts';
|
import {sleep} from '../utils.ts';
|
||||||
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
||||||
|
import {onMounted, ref} from 'vue';
|
||||||
|
|
||||||
const {pageData} = window.config;
|
const {pageData} = window.config;
|
||||||
|
|
||||||
|
@ -34,114 +36,110 @@ Chart.register(
|
||||||
Filler,
|
Filler,
|
||||||
);
|
);
|
||||||
|
|
||||||
export default {
|
defineProps<{
|
||||||
components: {ChartLine, SvgIcon},
|
locale: {
|
||||||
props: {
|
loadingTitle: string;
|
||||||
locale: {
|
loadingTitleFailed: string;
|
||||||
type: Object,
|
loadingInfo: string;
|
||||||
required: true,
|
};
|
||||||
},
|
}>();
|
||||||
},
|
|
||||||
data: () => ({
|
const isLoading = ref(false);
|
||||||
isLoading: false,
|
const errorText = ref('');
|
||||||
errorText: '',
|
const repoLink = ref(pageData.repoLink || []);
|
||||||
repoLink: pageData.repoLink || [],
|
const data = ref<DayData[]>([]);
|
||||||
data: [],
|
|
||||||
}),
|
onMounted(() => {
|
||||||
mounted() {
|
fetchGraphData();
|
||||||
this.fetchGraphData();
|
});
|
||||||
},
|
|
||||||
methods: {
|
async function fetchGraphData() {
|
||||||
async fetchGraphData() {
|
isLoading.value = true;
|
||||||
this.isLoading = true;
|
try {
|
||||||
try {
|
let response: Response;
|
||||||
let response;
|
do {
|
||||||
do {
|
response = await GET(`${repoLink.value}/activity/code-frequency/data`);
|
||||||
response = await GET(`${this.repoLink}/activity/code-frequency/data`);
|
if (response.status === 202) {
|
||||||
if (response.status === 202) {
|
await sleep(1000); // wait for 1 second before retrying
|
||||||
await sleep(1000); // wait for 1 second before retrying
|
|
||||||
}
|
|
||||||
} while (response.status === 202);
|
|
||||||
if (response.ok) {
|
|
||||||
this.data = await response.json();
|
|
||||||
const weekValues = Object.values(this.data);
|
|
||||||
const start = weekValues[0].week;
|
|
||||||
const end = firstStartDateAfterDate(new Date());
|
|
||||||
const startDays = startDaysBetween(start, end);
|
|
||||||
this.data = fillEmptyStartDaysWithZeroes(startDays, this.data);
|
|
||||||
this.errorText = '';
|
|
||||||
} else {
|
|
||||||
this.errorText = response.statusText;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.errorText = err.message;
|
|
||||||
} finally {
|
|
||||||
this.isLoading = false;
|
|
||||||
}
|
}
|
||||||
},
|
} while (response.status === 202);
|
||||||
|
if (response.ok) {
|
||||||
|
data.value = await response.json();
|
||||||
|
const weekValues = Object.values(data.value);
|
||||||
|
const start = weekValues[0].week;
|
||||||
|
const end = firstStartDateAfterDate(new Date());
|
||||||
|
const startDays = startDaysBetween(start, end);
|
||||||
|
data.value = fillEmptyStartDaysWithZeroes(startDays, data.value);
|
||||||
|
errorText.value = '';
|
||||||
|
} else {
|
||||||
|
errorText.value = response.statusText;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorText.value = err.message;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
toGraphData(data) {
|
function toGraphData(data) {
|
||||||
return {
|
return {
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
data: data.map((i) => ({x: i.week, y: i.additions})),
|
data: data.map((i) => ({x: i.week, y: i.additions})),
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
pointHitRadius: 0,
|
pointHitRadius: 0,
|
||||||
fill: true,
|
fill: true,
|
||||||
label: 'Additions',
|
label: 'Additions',
|
||||||
backgroundColor: chartJsColors['additions'],
|
backgroundColor: chartJsColors['additions'],
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
data: data.map((i) => ({x: i.week, y: -i.deletions})),
|
data: data.map((i) => ({x: i.week, y: -i.deletions})),
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
pointHitRadius: 0,
|
pointHitRadius: 0,
|
||||||
fill: true,
|
fill: true,
|
||||||
label: 'Deletions',
|
label: 'Deletions',
|
||||||
backgroundColor: chartJsColors['deletions'],
|
backgroundColor: chartJsColors['deletions'],
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
}
|
||||||
|
|
||||||
getOptions() {
|
const options = {
|
||||||
return {
|
responsive: true,
|
||||||
responsive: true,
|
maintainAspectRatio: false,
|
||||||
maintainAspectRatio: false,
|
animation: true,
|
||||||
animation: true,
|
plugins: {
|
||||||
plugins: {
|
legend: {
|
||||||
legend: {
|
display: true,
|
||||||
display: true,
|
},
|
||||||
},
|
},
|
||||||
},
|
scales: {
|
||||||
scales: {
|
x: {
|
||||||
x: {
|
type: 'time',
|
||||||
type: 'time',
|
grid: {
|
||||||
grid: {
|
display: false,
|
||||||
display: false,
|
},
|
||||||
},
|
time: {
|
||||||
time: {
|
minUnit: 'month',
|
||||||
minUnit: 'month',
|
},
|
||||||
},
|
ticks: {
|
||||||
ticks: {
|
maxRotation: 0,
|
||||||
maxRotation: 0,
|
maxTicksLimit: 12,
|
||||||
maxTicksLimit: 12,
|
},
|
||||||
},
|
},
|
||||||
},
|
y: {
|
||||||
y: {
|
ticks: {
|
||||||
ticks: {
|
maxTicksLimit: 6,
|
||||||
maxTicksLimit: 6,
|
},
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="ui header tw-flex tw-items-center tw-justify-between">
|
<div class="ui header tw-flex tw-items-center tw-justify-between">
|
||||||
|
@ -160,11 +158,12 @@ export default {
|
||||||
</div>
|
</div>
|
||||||
<ChartLine
|
<ChartLine
|
||||||
v-memo="data" v-if="data.length !== 0"
|
v-memo="data" v-if="data.length !== 0"
|
||||||
:data="toGraphData(data)" :options="getOptions()"
|
:data="toGraphData(data)" :options="options"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.main-graph {
|
.main-graph {
|
||||||
height: 440px;
|
height: 440px;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import {SvgIcon} from '../svg.ts';
|
import {SvgIcon} from '../svg.ts';
|
||||||
import {
|
import {
|
||||||
Chart,
|
Chart,
|
||||||
|
@ -6,6 +6,7 @@ import {
|
||||||
BarElement,
|
BarElement,
|
||||||
LinearScale,
|
LinearScale,
|
||||||
TimeScale,
|
TimeScale,
|
||||||
|
type ChartOptions,
|
||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
import {GET} from '../modules/fetch.ts';
|
import {GET} from '../modules/fetch.ts';
|
||||||
import {Bar} from 'vue-chartjs';
|
import {Bar} from 'vue-chartjs';
|
||||||
|
@ -13,10 +14,12 @@ import {
|
||||||
startDaysBetween,
|
startDaysBetween,
|
||||||
firstStartDateAfterDate,
|
firstStartDateAfterDate,
|
||||||
fillEmptyStartDaysWithZeroes,
|
fillEmptyStartDaysWithZeroes,
|
||||||
|
type DayData,
|
||||||
} from '../utils/time.ts';
|
} from '../utils/time.ts';
|
||||||
import {chartJsColors} from '../utils/color.ts';
|
import {chartJsColors} from '../utils/color.ts';
|
||||||
import {sleep} from '../utils.ts';
|
import {sleep} from '../utils.ts';
|
||||||
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
||||||
|
import {onMounted, ref} from 'vue';
|
||||||
|
|
||||||
const {pageData} = window.config;
|
const {pageData} = window.config;
|
||||||
|
|
||||||
|
@ -30,95 +33,91 @@ Chart.register(
|
||||||
Tooltip,
|
Tooltip,
|
||||||
);
|
);
|
||||||
|
|
||||||
export default {
|
defineProps<{
|
||||||
components: {Bar, SvgIcon},
|
locale: {
|
||||||
props: {
|
loadingTitle: string;
|
||||||
locale: {
|
loadingTitleFailed: string;
|
||||||
type: Object,
|
loadingInfo: string;
|
||||||
required: true,
|
};
|
||||||
},
|
}>();
|
||||||
},
|
|
||||||
data: () => ({
|
const isLoading = ref(false);
|
||||||
isLoading: false,
|
const errorText = ref('');
|
||||||
errorText: '',
|
const repoLink = ref(pageData.repoLink || []);
|
||||||
repoLink: pageData.repoLink || [],
|
const data = ref<DayData[]>([]);
|
||||||
data: [],
|
|
||||||
}),
|
onMounted(() => {
|
||||||
mounted() {
|
fetchGraphData();
|
||||||
this.fetchGraphData();
|
});
|
||||||
},
|
|
||||||
methods: {
|
async function fetchGraphData() {
|
||||||
async fetchGraphData() {
|
isLoading.value = true;
|
||||||
this.isLoading = true;
|
try {
|
||||||
try {
|
let response: Response;
|
||||||
let response;
|
do {
|
||||||
do {
|
response = await GET(`${repoLink.value}/activity/recent-commits/data`);
|
||||||
response = await GET(`${this.repoLink}/activity/recent-commits/data`);
|
if (response.status === 202) {
|
||||||
if (response.status === 202) {
|
await sleep(1000); // wait for 1 second before retrying
|
||||||
await sleep(1000); // wait for 1 second before retrying
|
|
||||||
}
|
|
||||||
} while (response.status === 202);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
const start = Object.values(data)[0].week;
|
|
||||||
const end = firstStartDateAfterDate(new Date());
|
|
||||||
const startDays = startDaysBetween(start, end);
|
|
||||||
this.data = fillEmptyStartDaysWithZeroes(startDays, data).slice(-52);
|
|
||||||
this.errorText = '';
|
|
||||||
} else {
|
|
||||||
this.errorText = response.statusText;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.errorText = err.message;
|
|
||||||
} finally {
|
|
||||||
this.isLoading = false;
|
|
||||||
}
|
}
|
||||||
},
|
} while (response.status === 202);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const start = Object.values(data)[0].week;
|
||||||
|
const end = firstStartDateAfterDate(new Date());
|
||||||
|
const startDays = startDaysBetween(start, end);
|
||||||
|
data.value = fillEmptyStartDaysWithZeroes(startDays, data).slice(-52);
|
||||||
|
errorText.value = '';
|
||||||
|
} else {
|
||||||
|
errorText.value = response.statusText;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorText.value = err.message;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
toGraphData(data) {
|
function toGraphData(data) {
|
||||||
return {
|
return {
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
data: data.map((i) => ({x: i.week, y: i.commits})),
|
data: data.map((i) => ({x: i.week, y: i.commits})),
|
||||||
label: 'Commits',
|
label: 'Commits',
|
||||||
backgroundColor: chartJsColors['commits'],
|
backgroundColor: chartJsColors['commits'],
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
}
|
||||||
|
|
||||||
getOptions() {
|
const options = {
|
||||||
return {
|
responsive: true,
|
||||||
responsive: true,
|
maintainAspectRatio: false,
|
||||||
maintainAspectRatio: false,
|
animation: true,
|
||||||
animation: true,
|
scales: {
|
||||||
scales: {
|
x: {
|
||||||
x: {
|
type: 'time',
|
||||||
type: 'time',
|
grid: {
|
||||||
grid: {
|
display: false,
|
||||||
display: false,
|
},
|
||||||
},
|
time: {
|
||||||
time: {
|
minUnit: 'week',
|
||||||
minUnit: 'week',
|
},
|
||||||
},
|
ticks: {
|
||||||
ticks: {
|
maxRotation: 0,
|
||||||
maxRotation: 0,
|
maxTicksLimit: 52,
|
||||||
maxTicksLimit: 52,
|
},
|
||||||
},
|
},
|
||||||
},
|
y: {
|
||||||
y: {
|
ticks: {
|
||||||
ticks: {
|
maxTicksLimit: 6,
|
||||||
maxTicksLimit: 6,
|
},
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
} satisfies ChartOptions;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="ui header tw-flex tw-items-center tw-justify-between">
|
<div class="ui header tw-flex tw-items-center tw-justify-between">
|
||||||
|
@ -137,7 +136,7 @@ export default {
|
||||||
</div>
|
</div>
|
||||||
<Bar
|
<Bar
|
||||||
v-memo="data" v-if="data.length !== 0"
|
v-memo="data" v-if="data.length !== 0"
|
||||||
:data="toGraphData(data)" :options="getOptions()"
|
:data="toGraphData(data)" :options="options"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,78 +1,60 @@
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
|
import {computed, onMounted, onUnmounted} from 'vue';
|
||||||
import {hideElem, showElem} from '../utils/dom.ts';
|
import {hideElem, showElem} from '../utils/dom.ts';
|
||||||
|
|
||||||
const sfc = {
|
const props = defineProps<{
|
||||||
props: {
|
isAdmin: boolean;
|
||||||
isAdmin: {
|
noAccessLabel: string;
|
||||||
type: Boolean,
|
readLabel: string;
|
||||||
required: true,
|
writeLabel: string;
|
||||||
},
|
}>();
|
||||||
noAccessLabel: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
readLabel: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
writeLabel: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
const categories = computed(() => {
|
||||||
categories() {
|
const categories = [
|
||||||
const categories = [
|
'activitypub',
|
||||||
'activitypub',
|
];
|
||||||
];
|
if (props.isAdmin) {
|
||||||
if (this.isAdmin) {
|
categories.push('admin');
|
||||||
categories.push('admin');
|
}
|
||||||
}
|
categories.push(
|
||||||
categories.push(
|
'issue',
|
||||||
'issue',
|
'misc',
|
||||||
'misc',
|
'notification',
|
||||||
'notification',
|
'organization',
|
||||||
'organization',
|
'package',
|
||||||
'package',
|
'repository',
|
||||||
'repository',
|
'user');
|
||||||
'user');
|
return categories;
|
||||||
return categories;
|
});
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
onMounted(() => {
|
||||||
document.querySelector('#scoped-access-submit').addEventListener('click', this.onClickSubmit);
|
document.querySelector('#scoped-access-submit').addEventListener('click', onClickSubmit);
|
||||||
},
|
});
|
||||||
|
|
||||||
unmounted() {
|
onUnmounted(() => {
|
||||||
document.querySelector('#scoped-access-submit').removeEventListener('click', this.onClickSubmit);
|
document.querySelector('#scoped-access-submit').removeEventListener('click', onClickSubmit);
|
||||||
},
|
});
|
||||||
|
|
||||||
methods: {
|
function onClickSubmit(e) {
|
||||||
onClickSubmit(e) {
|
e.preventDefault();
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const warningEl = document.querySelector('#scoped-access-warning');
|
const warningEl = document.querySelector('#scoped-access-warning');
|
||||||
// check that at least one scope has been selected
|
// check that at least one scope has been selected
|
||||||
for (const el of document.querySelectorAll('.access-token-select')) {
|
for (const el of document.querySelectorAll<HTMLInputElement>('.access-token-select')) {
|
||||||
if (el.value) {
|
if (el.value) {
|
||||||
// Hide the error if it was visible from previous attempt.
|
// Hide the error if it was visible from previous attempt.
|
||||||
hideElem(warningEl);
|
hideElem(warningEl);
|
||||||
// Submit the form.
|
// Submit the form.
|
||||||
document.querySelector('#scoped-access-form').submit();
|
document.querySelector<HTMLFormElement>('#scoped-access-form').submit();
|
||||||
// Don't show the warning.
|
// Don't show the warning.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// no scopes selected, show validation error
|
// no scopes selected, show validation error
|
||||||
showElem(warningEl);
|
showElem(warningEl);
|
||||||
},
|
}
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default sfc;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-for="category in categories" :key="category" class="field tw-pl-1 tw-pb-1 access-token-category">
|
<div v-for="category in categories" :key="category" class="field tw-pl-1 tw-pb-1 access-token-category">
|
||||||
<label class="category-label" :for="'access-token-scope-' + category">
|
<label class="category-label" :for="'access-token-scope-' + category">
|
||||||
|
|
|
@ -3,6 +3,8 @@ import {hideElem, queryElems, showElem} from '../utils/dom.ts';
|
||||||
import {POST} from '../modules/fetch.ts';
|
import {POST} from '../modules/fetch.ts';
|
||||||
import {showErrorToast} from '../modules/toast.ts';
|
import {showErrorToast} from '../modules/toast.ts';
|
||||||
import {sleep} from '../utils.ts';
|
import {sleep} from '../utils.ts';
|
||||||
|
import RepoActivityTopAuthors from '../components/RepoActivityTopAuthors.vue';
|
||||||
|
import {createApp} from 'vue';
|
||||||
|
|
||||||
async function onDownloadArchive(e) {
|
async function onDownloadArchive(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -32,6 +34,13 @@ export function initRepoArchiveLinks() {
|
||||||
queryElems('a.archive-link[href]', (el) => el.addEventListener('click', onDownloadArchive));
|
queryElems('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 initRepoCloneLink() {
|
export function initRepoCloneLink() {
|
||||||
const $repoCloneSsh = $('#repo-clone-ssh');
|
const $repoCloneSsh = $('#repo-clone-ssh');
|
||||||
const $repoCloneHttps = $('#repo-clone-https');
|
const $repoCloneHttps = $('#repo-clone-https');
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
import './bootstrap.ts';
|
import './bootstrap.ts';
|
||||||
import './htmx.ts';
|
import './htmx.ts';
|
||||||
|
|
||||||
import {initRepoActivityTopAuthorsChart} from './components/RepoActivityTopAuthors.vue';
|
|
||||||
import {initDashboardRepoList} from './components/DashboardRepoList.vue';
|
import {initDashboardRepoList} from './components/DashboardRepoList.vue';
|
||||||
|
|
||||||
import {initGlobalCopyToClipboardListener} from './features/clipboard.ts';
|
import {initGlobalCopyToClipboardListener} from './features/clipboard.ts';
|
||||||
|
@ -42,7 +41,7 @@ import {initRepoTemplateSearch} from './features/repo-template.ts';
|
||||||
import {initRepoCodeView} from './features/repo-code.ts';
|
import {initRepoCodeView} from './features/repo-code.ts';
|
||||||
import {initSshKeyFormParser} from './features/sshkey-helper.ts';
|
import {initSshKeyFormParser} from './features/sshkey-helper.ts';
|
||||||
import {initUserSettings} from './features/user-settings.ts';
|
import {initUserSettings} from './features/user-settings.ts';
|
||||||
import {initRepoArchiveLinks} from './features/repo-common.ts';
|
import {initRepoActivityTopAuthorsChart, initRepoArchiveLinks} from './features/repo-common.ts';
|
||||||
import {initRepoMigrationStatusChecker} from './features/repo-migrate.ts';
|
import {initRepoMigrationStatusChecker} from './features/repo-migrate.ts';
|
||||||
import {
|
import {
|
||||||
initRepoSettingGitHook,
|
initRepoSettingGitHook,
|
||||||
|
|
|
@ -36,3 +36,13 @@ export type IssueData = {
|
||||||
type: string,
|
type: string,
|
||||||
index: string,
|
index: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Issue = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
state: 'open' | 'closed';
|
||||||
|
pull_request?: {
|
||||||
|
draft: boolean;
|
||||||
|
merged: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
@ -42,14 +42,14 @@ export function firstStartDateAfterDate(inputDate: Date): number {
|
||||||
return resultDate.valueOf();
|
return resultDate.valueOf();
|
||||||
}
|
}
|
||||||
|
|
||||||
type DayData = {
|
export type DayData = {
|
||||||
week: number,
|
week: number,
|
||||||
additions: number,
|
additions: number,
|
||||||
deletions: number,
|
deletions: number,
|
||||||
commits: number,
|
commits: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fillEmptyStartDaysWithZeroes(startDays: number[], data: DayData): DayData[] {
|
export function fillEmptyStartDaysWithZeroes(startDays: number[], data: DayData[]): DayData[] {
|
||||||
const result = {};
|
const result = {};
|
||||||
|
|
||||||
for (const startDay of startDays) {
|
for (const startDay of startDays) {
|
||||||
|
|
Loading…
Reference in New Issue