<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.reposFilter !== 'all' ? '&exclusive=1' : '' }${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_state: webSearchRepo.latest_commit_status.State, locale_latest_commit_status_state: webSearchRepo.locale_latest_commit_status, latest_commit_status_state_link: webSearchRepo.latest_commit_status.TargetURL, }; }); 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 two item menu"> <a :class="{item: true, active: tab === 'repos'}" @click="changeTab('repos')">{{ textRepository }}</a> <a :class="{item: true, active: tab === 'organizations'}" @click="changeTab('organizations')">{{ textOrganization }}</a> </div> <div v-show="tab === 'repos'" class="ui tab active list dashboard-repos"> <h4 class="ui top attached header 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> <a class="tw-flex tw-items-center muted" :href="subUrl + '/repo/create' + (isOrganization ? '?org=' + organizationId : '')" :data-tooltip-content="textNewRepo"> <svg-icon name="octicon-plus"/> </a> </h4> <div class="ui attached segment repos-search"> <div class="ui small fluid action left icon input" :class="{loading: isLoading}"> <input type="search" spellcheck="false" maxlength="255" @input="changeReposFilter(reposFilter)" v-model="searchQuery" ref="search" @keydown="reposFilterKeyControl" :placeholder="textSearchRepos"> <i class="icon"><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_state" :href="repo.latest_commit_status_state_link" :data-tooltip-content="repo.locale_latest_commit_status_state"> <!-- 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"> <h4 class="ui top attached header tw-flex tw-items-center"> <div class="tw-flex-1 tw-flex tw-items-center"> {{ textMyOrgs }} <span class="ui grey label tw-ml-2">{{ organizationsTotalCount }}</span> </div> <a class="tw-flex tw-items-center muted" v-if="canCreateOrganization" :href="subUrl + '/org/create'" :data-tooltip-content="textNewOrg"> <svg-icon name="octicon-plus"/> </a> </h4> <div v-if="organizations.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="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>