]> source.dussan.org Git - gitea.git/commitdiff
Refactor dashboard repo list to Vue SFC (#23405)
authorwxiaoguang <wxiaoguang@gmail.com>
Tue, 14 Mar 2023 04:09:06 +0000 (12:09 +0800)
committerGitHub <noreply@github.com>
Tue, 14 Mar 2023 04:09:06 +0000 (12:09 +0800)
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
https://github.com/go-gitea/gitea/pull/23405/commits/d3adc0dcacf7de87b9819277e6598ac3993bbfa3
2. move the code into a Vue SFC
https://github.com/go-gitea/gitea/pull/23405/commits/7ebe55df6e67adfd272a4bf0a96ad6688edf661f

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>
templates/user/dashboard/repolist.tmpl
web_src/js/components/DashboardRepoList.js [deleted file]
web_src/js/components/DashboardRepoList.vue [new file with mode: 0644]
web_src/js/components/RepoActivityTopAuthors.vue
web_src/js/components/RepoBranchTagDropdown.js
web_src/js/components/VueComponentLoader.js [deleted file]
web_src/js/index.js
web_src/js/svg.js

index 97234176bd2104137a91a39acae90347a68853ab..0a8f427f9da15a98ce811918e19077e60d761df2 100644 (file)
-<div id="dashboard-repo-list" class="six wide column">
-       <repo-search
-       :search-limit="searchLimit"
-       :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>
+<script type="module">
+const data = {
+       ...window.config.pageData.dashboardRepoList, // it only contains searchLimit and uid
 
-<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>
+       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}}
+data.teamId = {{.Team.ID}};
+{{end}}
+
+{{if not .ContextUser.IsOrganization}}
+data.organizations = [{{range .Orgs}}{'name': {{.Name}}, 'num_repos': {{.NumRepos}}},{{end}}];
+data.isOrganization = false;
+data.organizationsTotalCount = {{.UserOrgsCount}}
+data.canCreateOrganization = {{.SignedUser.CanCreateOrganization}}
+{{end}}
+
+window.config.pageData.dashboardRepoList = data;
+</script>
+
+<div id="dashboard-repo-list" class="six wide column"></div>
diff --git a/web_src/js/components/DashboardRepoList.js b/web_src/js/components/DashboardRepoList.js
deleted file mode 100644 (file)
index 2328cc8..0000000
+++ /dev/null
@@ -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);
-}
diff --git a/web_src/js/components/DashboardRepoList.vue b/web_src/js/components/DashboardRepoList.vue
new file mode 100644 (file)
index 0000000..e295910
--- /dev/null
@@ -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>
index 37b6df91878f9d1a2c0c4f5c31637b9d6c68d1cd..294ee6f7bcce833ed0f9b78b4933d144bfaa58b0 100644 (file)
@@ -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>
index e1bf35c1294d06660e99c75c5b182806f948ca45..a8945b82d162db6f70531c7d83c595561e6c6ad4 100644 (file)
@@ -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;
       },
diff --git a/web_src/js/components/VueComponentLoader.js b/web_src/js/components/VueComponentLoader.js
deleted file mode 100644 (file)
index 33ebf95..0000000
+++ /dev/null
@@ -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);
-}
index 6b4f4ef3ebe129866d7c74dc4158996004a4446c..480661118bc65907bf1762b2edb8d5d60f129690 100644 (file)
@@ -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();
 
index 6476f16bfb3ebb88727f6c890c7541bfe8354296..9eabca3fd3f4513f89d1f3a9981298f265fe9f41 100644 (file)
@@ -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');