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>
This commit is contained in:
wxiaoguang 2023-03-14 12:09:06 +08:00 committed by GitHub
parent b942838bd4
commit e82f1b15c7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 516 additions and 585 deletions

View file

@ -1,181 +1,54 @@
<div id="dashboard-repo-list" class="six wide column"> <script type="module">
<repo-search const data = {
:search-limit="searchLimit" ...window.config.pageData.dashboardRepoList, // it only contains searchLimit and uid
:sub-url="subUrl"
:uid="uid"
{{if .Team}}
:team-id="{{.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}}"
{{end}}
inline-template
v-cloak
></repo-search>
</div>
<template id="dashboard-repo-list-template"> isMirrorsEnabled: {{.IsMirrorsEnabled}},
<div> isStarsEnabled: {{not .IsDisableStars}},
<div v-if="!isOrganization" class="ui two item tabable menu">
<a :class="{item: true, active: tab === 'repos'}" @click="changeTab('repos')">{{.locale.Tr "repository"}}</a> textRepository: {{.locale.Tr "repository"}},
<a :class="{item: true, active: tab === 'organizations'}" @click="changeTab('organizations')">{{.locale.Tr "organization"}}</a> textOrganization: {{.locale.Tr "organization"}},
</div> textMyRepos: {{.locale.Tr "home.my_repos"}},
<div v-show="tab === 'repos'" class="ui tab active list dashboard-repos"> textNewRepo: {{.locale.Tr "new_repo"}},
<h4 class="ui top attached header gt-df gt-ac"> textSearchRepos: {{.locale.Tr "home.search_repos"}},
<div class="gt-f1 gt-df gt-ac"> textFilter: {{.locale.Tr "home.filter"}},
{{.locale.Tr "home.my_repos"}} textShowArchived: {{.locale.Tr "home.show_archived"}},
<span class="ui grey label gt-ml-3">${reposTotalCount}</span> textShowPrivate: {{.locale.Tr "home.show_private"}},
</div>
<a class="tooltip" :href="subUrl + '/repo/create'" data-content="{{.locale.Tr "new_repo"}}" data-position="left center"> textShowBothArchivedUnarchived: {{.locale.Tr "home.show_both_archived_unarchived"}},
{{svg "octicon-plus"}} textShowOnlyUnarchived: {{.locale.Tr "home.show_only_unarchived"}},
<span class="sr-only">{{.locale.Tr "new_repo"}}</span> textShowOnlyArchived: {{.locale.Tr "home.show_only_archived"}},
</a>
</h4> textShowBothPrivatePublic: {{.locale.Tr "home.show_both_private_public"}},
<div class="ui attached segment repos-search"> textShowOnlyPublic: {{.locale.Tr "home.show_only_public"}},
<div class="ui fluid right action left icon input" :class="{loading: isLoading}"> textShowOnlyPrivate: {{.locale.Tr "home.show_only_private"}},
<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> textAll: {{.locale.Tr "all"}},
<div class="ui dropdown icon button" title="{{.locale.Tr "home.filter"}}"> textSources: {{.locale.Tr "sources"}},
<i class="icon gt-df gt-ac gt-jc gt-m-0">{{svg "octicon-filter" 16}}</i> textForks: {{.locale.Tr "forks"}},
<div class="menu"> textMirrors: {{.locale.Tr "mirrors"}},
<a class="item" @click="toggleArchivedFilter()"> textCollaborative: {{.locale.Tr "collaborative"}},
<div class="ui checkbox"
ref="checkboxArchivedFilter" textFirstPage: {{.locale.Tr "admin.first_page"}},
data-title-both="{{.locale.Tr "home.show_both_archived_unarchived"}}" textPreviousPage: {{.locale.Tr "repo.issues.previous"}},
data-title-unarchived="{{.locale.Tr "home.show_only_unarchived"}}" textNextPage: {{.locale.Tr "repo.issues.next"}},
data-title-archived="{{.locale.Tr "home.show_only_archived"}}" textLastPage: {{.locale.Tr "admin.last_page"}},
:title="checkboxArchivedFilterTitle"
> textMyOrgs: {{.locale.Tr "home.my_orgs"}},
<!--the "hidden" is necessary to make the checkbox work without Fomantic UI js, textNewOrg: {{.locale.Tr "new_org"}},
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> {{if .Team}}
{{svg "octicon-archive" 16 "gt-mr-2"}} data.teamId = {{.Team.ID}};
{{.locale.Tr "home.show_archived"}} {{end}}
</label>
</div> {{if not .ContextUser.IsOrganization}}
</a> data.organizations = [{{range .Orgs}}{'name': {{.Name}}, 'num_repos': {{.NumRepos}}},{{end}}];
<a class="item" @click="togglePrivateFilter()"> data.isOrganization = false;
<div class="ui checkbox" data.organizationsTotalCount = {{.UserOrgsCount}}
ref="checkboxPrivateFilter" data.canCreateOrganization = {{.SignedUser.CanCreateOrganization}}
data-title-both="{{.locale.Tr "home.show_both_private_public"}}" {{end}}
data-title-public="{{.locale.Tr "home.show_only_public"}}"
data-title-private="{{.locale.Tr "home.show_only_private"}}" window.config.pageData.dashboardRepoList = data;
:title="checkboxPrivateFilterTitle" </script>
>
<input type="checkbox" class="hidden" v-bind.prop="checkboxPrivateFilterProps"> <div id="dashboard-repo-list" class="six wide column"></div>
<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>

View file

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

View file

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

View file

@ -51,7 +51,7 @@
<script> <script>
import VueBarGraph from 'vue-bar-graph'; import VueBarGraph from 'vue-bar-graph';
import {initVueApp} from './VueComponentLoader.js'; import {createApp} from 'vue';
const sfc = { const sfc = {
components: {VueBarGraph}, components: {VueBarGraph},
@ -102,8 +102,11 @@ const sfc = {
}; };
export function initRepoActivityTopAuthorsChart() { 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> </script>

View file

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

View file

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

View file

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

View file

@ -31,8 +31,17 @@ import octiconSkip from '../../public/img/svg/octicon-skip.svg';
import octiconMeter from '../../public/img/svg/octicon-meter.svg'; import octiconMeter from '../../public/img/svg/octicon-meter.svg';
import octiconBlocked from '../../public/img/svg/octicon-blocked.svg'; import octiconBlocked from '../../public/img/svg/octicon-blocked.svg';
import octiconSync from '../../public/img/svg/octicon-sync.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-blocked': octiconBlocked,
'octicon-check-circle-fill': octiconCheckCircleFill, 'octicon-check-circle-fill': octiconCheckCircleFill,
'octicon-chevron-down': octiconChevronDown, 'octicon-chevron-down': octiconChevronDown,
@ -66,14 +75,25 @@ export const svgs = {
'octicon-triangle-down': octiconTriangleDown, 'octicon-triangle-down': octiconTriangleDown,
'octicon-x': octiconX, 'octicon-x': octiconX,
'octicon-x-circle-fill': octiconXCircleFill, '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 parser = new DOMParser();
const serializer = new XMLSerializer(); 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 = '') { 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]; if (size === 16 && !className) return svgs[name];
const document = parser.parseFromString(svgs[name], 'image/svg+xml'); const document = parser.parseFromString(svgs[name], 'image/svg+xml');