mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-11-04 00:02:20 -05:00 
			
		
		
		
	This will conclude the refactor of 1:1 class replacements to tailwind,
except `gt-hidden`. Commands ran:
```bash
perl -p -i -e 's#gt-(p|m)([lrtbxy])?-0#tw-$1$2-0#g'   {web_src/js,templates,routers,services}/**/*
perl -p -i -e 's#gt-(p|m)([lrtbxy])?-1#tw-$1$2-0.5#g' {web_src/js,templates,routers,services}/**/*
perl -p -i -e 's#gt-(p|m)([lrtbxy])?-2#tw-$1$2-1#g'   {web_src/js,templates,routers,services}/**/*
perl -p -i -e 's#gt-(p|m)([lrtbxy])?-3#tw-$1$2-2#g'   {web_src/js,templates,routers,services}/**/*
perl -p -i -e 's#gt-(p|m)([lrtbxy])?-4#tw-$1$2-4#g'   {web_src/js,templates,routers,services}/**/*
perl -p -i -e 's#gt-(p|m)([lrtbxy])?-5#tw-$1$2-8#g'   {web_src/js,templates,routers,services}/**/*
```
		
	
			
		
			
				
	
	
		
			433 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			433 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
<script>
 | 
						|
import {SvgIcon} from '../svg.js';
 | 
						|
import {
 | 
						|
  Chart,
 | 
						|
  Title,
 | 
						|
  BarElement,
 | 
						|
  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 {chartJsColors} from '../utils/color.js';
 | 
						|
import {sleep} from '../utils.js';
 | 
						|
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
 | 
						|
import $ from 'jquery';
 | 
						|
 | 
						|
const {pageData} = window.config;
 | 
						|
 | 
						|
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 = chartJsColors.text;
 | 
						|
Chart.defaults.borderColor = chartJsColors.border;
 | 
						|
 | 
						|
Chart.register(
 | 
						|
  TimeScale,
 | 
						|
  LinearScale,
 | 
						|
  BarElement,
 | 
						|
  Title,
 | 
						|
  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 sleep(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: chartJsColors[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',
 | 
						|
            position: 'top',
 | 
						|
            align: 'center',
 | 
						|
          },
 | 
						|
          customEventListener: {
 | 
						|
            chartType: type,
 | 
						|
            instance: this,
 | 
						|
          },
 | 
						|
          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>
 | 
						|
    <div class="ui header tw-flex tw-items-center tw-justify-between">
 | 
						|
      <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">
 | 
						|
              <span class="not-mobile">{{ locale.filterLabel }} </span><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>
 | 
						|
    </div>
 | 
						|
    <div class="tw-flex ui segment main-graph">
 | 
						|
      <div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto">
 | 
						|
        <div v-if="isLoading">
 | 
						|
          <SvgIcon name="octicon-sync" class="tw-mr-2 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"
 | 
						|
        v-memo="[sortedContributors, type]"
 | 
						|
      >
 | 
						|
        <div class="ui top attached header tw-flex tw-flex-1">
 | 
						|
          <b class="ui right">#{{ index + 1 }}</b>
 | 
						|
          <a :href="contributor.home_link">
 | 
						|
            <img class="ui avatar tw-align-middle" height="40" width="40" :src="contributor.avatar_link">
 | 
						|
          </a>
 | 
						|
          <div class="tw-ml-2">
 | 
						|
            <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="tw-text-12 tw-flex tw-gap-1">
 | 
						|
              <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;
 | 
						|
  padding-top: 2px;
 | 
						|
}
 | 
						|
 | 
						|
.contributor-grid {
 | 
						|
  display: grid;
 | 
						|
  grid-template-columns: repeat(2, 1fr);
 | 
						|
  gap: 1rem;
 | 
						|
}
 | 
						|
 | 
						|
.contributor-grid > * {
 | 
						|
  min-width: 0;
 | 
						|
}
 | 
						|
 | 
						|
@media (max-width: 991.98px) {
 | 
						|
  .contributor-grid {
 | 
						|
    grid-template-columns: repeat(1, 1fr);
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
.contributor-name {
 | 
						|
  margin-bottom: 0;
 | 
						|
}
 | 
						|
</style>
 |