mirror of https://github.com/go-gitea/gitea
Refactor dashboard repo list to Vue SFC (#23405)
Similar to #23394 The dashboard repo list mixes jQuery/Fomantic UI/Vue together, it's very diffcult to maintain and causes unfixable a11y problems. This PR uses two steps to refactor the repo list: 1. move `data-` attributes to JS object and use Vue data as much as possiblepull/23457/head^2d3adc0dcac
2. move the code into a Vue SFC7ebe55df6e
Total: +516 −585 Screenshots: <details> ![image](https://user-images.githubusercontent.com/2114189/224271457-a23e05be-d7d3-4247-a803-f0ee30c36f44.png) ![image](https://user-images.githubusercontent.com/2114189/224271504-76fbd3da-4d7a-4725-b0d1-fbff83caac63.png) ![image](https://user-images.githubusercontent.com/2114189/224271845-f007cadf-6c49-46bd-a65c-a3fc75bdba3b.png) </details> --------- Co-authored-by: John Olheiser <john.olheiser@gmail.com>
parent
b942838bd4
commit
e82f1b15c7
@ -1,181 +1,54 @@ |
||||
<div id="dashboard-repo-list" class="six wide column"> |
||||
<repo-search |
||||
:search-limit="searchLimit" |
||||
:sub-url="subUrl" |
||||
:uid="uid" |
||||
<script type="module"> |
||||
const data = { |
||||
...window.config.pageData.dashboardRepoList, // it only contains searchLimit and uid |
||||
|
||||
isMirrorsEnabled: {{.IsMirrorsEnabled}}, |
||||
isStarsEnabled: {{not .IsDisableStars}}, |
||||
|
||||
textRepository: {{.locale.Tr "repository"}}, |
||||
textOrganization: {{.locale.Tr "organization"}}, |
||||
textMyRepos: {{.locale.Tr "home.my_repos"}}, |
||||
textNewRepo: {{.locale.Tr "new_repo"}}, |
||||
textSearchRepos: {{.locale.Tr "home.search_repos"}}, |
||||
textFilter: {{.locale.Tr "home.filter"}}, |
||||
textShowArchived: {{.locale.Tr "home.show_archived"}}, |
||||
textShowPrivate: {{.locale.Tr "home.show_private"}}, |
||||
|
||||
textShowBothArchivedUnarchived: {{.locale.Tr "home.show_both_archived_unarchived"}}, |
||||
textShowOnlyUnarchived: {{.locale.Tr "home.show_only_unarchived"}}, |
||||
textShowOnlyArchived: {{.locale.Tr "home.show_only_archived"}}, |
||||
|
||||
textShowBothPrivatePublic: {{.locale.Tr "home.show_both_private_public"}}, |
||||
textShowOnlyPublic: {{.locale.Tr "home.show_only_public"}}, |
||||
textShowOnlyPrivate: {{.locale.Tr "home.show_only_private"}}, |
||||
|
||||
textAll: {{.locale.Tr "all"}}, |
||||
textSources: {{.locale.Tr "sources"}}, |
||||
textForks: {{.locale.Tr "forks"}}, |
||||
textMirrors: {{.locale.Tr "mirrors"}}, |
||||
textCollaborative: {{.locale.Tr "collaborative"}}, |
||||
|
||||
textFirstPage: {{.locale.Tr "admin.first_page"}}, |
||||
textPreviousPage: {{.locale.Tr "repo.issues.previous"}}, |
||||
textNextPage: {{.locale.Tr "repo.issues.next"}}, |
||||
textLastPage: {{.locale.Tr "admin.last_page"}}, |
||||
|
||||
textMyOrgs: {{.locale.Tr "home.my_orgs"}}, |
||||
textNewOrg: {{.locale.Tr "new_org"}}, |
||||
}; |
||||
|
||||
{{if .Team}} |
||||
:team-id="{{.Team.ID}}" |
||||
data.teamId = {{.Team.ID}}; |
||||
{{end}} |
||||
:more-repos-link="'{{.ContextUser.HomeLink}}'" |
||||
|
||||
{{if not .ContextUser.IsOrganization}} |
||||
:organizations="[ |
||||
{{range .Orgs}} |
||||
{name: '{{.Name}}', num_repos: '{{.NumRepos}}'}, |
||||
{{end}} |
||||
]" |
||||
:is-organization="false" |
||||
:organizations-total-count="{{.UserOrgsCount}}" |
||||
:can-create-organization="{{.SignedUser.CanCreateOrganization}}" |
||||
data.organizations = [{{range .Orgs}}{'name': {{.Name}}, 'num_repos': {{.NumRepos}}},{{end}}]; |
||||
data.isOrganization = false; |
||||
data.organizationsTotalCount = {{.UserOrgsCount}} |
||||
data.canCreateOrganization = {{.SignedUser.CanCreateOrganization}} |
||||
{{end}} |
||||
inline-template |
||||
v-cloak |
||||
></repo-search> |
||||
</div> |
||||
|
||||
<template id="dashboard-repo-list-template"> |
||||
<div> |
||||
<div v-if="!isOrganization" class="ui two item tabable menu"> |
||||
<a :class="{item: true, active: tab === 'repos'}" @click="changeTab('repos')">{{.locale.Tr "repository"}}</a> |
||||
<a :class="{item: true, active: tab === 'organizations'}" @click="changeTab('organizations')">{{.locale.Tr "organization"}}</a> |
||||
</div> |
||||
<div v-show="tab === 'repos'" class="ui tab active list dashboard-repos"> |
||||
<h4 class="ui top attached header gt-df gt-ac"> |
||||
<div class="gt-f1 gt-df gt-ac"> |
||||
{{.locale.Tr "home.my_repos"}} |
||||
<span class="ui grey label gt-ml-3">${reposTotalCount}</span> |
||||
</div> |
||||
<a class="tooltip" :href="subUrl + '/repo/create'" data-content="{{.locale.Tr "new_repo"}}" data-position="left center"> |
||||
{{svg "octicon-plus"}} |
||||
<span class="sr-only">{{.locale.Tr "new_repo"}}</span> |
||||
</a> |
||||
</h4> |
||||
<div class="ui attached segment repos-search"> |
||||
<div class="ui fluid right action left icon input" :class="{loading: isLoading}"> |
||||
<input @input="changeReposFilter(reposFilter)" v-model="searchQuery" ref="search" placeholder="{{.locale.Tr "home.search_repos"}}"> |
||||
<i class="icon gt-df gt-ac gt-jc">{{svg "octicon-search" 16}}</i> |
||||
<div class="ui dropdown icon button" title="{{.locale.Tr "home.filter"}}"> |
||||
<i class="icon gt-df gt-ac gt-jc gt-m-0">{{svg "octicon-filter" 16}}</i> |
||||
<div class="menu"> |
||||
<a class="item" @click="toggleArchivedFilter()"> |
||||
<div class="ui checkbox" |
||||
ref="checkboxArchivedFilter" |
||||
data-title-both="{{.locale.Tr "home.show_both_archived_unarchived"}}" |
||||
data-title-unarchived="{{.locale.Tr "home.show_only_unarchived"}}" |
||||
data-title-archived="{{.locale.Tr "home.show_only_archived"}}" |
||||
: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 "octicon-archive" 16 "gt-mr-2"}} |
||||
{{.locale.Tr "home.show_archived"}} |
||||
</label> |
||||
</div> |
||||
</a> |
||||
<a class="item" @click="togglePrivateFilter()"> |
||||
<div class="ui checkbox" |
||||
ref="checkboxPrivateFilter" |
||||
data-title-both="{{.locale.Tr "home.show_both_private_public"}}" |
||||
data-title-public="{{.locale.Tr "home.show_only_public"}}" |
||||
data-title-private="{{.locale.Tr "home.show_only_private"}}" |
||||
:title="checkboxPrivateFilterTitle" |
||||
> |
||||
<input type="checkbox" class="hidden" v-bind.prop="checkboxPrivateFilterProps"> |
||||
<label> |
||||
{{svg "octicon-lock" 16 "gt-mr-2"}} |
||||
{{.locale.Tr "home.show_private"}} |
||||
</label> |
||||
</div> |
||||
</a> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div class="ui secondary tiny pointing borderless menu center grid repos-filter"> |
||||
<a class="item" :class="{active: reposFilter === 'all'}" @click="changeReposFilter('all')"> |
||||
{{.locale.Tr "all"}} |
||||
<div v-show="reposFilter === 'all'" class="ui circular mini grey label">${repoTypeCount}</div> |
||||
</a> |
||||
<a class="item" :class="{active: reposFilter === 'sources'}" @click="changeReposFilter('sources')"> |
||||
{{.locale.Tr "sources"}} |
||||
<div v-show="reposFilter === 'sources'" class="ui circular mini grey label">${repoTypeCount}</div> |
||||
</a> |
||||
<a class="item" :class="{active: reposFilter === 'forks'}" @click="changeReposFilter('forks')"> |
||||
{{.locale.Tr "forks"}} |
||||
<div v-show="reposFilter === 'forks'" class="ui circular mini grey label">${repoTypeCount}</div> |
||||
</a> |
||||
{{if .MirrorsEnabled}} |
||||
<a class="item" :class="{active: reposFilter === 'mirrors'}" @click="changeReposFilter('mirrors')"> |
||||
{{.locale.Tr "mirrors"}} |
||||
<div v-show="reposFilter === 'mirrors'" class="ui circular mini grey label">${repoTypeCount}</div> |
||||
</a> |
||||
{{end}} |
||||
<a class="item" :class="{active: reposFilter === 'collaborative'}" @click="changeReposFilter('collaborative')"> |
||||
{{.locale.Tr "collaborative"}} |
||||
<div v-show="reposFilter === 'collaborative'" class="ui circular mini grey label">${repoTypeCount}</div> |
||||
</a> |
||||
</div> |
||||
</div> |
||||
<div v-if="repos.length" class="ui attached table segment gt-rounded-bottom"> |
||||
<ul class="repo-owner-name-list"> |
||||
<li v-for="repo in repos" :class="{'private': repo.private || repo.internal}"> |
||||
<a class="repo-list-link gt-df gt-ac gt-sb" :href="repo.link"> |
||||
<div class="item-name gt-df gt-ac gt-f1 gt-mr-2"> |
||||
<component v-bind:is="repoIcon(repo)" size="16" class="gt-mr-2"></component> |
||||
<div class="text gt-bold truncate gt-ml-1">${repo.full_name}</div> |
||||
<span v-if="repo.archived"> |
||||
{{svg "octicon-archive" 16 "gt-ml-2"}} |
||||
</span> |
||||
</div> |
||||
{{if not .DisableStars}} |
||||
<div class="text light grey gt-df gt-ac"> |
||||
${repo.stars_count} |
||||
{{svg "octicon-star" 16 "gt-ml-2"}} |
||||
</div> |
||||
{{end}} |
||||
</a> |
||||
</li> |
||||
</ul> |
||||
<div v-if="showMoreReposLink" class="center gt-py-3 gt-border-secondary-top"> |
||||
<div class="ui borderless pagination menu narrow"> |
||||
<a class="item navigation gt-py-2" :class="{'disabled': page === 1}" |
||||
@click="changePage(1)" title="{{$.locale.Tr "admin.first_page"}}"> |
||||
{{svg "gitea-double-chevron-left" 16 "gt-mr-2"}} |
||||
</a> |
||||
<a class="item navigation gt-py-2" :class="{'disabled': page === 1}" |
||||
@click="changePage(page - 1)" title="{{$.locale.Tr "repo.issues.previous"}}"> |
||||
{{svg "octicon-chevron-left" 16 "gt-mr-2"}} |
||||
</a> |
||||
<a class="active item gt-py-2">${page}</a> |
||||
<a class="item navigation" :class="{'disabled': page === finalPage}" |
||||
@click="changePage(page + 1)" title="{{$.locale.Tr "repo.issues.next"}}"> |
||||
{{svg "octicon-chevron-right" 16 "gt-ml-2"}} |
||||
</a> |
||||
<a class="item navigation gt-py-2" :class="{'disabled': page === finalPage}" |
||||
@click="changePage(finalPage)" title="{{$.locale.Tr "admin.last_page"}}"> |
||||
{{svg "gitea-double-chevron-right" 16 "gt-ml-2"}} |
||||
</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 gt-df gt-ac"> |
||||
<div class="gt-f1 gt-df gt-ac"> |
||||
{{.locale.Tr "home.my_orgs"}} |
||||
<span class="ui grey label gt-ml-3">${organizationsTotalCount}</span> |
||||
</div> |
||||
<a v-if="canCreateOrganization" class="tooltip" :href="subUrl + '/org/create'" data-content="{{.locale.Tr "new_org"}}" data-position="left center"> |
||||
{{svg "octicon-plus"}} |
||||
<span class="sr-only">{{.locale.Tr "new_org"}}</span> |
||||
</a> |
||||
</h4> |
||||
<div v-if="organizations.length" class="ui attached table segment gt-rounded-bottom"> |
||||
<ul class="repo-owner-name-list"> |
||||
<li v-for="org in organizations"> |
||||
<a class="repo-list-link gt-df gt-ac gt-sb" :href="subUrl + '/' + encodeURIComponent(org.name)"> |
||||
<div class="text truncate item-name gt-f1"> |
||||
{{svg "octicon-organization" 16 "gt-mr-2"}} |
||||
<strong>${org.name}</strong> |
||||
</div> |
||||
<div class="text light grey gt-df gt-ac"> |
||||
${org.num_repos} |
||||
{{svg "octicon-repo" 16 "gt-ml-2 gt-mt-1"}} |
||||
</div> |
||||
</a> |
||||
</li> |
||||
</ul> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
window.config.pageData.dashboardRepoList = data; |
||||
</script> |
||||
|
||||
<div id="dashboard-repo-list" class="six wide column"></div> |
||||
|
@ -1,345 +0,0 @@ |
||||
import {createApp, nextTick} from 'vue'; |
||||
import $ from 'jquery'; |
||||
import {initVueSvg, vueDelimiters} from './VueComponentLoader.js'; |
||||
import {initTooltip} from '../modules/tippy.js'; |
||||
|
||||
const {appSubUrl, assetUrlPrefix, pageData} = window.config; |
||||
|
||||
function initVueComponents(app) { |
||||
app.component('repo-search', { |
||||
delimiters: vueDelimiters, |
||||
props: { |
||||
searchLimit: { |
||||
type: Number, |
||||
default: 10 |
||||
}, |
||||
subUrl: { |
||||
type: String, |
||||
required: true |
||||
}, |
||||
uid: { |
||||
type: Number, |
||||
default: 0 |
||||
}, |
||||
teamId: { |
||||
type: Number, |
||||
required: false, |
||||
default: 0 |
||||
}, |
||||
organizations: { |
||||
type: Array, |
||||
default: () => [], |
||||
}, |
||||
isOrganization: { |
||||
type: Boolean, |
||||
default: true |
||||
}, |
||||
canCreateOrganization: { |
||||
type: Boolean, |
||||
default: false |
||||
}, |
||||
organizationsTotalCount: { |
||||
type: Number, |
||||
default: 0 |
||||
}, |
||||
moreReposLink: { |
||||
type: String, |
||||
default: '' |
||||
} |
||||
}, |
||||
|
||||
data() { |
||||
const params = new URLSearchParams(window.location.search); |
||||
|
||||
let tab = params.get('repo-search-tab'); |
||||
if (!tab) { |
||||
tab = 'repos'; |
||||
} |
||||
|
||||
let reposFilter = params.get('repo-search-filter'); |
||||
if (!reposFilter) { |
||||
reposFilter = 'all'; |
||||
} |
||||
|
||||
let privateFilter = params.get('repo-search-private'); |
||||
if (!privateFilter) { |
||||
privateFilter = 'both'; |
||||
} |
||||
|
||||
let archivedFilter = params.get('repo-search-archived'); |
||||
if (!archivedFilter) { |
||||
archivedFilter = 'unarchived'; |
||||
} |
||||
|
||||
let searchQuery = params.get('repo-search-query'); |
||||
if (!searchQuery) { |
||||
searchQuery = ''; |
||||
} |
||||
|
||||
let page = 1; |
||||
try { |
||||
page = parseInt(params.get('repo-search-page')); |
||||
} catch { |
||||
// noop
|
||||
} |
||||
if (!page) { |
||||
page = 1; |
||||
} |
||||
|
||||
return { |
||||
hasMounted: false, // accessing $refs in computed() need to wait for mounted
|
||||
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', |
||||
}, |
||||
} |
||||
}; |
||||
}, |
||||
|
||||
computed: { |
||||
// used in `repolist.tmpl`
|
||||
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.hasMounted && this.$refs.checkboxArchivedFilter?.getAttribute(`data-title-${this.archivedFilter}`); |
||||
}, |
||||
checkboxArchivedFilterProps() { |
||||
return {checked: this.archivedFilter === 'archived', indeterminate: this.archivedFilter === 'both'}; |
||||
}, |
||||
checkboxPrivateFilterTitle() { |
||||
return this.hasMounted && this.$refs.checkboxPrivateFilter?.getAttribute(`data-title-${this.privateFilter}`); |
||||
}, |
||||
checkboxPrivateFilterProps() { |
||||
return {checked: this.privateFilter === 'private', indeterminate: this.privateFilter === 'both'}; |
||||
}, |
||||
}, |
||||
|
||||
mounted() { |
||||
const el = document.getElementById('dashboard-repo-list'); |
||||
this.changeReposFilter(this.reposFilter); |
||||
for (const elTooltip of el.querySelectorAll('.tooltip')) { |
||||
initTooltip(elTooltip); |
||||
} |
||||
$(el).find('.dropdown').dropdown(); |
||||
nextTick(() => { |
||||
this.$refs.search.focus(); |
||||
}); |
||||
|
||||
this.hasMounted = true; |
||||
}, |
||||
|
||||
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 fetch(totalCountSearchURL); |
||||
this.reposTotalCount = response.headers.get('X-Total-Count'); |
||||
} |
||||
|
||||
response = await fetch(searchedURL); |
||||
json = await response.json(); |
||||
} catch { |
||||
if (searchedURL === this.searchURL) { |
||||
this.isLoading = false; |
||||
} |
||||
return; |
||||
} |
||||
|
||||
if (searchedURL === this.searchURL) { |
||||
this.repos = json.data; |
||||
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'; |
||||
} |
||||
}, |
||||
|
||||
template: document.getElementById('dashboard-repo-list-template'), |
||||
}); |
||||
} |
||||
|
||||
export function initDashboardRepoList() { |
||||
const el = document.getElementById('dashboard-repo-list'); |
||||
const dashboardRepoListData = pageData.dashboardRepoList || null; |
||||
if (!el || !dashboardRepoListData) return; |
||||
|
||||
const app = createApp({ |
||||
delimiters: vueDelimiters, |
||||
data() { |
||||
return { |
||||
searchLimit: dashboardRepoListData.searchLimit || 0, |
||||
subUrl: appSubUrl, |
||||
uid: dashboardRepoListData.uid || 0, |
||||
}; |
||||
}, |
||||
}); |
||||
initVueSvg(app); |
||||
initVueComponents(app); |
||||
app.mount(el); |
||||
} |
@ -0,0 +1,432 @@ |
||||
<template> |
||||
<div> |
||||
<div v-if="!isOrganization" class="ui two item tabable 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 gt-df gt-ac"> |
||||
<div class="gt-f1 gt-df gt-ac"> |
||||
{{ textMyRepos }} |
||||
<span class="ui grey label gt-ml-3">{{ reposTotalCount }}</span> |
||||
</div> |
||||
<a class="tooltip" :href="subUrl + '/repo/create'" :data-content="textNewRepo" data-position="left center"> |
||||
<svg-icon name="octicon-plus"/> |
||||
<span class="sr-only">{{ textNewRepo }}</span> |
||||
</a> |
||||
</h4> |
||||
<div class="ui attached segment repos-search"> |
||||
<div class="ui fluid right action left icon input" :class="{loading: isLoading}"> |
||||
<input @input="changeReposFilter(reposFilter)" v-model="searchQuery" ref="search" :placeholder="textSearchRepos"> |
||||
<i class="icon gt-df gt-ac gt-jc"><svg-icon name="octicon-search" :size="16"/></i> |
||||
<div class="ui dropdown icon button" :title="textFilter"> |
||||
<i class="icon gt-df gt-ac gt-jc gt-m-0"><svg-icon name="octicon-filter" :size="16"/></i> |
||||
<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="gt-mr-2"/> |
||||
{{ 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="gt-mr-2"/> |
||||
{{ textShowPrivate }} |
||||
</label> |
||||
</div> |
||||
</a> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div class="ui secondary tiny pointing borderless menu center grid repos-filter"> |
||||
<a class="item" :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" :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" :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" :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" :class="{active: reposFilter === 'collaborative'}" @click="changeReposFilter('collaborative')"> |
||||
{{ textCollaborative }} |
||||
<div v-show="reposFilter === 'collaborative'" class="ui circular mini grey label">{{ repoTypeCount }}</div> |
||||
</a> |
||||
</div> |
||||
</div> |
||||
<div v-if="repos.length" class="ui attached table segment gt-rounded-bottom"> |
||||
<ul class="repo-owner-name-list"> |
||||
<li v-for="repo in repos" :class="{'private': repo.private || repo.internal}" :key="repo.id"> |
||||
<a class="repo-list-link gt-df gt-ac gt-sb" :href="repo.link"> |
||||
<div class="item-name gt-df gt-ac gt-f1 gt-mr-2"> |
||||
<svg-icon :name="repoIcon(repo)" size="16" class-name="gt-mr-2"/> |
||||
<div class="text gt-bold truncate gt-ml-1">{{ repo.full_name }}</div> |
||||
<span v-if="repo.archived"> |
||||
<svg-icon name="octicon-archive" :size="16" class-name="gt-ml-2"/> |
||||
</span> |
||||
</div> |
||||
<div class="text light grey gt-df gt-ac" v-if="isStarsEnabled"> |
||||
{{ repo.stars_count }} |
||||
<svg-icon name="octicon-star" :size="16" class-name="gt-ml-2"/> |
||||
</div> |
||||
</a> |
||||
</li> |
||||
</ul> |
||||
<div v-if="showMoreReposLink" class="center gt-py-3 gt-border-secondary-top"> |
||||
<div class="ui borderless pagination menu narrow"> |
||||
<a |
||||
class="item navigation gt-py-2" :class="{'disabled': page === 1}" |
||||
@click="changePage(1)" :title="textFirstPage" |
||||
> |
||||
<svg-icon name="gitea-double-chevron-left" :size="16" class-name="gt-mr-2"/> |
||||
</a> |
||||
<a |
||||
class="item navigation gt-py-2" :class="{'disabled': page === 1}" |
||||
@click="changePage(page - 1)" :title="textPreviousPage" |
||||
> |
||||
<svg-icon name="octicon-chevron-left" :size="16" clsas-name="gt-mr-2"/> |
||||
</a> |
||||
<a class="active item gt-py-2">{{ 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="gt-ml-2"/> |
||||
</a> |
||||
<a |
||||
class="item navigation gt-py-2" :class="{'disabled': page === finalPage}" |
||||
@click="changePage(finalPage)" :title="textLastPage" |
||||
> |
||||
<svg-icon name="gitea-double-chevron-right" :size="16" class-name="gt-ml-2"/> |
||||
</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 gt-df gt-ac"> |
||||
<div class="gt-f1 gt-df gt-ac"> |
||||
{{ textMyOrgs }} |
||||
<span class="ui grey label gt-ml-3">{{ organizationsTotalCount }}</span> |
||||
</div> |
||||
<a v-if="canCreateOrganization" class="tooltip" :href="subUrl + '/org/create'" :data-content="textNewOrg" data-position="left center"> |
||||
<svg-icon name="octicon-plus"/> |
||||
<span class="sr-only">{{ textNewOrg }}</span> |
||||
</a> |
||||
</h4> |
||||
<div v-if="organizations.length" class="ui attached table segment gt-rounded-bottom"> |
||||
<ul class="repo-owner-name-list"> |
||||
<li v-for="org in organizations" :key="org.name"> |
||||
<a class="repo-list-link gt-df gt-ac gt-sb" :href="subUrl + '/' + encodeURIComponent(org.name)"> |
||||
<div class="text truncate item-name gt-f1"> |
||||
<svg-icon name="octicon-organization" :size="16" class-name="gt-mr-2"/> |
||||
<strong>{{ org.name }}</strong> |
||||
</div> |
||||
<div class="text light grey gt-df gt-ac"> |
||||
{{ org.num_repos }} |
||||
<svg-icon name="octicon-repo" :size="16" class-name="gt-ml-2 gt-mt-1"/> |
||||
</div> |
||||
</a> |
||||
</li> |
||||
</ul> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<script> |
||||
import {createApp, nextTick} from 'vue'; |
||||
import $ from 'jquery'; |
||||
import {initTooltip} from '../modules/tippy.js'; |
||||
import {SvgIcon} from '../svg.js'; |
||||
|
||||
const {appSubUrl, assetUrlPrefix, pageData} = window.config; |
||||
|
||||
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, |
||||
|
||||
subUrl: appSubUrl, |
||||
...pageData.dashboardRepoList, |
||||
}; |
||||
}, |
||||
|
||||
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); |
||||
for (const elTooltip of el.querySelectorAll('.tooltip')) { |
||||
initTooltip(elTooltip); |
||||
} |
||||
$(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 fetch(totalCountSearchURL); |
||||
this.reposTotalCount = response.headers.get('X-Total-Count'); |
||||
} |
||||
|
||||
response = await fetch(searchedURL); |
||||
json = await response.json(); |
||||
} catch { |
||||
if (searchedURL === this.searchURL) { |
||||
this.isLoading = false; |
||||
} |
||||
return; |
||||
} |
||||
|
||||
if (searchedURL === this.searchURL) { |
||||
this.repos = json.data; |
||||
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'; |
||||
} |
||||
}, |
||||
}; |
||||
|
||||
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> |
@ -1,49 +0,0 @@ |
||||
import {createApp} from 'vue'; |
||||
import {svgs} from '../svg.js'; |
||||
|
||||
export const vueDelimiters = ['${', '}']; |
||||
|
||||
let vueEnvInited = false; |
||||
export function initVueEnv() { |
||||
if (vueEnvInited) return; |
||||
vueEnvInited = true; |
||||
|
||||
// As far as I could tell, this is no longer possible.
|
||||
// But there seem not to be a guide what to do instead.
|
||||
// const isProd = window.config.runModeIsProd;
|
||||
// Vue.config.devtools = !isProd;
|
||||
} |
||||
|
||||
let vueSvgInited = false; |
||||
export function initVueSvg(app) { |
||||
if (vueSvgInited) return; |
||||
vueSvgInited = true; |
||||
|
||||
// register svg icon vue components, e.g. <octicon-repo size="16"/>
|
||||
for (const [name, htmlString] of Object.entries(svgs)) { |
||||
const template = htmlString |
||||
.replace(/height="[0-9]+"/, 'v-bind:height="size"') |
||||
.replace(/width="[0-9]+"/, 'v-bind:width="size"'); |
||||
|
||||
app.component(name, { |
||||
props: { |
||||
size: { |
||||
type: String, |
||||
default: '16', |
||||
}, |
||||
}, |
||||
template, |
||||
}); |
||||
} |
||||
} |
||||
|
||||
export function initVueApp(el, opts = {}) { |
||||
if (typeof el === 'string') { |
||||
el = document.querySelector(el); |
||||
} |
||||
if (!el) return null; |
||||
|
||||
return createApp( |
||||
{delimiters: vueDelimiters, ...opts} |
||||
).mount(el); |
||||
} |
Loading…
Reference in new issue