You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

DashboardRepoList.vue 20KB

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