code.gitea.io/gitea@v1.21.7/web_src/js/components/DashboardRepoList.vue (about) 1 <script> 2 import {createApp, nextTick} from 'vue'; 3 import $ from 'jquery'; 4 import {SvgIcon} from '../svg.js'; 5 import {GET} from '../modules/fetch.js'; 6 7 const {appSubUrl, assetUrlPrefix, pageData} = window.config; 8 9 // make sure this matches templates/repo/commit_status.tmpl 10 const commitStatus = { 11 pending: {name: 'octicon-dot-fill', color: 'yellow'}, 12 success: {name: 'octicon-check', color: 'green'}, 13 error: {name: 'gitea-exclamation', color: 'red'}, 14 failure: {name: 'octicon-x', color: 'red'}, 15 warning: {name: 'gitea-exclamation', color: 'yellow'}, 16 }; 17 18 const sfc = { 19 components: {SvgIcon}, 20 data() { 21 const params = new URLSearchParams(window.location.search); 22 const tab = params.get('repo-search-tab') || 'repos'; 23 const reposFilter = params.get('repo-search-filter') || 'all'; 24 const privateFilter = params.get('repo-search-private') || 'both'; 25 const archivedFilter = params.get('repo-search-archived') || 'unarchived'; 26 const searchQuery = params.get('repo-search-query') || ''; 27 const page = Number(params.get('repo-search-page')) || 1; 28 29 return { 30 tab, 31 repos: [], 32 reposTotalCount: 0, 33 reposFilter, 34 archivedFilter, 35 privateFilter, 36 page, 37 finalPage: 1, 38 searchQuery, 39 isLoading: false, 40 staticPrefix: assetUrlPrefix, 41 counts: {}, 42 repoTypes: { 43 all: { 44 searchMode: '', 45 }, 46 forks: { 47 searchMode: 'fork', 48 }, 49 mirrors: { 50 searchMode: 'mirror', 51 }, 52 sources: { 53 searchMode: 'source', 54 }, 55 collaborative: { 56 searchMode: 'collaborative', 57 }, 58 }, 59 textArchivedFilterTitles: {}, 60 textPrivateFilterTitles: {}, 61 62 organizations: [], 63 isOrganization: true, 64 canCreateOrganization: false, 65 organizationsTotalCount: 0, 66 organizationId: 0, 67 68 subUrl: appSubUrl, 69 ...pageData.dashboardRepoList, 70 activeIndex: -1, // don't select anything at load, first cursor down will select 71 }; 72 }, 73 74 computed: { 75 showMoreReposLink() { 76 return this.repos.length > 0 && this.repos.length < this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`]; 77 }, 78 searchURL() { 79 return `${this.subUrl}/repo/search?sort=updated&order=desc&uid=${this.uid}&team_id=${this.teamId}&q=${this.searchQuery 80 }&page=${this.page}&limit=${this.searchLimit}&mode=${this.repoTypes[this.reposFilter].searchMode 81 }${this.reposFilter !== 'all' ? '&exclusive=1' : '' 82 }${this.archivedFilter === 'archived' ? '&archived=true' : ''}${this.archivedFilter === 'unarchived' ? '&archived=false' : '' 83 }${this.privateFilter === 'private' ? '&is_private=true' : ''}${this.privateFilter === 'public' ? '&is_private=false' : '' 84 }`; 85 }, 86 repoTypeCount() { 87 return this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`]; 88 }, 89 checkboxArchivedFilterTitle() { 90 return this.textArchivedFilterTitles[this.archivedFilter]; 91 }, 92 checkboxArchivedFilterProps() { 93 return {checked: this.archivedFilter === 'archived', indeterminate: this.archivedFilter === 'both'}; 94 }, 95 checkboxPrivateFilterTitle() { 96 return this.textPrivateFilterTitles[this.privateFilter]; 97 }, 98 checkboxPrivateFilterProps() { 99 return {checked: this.privateFilter === 'private', indeterminate: this.privateFilter === 'both'}; 100 }, 101 }, 102 103 mounted() { 104 const el = document.getElementById('dashboard-repo-list'); 105 this.changeReposFilter(this.reposFilter); 106 $(el).find('.dropdown').dropdown(); 107 nextTick(() => { 108 this.$refs.search.focus(); 109 }); 110 111 this.textArchivedFilterTitles = { 112 'archived': this.textShowOnlyArchived, 113 'unarchived': this.textShowOnlyUnarchived, 114 'both': this.textShowBothArchivedUnarchived, 115 }; 116 117 this.textPrivateFilterTitles = { 118 'private': this.textShowOnlyPrivate, 119 'public': this.textShowOnlyPublic, 120 'both': this.textShowBothPrivatePublic, 121 }; 122 }, 123 124 methods: { 125 changeTab(t) { 126 this.tab = t; 127 this.updateHistory(); 128 }, 129 130 changeReposFilter(filter) { 131 this.reposFilter = filter; 132 this.repos = []; 133 this.page = 1; 134 this.counts[`${filter}:${this.archivedFilter}:${this.privateFilter}`] = 0; 135 this.searchRepos(); 136 }, 137 138 updateHistory() { 139 const params = new URLSearchParams(window.location.search); 140 141 if (this.tab === 'repos') { 142 params.delete('repo-search-tab'); 143 } else { 144 params.set('repo-search-tab', this.tab); 145 } 146 147 if (this.reposFilter === 'all') { 148 params.delete('repo-search-filter'); 149 } else { 150 params.set('repo-search-filter', this.reposFilter); 151 } 152 153 if (this.privateFilter === 'both') { 154 params.delete('repo-search-private'); 155 } else { 156 params.set('repo-search-private', this.privateFilter); 157 } 158 159 if (this.archivedFilter === 'unarchived') { 160 params.delete('repo-search-archived'); 161 } else { 162 params.set('repo-search-archived', this.archivedFilter); 163 } 164 165 if (this.searchQuery === '') { 166 params.delete('repo-search-query'); 167 } else { 168 params.set('repo-search-query', this.searchQuery); 169 } 170 171 if (this.page === 1) { 172 params.delete('repo-search-page'); 173 } else { 174 params.set('repo-search-page', `${this.page}`); 175 } 176 177 const queryString = params.toString(); 178 if (queryString) { 179 window.history.replaceState({}, '', `?${queryString}`); 180 } else { 181 window.history.replaceState({}, '', window.location.pathname); 182 } 183 }, 184 185 toggleArchivedFilter() { 186 if (this.archivedFilter === 'unarchived') { 187 this.archivedFilter = 'archived'; 188 } else if (this.archivedFilter === 'archived') { 189 this.archivedFilter = 'both'; 190 } else { // including both 191 this.archivedFilter = 'unarchived'; 192 } 193 this.page = 1; 194 this.repos = []; 195 this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0; 196 this.searchRepos(); 197 }, 198 199 togglePrivateFilter() { 200 if (this.privateFilter === 'both') { 201 this.privateFilter = 'public'; 202 } else if (this.privateFilter === 'public') { 203 this.privateFilter = 'private'; 204 } else { // including private 205 this.privateFilter = 'both'; 206 } 207 this.page = 1; 208 this.repos = []; 209 this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0; 210 this.searchRepos(); 211 }, 212 213 214 changePage(page) { 215 this.page = page; 216 if (this.page > this.finalPage) { 217 this.page = this.finalPage; 218 } 219 if (this.page < 1) { 220 this.page = 1; 221 } 222 this.repos = []; 223 this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0; 224 this.searchRepos(); 225 }, 226 227 async searchRepos() { 228 this.isLoading = true; 229 230 const searchedMode = this.repoTypes[this.reposFilter].searchMode; 231 const searchedURL = this.searchURL; 232 const searchedQuery = this.searchQuery; 233 234 let response, json; 235 try { 236 if (!this.reposTotalCount) { 237 const totalCountSearchURL = `${this.subUrl}/repo/search?count_only=1&uid=${this.uid}&team_id=${this.teamId}&q=&page=1&mode=`; 238 response = await GET(totalCountSearchURL); 239 this.reposTotalCount = response.headers.get('X-Total-Count'); 240 } 241 242 response = await GET(searchedURL); 243 json = await response.json(); 244 } catch { 245 if (searchedURL === this.searchURL) { 246 this.isLoading = false; 247 } 248 return; 249 } 250 251 if (searchedURL === this.searchURL) { 252 this.repos = json.data.map((webSearchRepo) => { 253 return { 254 ...webSearchRepo.repository, 255 latest_commit_status_state: webSearchRepo.latest_commit_status.State, 256 locale_latest_commit_status_state: webSearchRepo.locale_latest_commit_status, 257 latest_commit_status_state_link: webSearchRepo.latest_commit_status.TargetURL 258 }; 259 }); 260 const count = response.headers.get('X-Total-Count'); 261 if (searchedQuery === '' && searchedMode === '' && this.archivedFilter === 'both') { 262 this.reposTotalCount = count; 263 } 264 this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = count; 265 this.finalPage = Math.ceil(count / this.searchLimit); 266 this.updateHistory(); 267 this.isLoading = false; 268 } 269 }, 270 271 repoIcon(repo) { 272 if (repo.fork) { 273 return 'octicon-repo-forked'; 274 } else if (repo.mirror) { 275 return 'octicon-mirror'; 276 } else if (repo.template) { 277 return `octicon-repo-template`; 278 } else if (repo.private) { 279 return 'octicon-lock'; 280 } else if (repo.internal) { 281 return 'octicon-repo'; 282 } 283 return 'octicon-repo'; 284 }, 285 286 statusIcon(status) { 287 return commitStatus[status].name; 288 }, 289 290 statusColor(status) { 291 return commitStatus[status].color; 292 }, 293 294 reposFilterKeyControl(e) { 295 switch (e.key) { 296 case 'Enter': 297 document.querySelector('.repo-owner-name-list li.active a')?.click(); 298 break; 299 case 'ArrowUp': 300 if (this.activeIndex > 0) { 301 this.activeIndex--; 302 } else if (this.page > 1) { 303 this.changePage(this.page - 1); 304 this.activeIndex = this.searchLimit - 1; 305 } 306 break; 307 case 'ArrowDown': 308 if (this.activeIndex < this.repos.length - 1) { 309 this.activeIndex++; 310 } else if (this.page < this.finalPage) { 311 this.activeIndex = 0; 312 this.changePage(this.page + 1); 313 } 314 break; 315 case 'ArrowRight': 316 if (this.page < this.finalPage) { 317 this.changePage(this.page + 1); 318 } 319 break; 320 case 'ArrowLeft': 321 if (this.page > 1) { 322 this.changePage(this.page - 1); 323 } 324 break; 325 } 326 if (this.activeIndex === -1 || this.activeIndex > this.repos.length - 1) { 327 this.activeIndex = 0; 328 } 329 } 330 }, 331 }; 332 333 export function initDashboardRepoList() { 334 const el = document.getElementById('dashboard-repo-list'); 335 if (el) { 336 createApp(sfc).mount(el); 337 } 338 } 339 340 export default sfc; // activate the IDE's Vue plugin 341 </script> 342 <template> 343 <div> 344 <div v-if="!isOrganization" class="ui two item menu"> 345 <a :class="{item: true, active: tab === 'repos'}" @click="changeTab('repos')">{{ textRepository }}</a> 346 <a :class="{item: true, active: tab === 'organizations'}" @click="changeTab('organizations')">{{ textOrganization }}</a> 347 </div> 348 <div v-show="tab === 'repos'" class="ui tab active list dashboard-repos"> 349 <h4 class="ui top attached header gt-df gt-ac"> 350 <div class="gt-f1 gt-df gt-ac"> 351 {{ textMyRepos }} 352 <span class="ui grey label gt-ml-3">{{ reposTotalCount }}</span> 353 </div> 354 <a class="gt-df gt-ac muted" :href="subUrl + '/repo/create' + (isOrganization ? '?org=' + organizationId : '')" :data-tooltip-content="textNewRepo"> 355 <svg-icon name="octicon-plus"/> 356 </a> 357 </h4> 358 <div class="ui attached segment repos-search"> 359 <div class="ui fluid action left icon input" :class="{loading: isLoading}"> 360 <input type="search" spellcheck="false" maxlength="255" @input="changeReposFilter(reposFilter)" v-model="searchQuery" ref="search" @keydown="reposFilterKeyControl" :placeholder="textSearchRepos"> 361 <i class="icon"><svg-icon name="octicon-search" :size="16"/></i> 362 <div class="ui dropdown icon button" :title="textFilter"> 363 <svg-icon name="octicon-filter" :size="16"/> 364 <div class="menu"> 365 <a class="item" @click="toggleArchivedFilter()"> 366 <div class="ui checkbox" ref="checkboxArchivedFilter" :title="checkboxArchivedFilterTitle"> 367 <!--the "hidden" is necessary to make the checkbox work without Fomantic UI js, 368 otherwise if the "input" handles click event for intermediate status, it breaks the internal state--> 369 <input type="checkbox" class="hidden" v-bind.prop="checkboxArchivedFilterProps"> 370 <label> 371 <svg-icon name="octicon-archive" :size="16" class-name="gt-mr-2"/> 372 {{ textShowArchived }} 373 </label> 374 </div> 375 </a> 376 <a class="item" @click="togglePrivateFilter()"> 377 <div class="ui checkbox" ref="checkboxPrivateFilter" :title="checkboxPrivateFilterTitle"> 378 <input type="checkbox" class="hidden" v-bind.prop="checkboxPrivateFilterProps"> 379 <label> 380 <svg-icon name="octicon-lock" :size="16" class-name="gt-mr-2"/> 381 {{ textShowPrivate }} 382 </label> 383 </div> 384 </a> 385 </div> 386 </div> 387 </div> 388 <div class="ui secondary tiny pointing borderless menu center grid repos-filter"> 389 <a class="item" :class="{active: reposFilter === 'all'}" @click="changeReposFilter('all')"> 390 {{ textAll }} 391 <div v-show="reposFilter === 'all'" class="ui circular mini grey label">{{ repoTypeCount }}</div> 392 </a> 393 <a class="item" :class="{active: reposFilter === 'sources'}" @click="changeReposFilter('sources')"> 394 {{ textSources }} 395 <div v-show="reposFilter === 'sources'" class="ui circular mini grey label">{{ repoTypeCount }}</div> 396 </a> 397 <a class="item" :class="{active: reposFilter === 'forks'}" @click="changeReposFilter('forks')"> 398 {{ textForks }} 399 <div v-show="reposFilter === 'forks'" class="ui circular mini grey label">{{ repoTypeCount }}</div> 400 </a> 401 <a class="item" :class="{active: reposFilter === 'mirrors'}" @click="changeReposFilter('mirrors')" v-if="isMirrorsEnabled"> 402 {{ textMirrors }} 403 <div v-show="reposFilter === 'mirrors'" class="ui circular mini grey label">{{ repoTypeCount }}</div> 404 </a> 405 <a class="item" :class="{active: reposFilter === 'collaborative'}" @click="changeReposFilter('collaborative')"> 406 {{ textCollaborative }} 407 <div v-show="reposFilter === 'collaborative'" class="ui circular mini grey label">{{ repoTypeCount }}</div> 408 </a> 409 </div> 410 </div> 411 <div v-if="repos.length" class="ui attached table segment gt-rounded-bottom"> 412 <ul class="repo-owner-name-list"> 413 <li class="gt-df gt-ac gt-py-3" v-for="repo, index in repos" :class="{'active': index === activeIndex}" :key="repo.id"> 414 <a class="repo-list-link muted" :href="repo.link"> 415 <svg-icon :name="repoIcon(repo)" :size="16" class-name="repo-list-icon"/> 416 <div class="text truncate">{{ repo.full_name }}</div> 417 <div v-if="repo.archived"> 418 <svg-icon name="octicon-archive" :size="16"/> 419 </div> 420 </a> 421 <a class="gt-df gt-ac" v-if="repo.latest_commit_status_state" :href="repo.latest_commit_status_state_link" :data-tooltip-content="repo.locale_latest_commit_status_state"> 422 <!-- the commit status icon logic is taken from templates/repo/commit_status.tmpl --> 423 <svg-icon :name="statusIcon(repo.latest_commit_status_state)" :class-name="'gt-ml-3 commit-status icon text ' + statusColor(repo.latest_commit_status_state)" :size="16"/> 424 </a> 425 </li> 426 </ul> 427 <div v-if="showMoreReposLink" class="center gt-py-3 gt-border-secondary-top"> 428 <div class="ui borderless pagination menu narrow"> 429 <a 430 class="item navigation gt-py-2" :class="{'disabled': page === 1}" 431 @click="changePage(1)" :title="textFirstPage" 432 > 433 <svg-icon name="gitea-double-chevron-left" :size="16" class-name="gt-mr-2"/> 434 </a> 435 <a 436 class="item navigation gt-py-2" :class="{'disabled': page === 1}" 437 @click="changePage(page - 1)" :title="textPreviousPage" 438 > 439 <svg-icon name="octicon-chevron-left" :size="16" clsas-name="gt-mr-2"/> 440 </a> 441 <a class="active item gt-py-2">{{ page }}</a> 442 <a 443 class="item navigation" :class="{'disabled': page === finalPage}" 444 @click="changePage(page + 1)" :title="textNextPage" 445 > 446 <svg-icon name="octicon-chevron-right" :size="16" class-name="gt-ml-2"/> 447 </a> 448 <a 449 class="item navigation gt-py-2" :class="{'disabled': page === finalPage}" 450 @click="changePage(finalPage)" :title="textLastPage" 451 > 452 <svg-icon name="gitea-double-chevron-right" :size="16" class-name="gt-ml-2"/> 453 </a> 454 </div> 455 </div> 456 </div> 457 </div> 458 <div v-if="!isOrganization" v-show="tab === 'organizations'" class="ui tab active list dashboard-orgs"> 459 <h4 class="ui top attached header gt-df gt-ac"> 460 <div class="gt-f1 gt-df gt-ac"> 461 {{ textMyOrgs }} 462 <span class="ui grey label gt-ml-3">{{ organizationsTotalCount }}</span> 463 </div> 464 <a class="gt-df gt-ac muted" v-if="canCreateOrganization" :href="subUrl + '/org/create'" :data-tooltip-content="textNewOrg"> 465 <svg-icon name="octicon-plus"/> 466 </a> 467 </h4> 468 <div v-if="organizations.length" class="ui attached table segment gt-rounded-bottom"> 469 <ul class="repo-owner-name-list"> 470 <li class="gt-df gt-ac gt-py-3" v-for="org in organizations" :key="org.name"> 471 <a class="repo-list-link muted" :href="subUrl + '/' + encodeURIComponent(org.name)"> 472 <svg-icon name="octicon-organization" :size="16" class-name="repo-list-icon"/> 473 <div class="text truncate">{{ org.name }}</div> 474 <div><!-- div to prevent underline of label on hover --> 475 <span class="ui tiny basic label" v-if="org.org_visibility !== 'public'"> 476 {{ org.org_visibility === 'limited' ? textOrgVisibilityLimited: textOrgVisibilityPrivate }} 477 </span> 478 </div> 479 </a> 480 <div class="text light grey gt-df gt-ac gt-ml-3"> 481 {{ org.num_repos }} 482 <svg-icon name="octicon-repo" :size="16" class-name="gt-ml-2 gt-mt-1"/> 483 </div> 484 </li> 485 </ul> 486 </div> 487 </div> 488 </div> 489 </template> 490 <style scoped> 491 ul { 492 list-style: none; 493 margin: 0; 494 padding-left: 0; 495 } 496 497 ul li { 498 padding: 0 10px; 499 } 500 501 ul li:not(:last-child) { 502 border-bottom: 1px solid var(--color-secondary); 503 } 504 505 .repo-list-link { 506 min-width: 0; /* for text truncation */ 507 display: flex; 508 align-items: center; 509 flex: 1; 510 gap: 0.5rem; 511 } 512 513 .repo-list-link .svg { 514 color: var(--color-text-light-2); 515 } 516 517 .repo-list-icon { 518 min-width: 16px; 519 margin-right: 2px; 520 } 521 522 /* octicon-mirror has no padding inside the SVG */ 523 .repo-list-icon.octicon-mirror { 524 width: 14px; 525 min-width: 14px; 526 margin-left: 1px; 527 margin-right: 3px; 528 } 529 530 .repo-owner-name-list li.active { 531 background: var(--color-hover); 532 } 533 </style>