summaryrefslogtreecommitdiffstats
path: root/web_src/js/components/DashboardRepoList.vue
diff options
context:
space:
mode:
Diffstat (limited to 'web_src/js/components/DashboardRepoList.vue')
-rw-r--r--web_src/js/components/DashboardRepoList.vue537
1 files changed, 537 insertions, 0 deletions
diff --git a/web_src/js/components/DashboardRepoList.vue b/web_src/js/components/DashboardRepoList.vue
new file mode 100644
index 0000000..e587413
--- /dev/null
+++ b/web_src/js/components/DashboardRepoList.vue
@@ -0,0 +1,537 @@
+<script>
+import {createApp, nextTick} from 'vue';
+import $ from 'jquery';
+import {SvgIcon} from '../svg.js';
+import {GET} from '../modules/fetch.js';
+
+const {appSubUrl, assetUrlPrefix, pageData} = window.config;
+
+// make sure this matches templates/repo/commit_status.tmpl
+const commitStatus = {
+ pending: {name: 'octicon-dot-fill', color: 'yellow'},
+ success: {name: 'octicon-check', color: 'green'},
+ error: {name: 'gitea-exclamation', color: 'red'},
+ failure: {name: 'octicon-x', color: 'red'},
+ warning: {name: 'gitea-exclamation', color: 'yellow'},
+};
+
+const sfc = {
+ components: {SvgIcon},
+ data() {
+ const params = new URLSearchParams(window.location.search);
+ const tab = params.get('repo-search-tab') || 'repos';
+ const reposFilter = params.get('repo-search-filter') || 'all';
+ const privateFilter = params.get('repo-search-private') || 'both';
+ const archivedFilter = params.get('repo-search-archived') || 'unarchived';
+ const searchQuery = params.get('repo-search-query') || '';
+ const page = Number(params.get('repo-search-page')) || 1;
+
+ return {
+ tab,
+ repos: [],
+ reposTotalCount: 0,
+ reposFilter,
+ archivedFilter,
+ privateFilter,
+ page,
+ finalPage: 1,
+ searchQuery,
+ isLoading: false,
+ staticPrefix: assetUrlPrefix,
+ counts: {},
+ repoTypes: {
+ all: {
+ searchMode: '',
+ },
+ forks: {
+ searchMode: 'fork',
+ },
+ mirrors: {
+ searchMode: 'mirror',
+ },
+ sources: {
+ searchMode: 'source',
+ },
+ collaborative: {
+ searchMode: 'collaborative',
+ },
+ },
+ textArchivedFilterTitles: {},
+ textPrivateFilterTitles: {},
+
+ organizations: [],
+ isOrganization: true,
+ canCreateOrganization: false,
+ organizationsTotalCount: 0,
+ organizationId: 0,
+
+ subUrl: appSubUrl,
+ ...pageData.dashboardRepoList,
+ activeIndex: -1, // don't select anything at load, first cursor down will select
+ };
+ },
+
+ computed: {
+ showMoreReposLink() {
+ return this.repos.length > 0 && this.repos.length < this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`];
+ },
+ searchURL() {
+ return `${this.subUrl}/repo/search?sort=updated&order=desc&uid=${this.uid}&team_id=${this.teamId}&q=${this.searchQuery
+ }&page=${this.page}&limit=${this.searchLimit}&mode=${this.repoTypes[this.reposFilter].searchMode
+ }${this.archivedFilter === 'archived' ? '&archived=true' : ''}${this.archivedFilter === 'unarchived' ? '&archived=false' : ''
+ }${this.privateFilter === 'private' ? '&is_private=true' : ''}${this.privateFilter === 'public' ? '&is_private=false' : ''
+ }`;
+ },
+ repoTypeCount() {
+ return this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`];
+ },
+ checkboxArchivedFilterTitle() {
+ return this.textArchivedFilterTitles[this.archivedFilter];
+ },
+ checkboxArchivedFilterProps() {
+ return {checked: this.archivedFilter === 'archived', indeterminate: this.archivedFilter === 'both'};
+ },
+ checkboxPrivateFilterTitle() {
+ return this.textPrivateFilterTitles[this.privateFilter];
+ },
+ checkboxPrivateFilterProps() {
+ return {checked: this.privateFilter === 'private', indeterminate: this.privateFilter === 'both'};
+ },
+ },
+
+ mounted() {
+ const el = document.getElementById('dashboard-repo-list');
+ this.changeReposFilter(this.reposFilter);
+ $(el).find('.dropdown').dropdown();
+ nextTick(() => {
+ this.$refs.search.focus();
+ });
+
+ this.textArchivedFilterTitles = {
+ 'archived': this.textShowOnlyArchived,
+ 'unarchived': this.textShowOnlyUnarchived,
+ 'both': this.textShowBothArchivedUnarchived,
+ };
+
+ this.textPrivateFilterTitles = {
+ 'private': this.textShowOnlyPrivate,
+ 'public': this.textShowOnlyPublic,
+ 'both': this.textShowBothPrivatePublic,
+ };
+ },
+
+ methods: {
+ changeTab(t) {
+ this.tab = t;
+ this.updateHistory();
+ },
+
+ changeReposFilter(filter) {
+ this.reposFilter = filter;
+ this.repos = [];
+ this.page = 1;
+ this.counts[`${filter}:${this.archivedFilter}:${this.privateFilter}`] = 0;
+ this.searchRepos();
+ },
+
+ updateHistory() {
+ const params = new URLSearchParams(window.location.search);
+
+ if (this.tab === 'repos') {
+ params.delete('repo-search-tab');
+ } else {
+ params.set('repo-search-tab', this.tab);
+ }
+
+ if (this.reposFilter === 'all') {
+ params.delete('repo-search-filter');
+ } else {
+ params.set('repo-search-filter', this.reposFilter);
+ }
+
+ if (this.privateFilter === 'both') {
+ params.delete('repo-search-private');
+ } else {
+ params.set('repo-search-private', this.privateFilter);
+ }
+
+ if (this.archivedFilter === 'unarchived') {
+ params.delete('repo-search-archived');
+ } else {
+ params.set('repo-search-archived', this.archivedFilter);
+ }
+
+ if (this.searchQuery === '') {
+ params.delete('repo-search-query');
+ } else {
+ params.set('repo-search-query', this.searchQuery);
+ }
+
+ if (this.page === 1) {
+ params.delete('repo-search-page');
+ } else {
+ params.set('repo-search-page', `${this.page}`);
+ }
+
+ const queryString = params.toString();
+ if (queryString) {
+ window.history.replaceState({}, '', `?${queryString}`);
+ } else {
+ window.history.replaceState({}, '', window.location.pathname);
+ }
+ },
+
+ toggleArchivedFilter() {
+ if (this.archivedFilter === 'unarchived') {
+ this.archivedFilter = 'archived';
+ } else if (this.archivedFilter === 'archived') {
+ this.archivedFilter = 'both';
+ } else { // including both
+ this.archivedFilter = 'unarchived';
+ }
+ this.page = 1;
+ this.repos = [];
+ this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0;
+ this.searchRepos();
+ },
+
+ togglePrivateFilter() {
+ if (this.privateFilter === 'both') {
+ this.privateFilter = 'public';
+ } else if (this.privateFilter === 'public') {
+ this.privateFilter = 'private';
+ } else { // including private
+ this.privateFilter = 'both';
+ }
+ this.page = 1;
+ this.repos = [];
+ this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0;
+ this.searchRepos();
+ },
+
+ changePage(page) {
+ this.page = page;
+ if (this.page > this.finalPage) {
+ this.page = this.finalPage;
+ }
+ if (this.page < 1) {
+ this.page = 1;
+ }
+ this.repos = [];
+ this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0;
+ this.searchRepos();
+ },
+
+ async searchRepos() {
+ this.isLoading = true;
+
+ const searchedMode = this.repoTypes[this.reposFilter].searchMode;
+ const searchedURL = this.searchURL;
+ const searchedQuery = this.searchQuery;
+
+ let response, json;
+ try {
+ if (!this.reposTotalCount) {
+ const totalCountSearchURL = `${this.subUrl}/repo/search?count_only=1&uid=${this.uid}&team_id=${this.teamId}&q=&page=1&mode=`;
+ response = await GET(totalCountSearchURL);
+ this.reposTotalCount = response.headers.get('X-Total-Count') ?? '?';
+ }
+
+ response = await GET(searchedURL);
+ json = await response.json();
+ } catch {
+ if (searchedURL === this.searchURL) {
+ this.isLoading = false;
+ }
+ return;
+ }
+
+ if (searchedURL === this.searchURL) {
+ this.repos = json.data.map((webSearchRepo) => {
+ return {
+ ...webSearchRepo.repository,
+ latest_commit_status: webSearchRepo.latest_commit_status,
+ locale_latest_commit_status: webSearchRepo.locale_latest_commit_status,
+ };
+ });
+ const count = response.headers.get('X-Total-Count');
+ if (searchedQuery === '' && searchedMode === '' && this.archivedFilter === 'both') {
+ this.reposTotalCount = count;
+ }
+ this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = count;
+ this.finalPage = Math.ceil(count / this.searchLimit);
+ this.updateHistory();
+ this.isLoading = false;
+ }
+ },
+
+ repoIcon(repo) {
+ if (repo.fork) {
+ return 'octicon-repo-forked';
+ } else if (repo.mirror) {
+ return 'octicon-mirror';
+ } else if (repo.template) {
+ return `octicon-repo-template`;
+ } else if (repo.private) {
+ return 'octicon-lock';
+ } else if (repo.internal) {
+ return 'octicon-repo';
+ }
+ return 'octicon-repo';
+ },
+
+ statusIcon(status) {
+ return commitStatus[status].name;
+ },
+
+ statusColor(status) {
+ return commitStatus[status].color;
+ },
+
+ reposFilterKeyControl(e) {
+ switch (e.key) {
+ case 'Enter':
+ document.querySelector('.repo-owner-name-list li.active a')?.click();
+ break;
+ case 'ArrowUp':
+ if (this.activeIndex > 0) {
+ this.activeIndex--;
+ } else if (this.page > 1) {
+ this.changePage(this.page - 1);
+ this.activeIndex = this.searchLimit - 1;
+ }
+ break;
+ case 'ArrowDown':
+ if (this.activeIndex < this.repos.length - 1) {
+ this.activeIndex++;
+ } else if (this.page < this.finalPage) {
+ this.activeIndex = 0;
+ this.changePage(this.page + 1);
+ }
+ break;
+ case 'ArrowRight':
+ if (this.page < this.finalPage) {
+ this.changePage(this.page + 1);
+ }
+ break;
+ case 'ArrowLeft':
+ if (this.page > 1) {
+ this.changePage(this.page - 1);
+ }
+ break;
+ }
+ if (this.activeIndex === -1 || this.activeIndex > this.repos.length - 1) {
+ this.activeIndex = 0;
+ }
+ },
+ },
+};
+
+export function initDashboardRepoList() {
+ const el = document.getElementById('dashboard-repo-list');
+ if (el) {
+ createApp(sfc).mount(el);
+ }
+}
+
+export default sfc; // activate the IDE's Vue plugin
+</script>
+<template>
+ <div>
+ <div v-if="!isOrganization" class="ui secondary stackable menu tabs-with-labels">
+ <a :class="{item: true, active: tab === 'repos'}" @click="changeTab('repos')">{{ textMyRepos }} <span class="ui grey label tw-ml-2">{{ reposTotalCount }}</span></a>
+ <a :class="{item: true, active: tab === 'organizations'}" @click="changeTab('organizations')">{{ textMyOrgs }} <span class="ui grey label tw-ml-2">{{ organizationsTotalCount }}</span></a>
+ </div>
+ <div v-show="tab === 'repos'" class="ui tab active list dashboard-repos">
+ <h4 v-if="isOrganization" class="ui top attached tw-mt-4 tw-flex tw-items-center">
+ <div class="tw-flex-1 tw-flex tw-items-center">
+ {{ textMyRepos }}
+ <span class="ui grey label tw-ml-2">{{ reposTotalCount }}</span>
+ </div>
+ </h4>
+ <div class="ui top attached segment repos-search">
+ <div class="ui small fluid action left icon input">
+ <input type="search" spellcheck="false" maxlength="255" @input="changeReposFilter(reposFilter)" v-model="searchQuery" ref="search" @keydown="reposFilterKeyControl" :placeholder="textSearchRepos">
+ <i class="icon loading-icon-3px" :class="{'is-loading': isLoading}"><svg-icon name="octicon-search" :size="16"/></i>
+ <div class="ui dropdown icon button" :title="textFilter">
+ <svg-icon name="octicon-filter" :size="16"/>
+ <div class="menu">
+ <a class="item" @click="toggleArchivedFilter()">
+ <div class="ui checkbox" ref="checkboxArchivedFilter" :title="checkboxArchivedFilterTitle">
+ <!--the "hidden" is necessary to make the checkbox work without Fomantic UI js,
+ otherwise if the "input" handles click event for intermediate status, it breaks the internal state-->
+ <input type="checkbox" class="hidden" v-bind.prop="checkboxArchivedFilterProps">
+ <label>
+ <svg-icon name="octicon-archive" :size="16" class-name="tw-mr-1"/>
+ {{ textShowArchived }}
+ </label>
+ </div>
+ </a>
+ <a class="item" @click="togglePrivateFilter()">
+ <div class="ui checkbox" ref="checkboxPrivateFilter" :title="checkboxPrivateFilterTitle">
+ <input type="checkbox" class="hidden" v-bind.prop="checkboxPrivateFilterProps">
+ <label>
+ <svg-icon name="octicon-lock" :size="16" class-name="tw-mr-1"/>
+ {{ textShowPrivate }}
+ </label>
+ </div>
+ </a>
+ </div>
+ </div>
+ </div>
+ <overflow-menu class="ui secondary pointing tabular borderless menu repos-filter">
+ <div class="overflow-menu-items tw-justify-center">
+ <a class="item" tabindex="0" :class="{active: reposFilter === 'all'}" @click="changeReposFilter('all')">
+ {{ textAll }}
+ <div v-show="reposFilter === 'all'" class="ui circular mini grey label">{{ repoTypeCount }}</div>
+ </a>
+ <a class="item" tabindex="0" :class="{active: reposFilter === 'sources'}" @click="changeReposFilter('sources')">
+ {{ textSources }}
+ <div v-show="reposFilter === 'sources'" class="ui circular mini grey label">{{ repoTypeCount }}</div>
+ </a>
+ <a class="item" tabindex="0" :class="{active: reposFilter === 'forks'}" @click="changeReposFilter('forks')">
+ {{ textForks }}
+ <div v-show="reposFilter === 'forks'" class="ui circular mini grey label">{{ repoTypeCount }}</div>
+ </a>
+ <a class="item" tabindex="0" :class="{active: reposFilter === 'mirrors'}" @click="changeReposFilter('mirrors')" v-if="isMirrorsEnabled">
+ {{ textMirrors }}
+ <div v-show="reposFilter === 'mirrors'" class="ui circular mini grey label">{{ repoTypeCount }}</div>
+ </a>
+ <a class="item" tabindex="0" :class="{active: reposFilter === 'collaborative'}" @click="changeReposFilter('collaborative')">
+ {{ textCollaborative }}
+ <div v-show="reposFilter === 'collaborative'" class="ui circular mini grey label">{{ repoTypeCount }}</div>
+ </a>
+ </div>
+ </overflow-menu>
+ </div>
+ <div v-if="repos.length" class="ui attached table segment tw-rounded-b">
+ <ul class="repo-owner-name-list">
+ <li class="tw-flex tw-items-center tw-py-2" v-for="repo, index in repos" :class="{'active': index === activeIndex}" :key="repo.id">
+ <a class="repo-list-link muted" :href="repo.link">
+ <svg-icon :name="repoIcon(repo)" :size="16" class-name="repo-list-icon"/>
+ <div class="text truncate">{{ repo.full_name }}</div>
+ <div v-if="repo.archived">
+ <svg-icon name="octicon-archive" :size="16"/>
+ </div>
+ </a>
+ <a class="tw-flex tw-items-center" v-if="repo.latest_commit_status" :href="repo.latest_commit_status.TargetURL" :data-tooltip-content="repo.locale_latest_commit_status">
+ <!-- the commit status icon logic is taken from templates/repo/commit_status.tmpl -->
+ <svg-icon :name="statusIcon(repo.latest_commit_status.State)" :class-name="'tw-ml-2 commit-status icon text ' + statusColor(repo.latest_commit_status.State)" :size="16"/>
+ </a>
+ </li>
+ </ul>
+ <div v-if="showMoreReposLink" class="tw-text-center">
+ <div class="divider tw-my-0"/>
+ <div class="ui borderless pagination menu narrow tw-my-2">
+ <a
+ class="item navigation tw-py-1" :class="{'disabled': page === 1}"
+ @click="changePage(1)" :title="textFirstPage"
+ >
+ <svg-icon name="gitea-double-chevron-left" :size="16" class-name="tw-mr-1"/>
+ </a>
+ <a
+ class="item navigation tw-py-1" :class="{'disabled': page === 1}"
+ @click="changePage(page - 1)" :title="textPreviousPage"
+ >
+ <svg-icon name="octicon-chevron-left" :size="16" clsas-name="tw-mr-1"/>
+ </a>
+ <a class="active item tw-py-1">{{ page }}</a>
+ <a
+ class="item navigation" :class="{'disabled': page === finalPage}"
+ @click="changePage(page + 1)" :title="textNextPage"
+ >
+ <svg-icon name="octicon-chevron-right" :size="16" class-name="tw-ml-1"/>
+ </a>
+ <a
+ class="item navigation tw-py-1" :class="{'disabled': page === finalPage}"
+ @click="changePage(finalPage)" :title="textLastPage"
+ >
+ <svg-icon name="gitea-double-chevron-right" :size="16" class-name="tw-ml-1"/>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div v-if="!isOrganization" v-show="tab === 'organizations'" class="ui tab active list dashboard-orgs">
+ <div v-if="organizations.length" class="ui attached table segment tw-rounded">
+ <ul class="repo-owner-name-list">
+ <li class="tw-flex tw-items-center tw-py-2" v-for="org in organizations" :key="org.name">
+ <a class="repo-list-link muted" :href="subUrl + '/' + encodeURIComponent(org.name)">
+ <svg-icon name="octicon-organization" :size="16" class-name="repo-list-icon"/>
+ <div class="text truncate">{{ org.name }}</div>
+ <div><!-- div to prevent underline of label on hover -->
+ <span class="ui tiny basic label" v-if="org.org_visibility !== 'public'">
+ {{ org.org_visibility === 'limited' ? textOrgVisibilityLimited: textOrgVisibilityPrivate }}
+ </span>
+ </div>
+ </a>
+ <div class="text light grey tw-flex tw-items-center tw-ml-2">
+ {{ org.num_repos }}
+ <svg-icon name="octicon-repo" :size="16" class-name="tw-ml-1 tw-mt-0.5"/>
+ </div>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+</template>
+<style scoped>
+ul {
+ list-style: none;
+ margin: 0;
+ padding-left: 0;
+}
+
+ul li {
+ padding: 0 10px;
+}
+
+ul li:not(:last-child) {
+ border-bottom: 1px solid var(--color-secondary);
+}
+
+.repos-search {
+ padding-bottom: 0 !important;
+}
+
+.repos-filter {
+ padding-top: 0 !important;
+ margin-top: 0 !important;
+ border-bottom-width: 0 !important;
+ margin-bottom: 2px !important;
+}
+
+.repos-filter .item {
+ padding-left: 6px !important;
+ padding-right: 6px !important;
+}
+
+.repo-list-link {
+ min-width: 0; /* for text truncation */
+ display: flex;
+ align-items: center;
+ flex: 1;
+ gap: 0.5rem;
+}
+
+.repo-list-link .svg {
+ color: var(--color-text-light-2);
+}
+
+.repo-list-icon {
+ min-width: 16px;
+ margin-right: 2px;
+}
+
+/* octicon-mirror has no padding inside the SVG */
+.repo-list-icon.octicon-mirror {
+ width: 14px;
+ min-width: 14px;
+ margin-left: 1px;
+ margin-right: 3px;
+}
+
+.repo-owner-name-list li.active {
+ background: var(--color-hover);
+}
+</style>