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
possible
d3adc0dcac
2. move the code into a Vue SFC
7ebe55df6e

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>
pull/23457/head^2
wxiaoguang 2 years ago committed by GitHub
parent b942838bd4
commit e82f1b15c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 225
      templates/user/dashboard/repolist.tmpl
  2. 345
      web_src/js/components/DashboardRepoList.js
  3. 432
      web_src/js/components/DashboardRepoList.vue
  4. 9
      web_src/js/components/RepoActivityTopAuthors.vue
  5. 3
      web_src/js/components/RepoBranchTagDropdown.js
  6. 49
      web_src/js/components/VueComponentLoader.js
  7. 4
      web_src/js/index.js
  8. 26
      web_src/js/svg.js

@ -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>

@ -51,7 +51,7 @@
<script>
import VueBarGraph from 'vue-bar-graph';
import {initVueApp} from './VueComponentLoader.js';
import {createApp} from 'vue';
const sfc = {
components: {VueBarGraph},
@ -102,8 +102,11 @@ const sfc = {
};
export function initRepoActivityTopAuthorsChart() {
initVueApp('#repo-activity-top-authors-chart', sfc);
const el = document.getElementById('repo-activity-top-authors-chart');
if (el) {
createApp(sfc).mount(el);
}
}
export default sfc; // this line is necessary to activate the IDE's Vue plugin
export default sfc; // activate the IDE's Vue plugin
</script>

@ -1,6 +1,5 @@
import {createApp, nextTick} from 'vue';
import $ from 'jquery';
import {vueDelimiters} from './VueComponentLoader.js';
export function initRepoBranchTagDropdown(selector) {
$(selector).each(function (dropdownIndex, elRoot) {
@ -39,7 +38,7 @@ export function initRepoBranchTagDropdown(selector) {
}
const view = createApp({
delimiters: vueDelimiters,
delimiters: ['${', '}'],
data() {
return data;
},

@ -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);
}

@ -2,9 +2,8 @@
import './bootstrap.js';
import $ from 'jquery';
import {initVueEnv} from './components/VueComponentLoader.js';
import {initRepoActivityTopAuthorsChart} from './components/RepoActivityTopAuthors.vue';
import {initDashboardRepoList} from './components/DashboardRepoList.js';
import {initDashboardRepoList} from './components/DashboardRepoList.vue';
import {attachTribute} from './features/tribute.js';
import {initGlobalCopyToClipboardListener} from './features/clipboard.js';
@ -100,7 +99,6 @@ $.fn.tab.settings.silent = true;
// Disable the behavior of fomantic to toggle the checkbox when you press enter on a checkbox element.
$.fn.checkbox.settings.enableEnterKey = false;
initVueEnv();
$(document).ready(() => {
initGlobalCommon();

@ -31,8 +31,17 @@ import octiconSkip from '../../public/img/svg/octicon-skip.svg';
import octiconMeter from '../../public/img/svg/octicon-meter.svg';
import octiconBlocked from '../../public/img/svg/octicon-blocked.svg';
import octiconSync from '../../public/img/svg/octicon-sync.svg';
import octiconFilter from '../../public/img/svg/octicon-filter.svg';
import octiconPlus from '../../public/img/svg/octicon-plus.svg';
import octiconSearch from '../../public/img/svg/octicon-search.svg';
import octiconArchive from '../../public/img/svg/octicon-archive.svg';
import octiconStar from '../../public/img/svg/octicon-star.svg';
import giteaDoubleChevronLeft from '../../public/img/svg/gitea-double-chevron-left.svg';
import giteaDoubleChevronRight from '../../public/img/svg/gitea-double-chevron-right.svg';
import octiconChevronLeft from '../../public/img/svg/octicon-chevron-left.svg';
import octiconOrganization from '../../public/img/svg/octicon-organization.svg';
export const svgs = {
const svgs = {
'octicon-blocked': octiconBlocked,
'octicon-check-circle-fill': octiconCheckCircleFill,
'octicon-chevron-down': octiconChevronDown,
@ -66,14 +75,25 @@ export const svgs = {
'octicon-triangle-down': octiconTriangleDown,
'octicon-x': octiconX,
'octicon-x-circle-fill': octiconXCircleFill,
'octicon-filter': octiconFilter,
'octicon-plus': octiconPlus,
'octicon-search': octiconSearch,
'octicon-archive': octiconArchive,
'octicon-star': octiconStar,
'gitea-double-chevron-left': giteaDoubleChevronLeft,
'gitea-double-chevron-right': giteaDoubleChevronRight,
'octicon-chevron-left': octiconChevronLeft,
'octicon-organization': octiconOrganization,
};
// TODO: use a more general approach to access SVG icons. At the moment, developers must check, pick and fill the names manually, most of the SVG icons in assets couldn't be used directly.
const parser = new DOMParser();
const serializer = new XMLSerializer();
// retrieve a HTML string for given SVG icon name, size and additional classes
// retrieve an HTML string for given SVG icon name, size and additional classes
export function svg(name, size = 16, className = '') {
if (!(name in svgs)) return '';
if (!(name in svgs)) throw new Error(`Unknown SVG icon: ${name}`);
if (size === 16 && !className) return svgs[name];
const document = parser.parseFromString(svgs[name], 'image/svg+xml');

Loading…
Cancel
Save