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.

FilesList.vue 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688
  1. <!--
  2. - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
  3. -
  4. - @author John Molakvoæ <skjnldsv@protonmail.com>
  5. -
  6. - @license GNU AGPL version 3 or any later version
  7. -
  8. - This program is free software: you can redistribute it and/or modify
  9. - it under the terms of the GNU Affero General Public License as
  10. - published by the Free Software Foundation, either version 3 of the
  11. - License, or (at your option) any later version.
  12. -
  13. - This program is distributed in the hope that it will be useful,
  14. - but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. - GNU Affero General Public License for more details.
  17. -
  18. - You should have received a copy of the GNU Affero General Public License
  19. - along with this program. If not, see <http://www.gnu.org/licenses/>.
  20. -
  21. -->
  22. <template>
  23. <NcAppContent :page-heading="pageHeading" data-cy-files-content>
  24. <div class="files-list__header">
  25. <!-- Current folder breadcrumbs -->
  26. <BreadCrumbs :path="dir" @reload="fetchContent">
  27. <template #actions>
  28. <!-- Sharing button -->
  29. <NcButton v-if="canShare && filesListWidth >= 512"
  30. :aria-label="shareButtonLabel"
  31. :class="{ 'files-list__header-share-button--shared': shareButtonType }"
  32. :title="shareButtonLabel"
  33. class="files-list__header-share-button"
  34. type="tertiary"
  35. @click="openSharingSidebar">
  36. <template #icon>
  37. <LinkIcon v-if="shareButtonType === Type.SHARE_TYPE_LINK" />
  38. <AccountPlusIcon v-else :size="20" />
  39. </template>
  40. </NcButton>
  41. <!-- Disabled upload button -->
  42. <NcButton v-if="!canUpload || isQuotaExceeded"
  43. :aria-label="cantUploadLabel"
  44. :title="cantUploadLabel"
  45. class="files-list__header-upload-button--disabled"
  46. :disabled="true"
  47. type="secondary">
  48. <template #icon>
  49. <PlusIcon :size="20" />
  50. </template>
  51. {{ t('files', 'New') }}
  52. </NcButton>
  53. <!-- Uploader -->
  54. <UploadPicker v-else-if="currentFolder"
  55. :content="dirContents"
  56. :destination="currentFolder"
  57. :multiple="true"
  58. class="files-list__header-upload-button"
  59. @failed="onUploadFail"
  60. @uploaded="onUpload" />
  61. </template>
  62. </BreadCrumbs>
  63. <NcButton v-if="filesListWidth >= 512 && enableGridView"
  64. :aria-label="gridViewButtonLabel"
  65. :title="gridViewButtonLabel"
  66. class="files-list__header-grid-button"
  67. type="tertiary"
  68. @click="toggleGridView">
  69. <template #icon>
  70. <ListViewIcon v-if="userConfig.grid_view" />
  71. <ViewGridIcon v-else />
  72. </template>
  73. </NcButton>
  74. <!-- Secondary loading indicator -->
  75. <NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" />
  76. </div>
  77. <!-- Drag and drop notice -->
  78. <DragAndDropNotice v-if="!loading && canUpload" :current-folder="currentFolder" />
  79. <!-- Initial loading -->
  80. <NcLoadingIcon v-if="loading && !isRefreshing"
  81. class="files-list__loading-icon"
  82. :size="38"
  83. :name="t('files', 'Loading current folder')" />
  84. <!-- Empty content placeholder -->
  85. <NcEmptyContent v-else-if="!loading && isEmptyDir"
  86. :name="currentView?.emptyTitle || t('files', 'No files in here')"
  87. :description="currentView?.emptyCaption || t('files', 'Upload some content or sync with your devices!')"
  88. data-cy-files-content-empty>
  89. <template #action>
  90. <NcButton v-if="dir !== '/'"
  91. :aria-label="t('files', 'Go to the previous folder')"
  92. type="primary"
  93. :to="toPreviousDir">
  94. {{ t('files', 'Go back') }}
  95. </NcButton>
  96. </template>
  97. <template #icon>
  98. <NcIconSvgWrapper :svg="currentView.icon" />
  99. </template>
  100. </NcEmptyContent>
  101. <!-- File list -->
  102. <FilesListVirtual v-else
  103. ref="filesListVirtual"
  104. :current-folder="currentFolder"
  105. :current-view="currentView"
  106. :nodes="dirContentsSorted" />
  107. </NcAppContent>
  108. </template>
  109. <script lang="ts">
  110. import type { Route } from 'vue-router'
  111. import type { Upload } from '@nextcloud/upload'
  112. import type { UserConfig } from '../types.ts'
  113. import type { View, ContentsWithRoot } from '@nextcloud/files'
  114. import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
  115. import { Folder, Node, Permission } from '@nextcloud/files'
  116. import { getCapabilities } from '@nextcloud/capabilities'
  117. import { join, dirname } from 'path'
  118. import { orderBy } from 'natural-orderby'
  119. import { Parser } from 'xml2js'
  120. import { showError } from '@nextcloud/dialogs'
  121. import { translate, translatePlural } from '@nextcloud/l10n'
  122. import { Type } from '@nextcloud/sharing'
  123. import { UploadPicker } from '@nextcloud/upload'
  124. import { loadState } from '@nextcloud/initial-state'
  125. import { defineComponent } from 'vue'
  126. import LinkIcon from 'vue-material-design-icons/Link.vue'
  127. import ListViewIcon from 'vue-material-design-icons/FormatListBulletedSquare.vue'
  128. import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
  129. import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
  130. import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
  131. import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
  132. import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
  133. import PlusIcon from 'vue-material-design-icons/Plus.vue'
  134. import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue'
  135. import ViewGridIcon from 'vue-material-design-icons/ViewGrid.vue'
  136. import { action as sidebarAction } from '../actions/sidebarAction.ts'
  137. import { useFilesStore } from '../store/files.ts'
  138. import { usePathsStore } from '../store/paths.ts'
  139. import { useSelectionStore } from '../store/selection.ts'
  140. import { useUploaderStore } from '../store/uploader.ts'
  141. import { useUserConfigStore } from '../store/userconfig.ts'
  142. import { useViewConfigStore } from '../store/viewConfig.ts'
  143. import BreadCrumbs from '../components/BreadCrumbs.vue'
  144. import FilesListVirtual from '../components/FilesListVirtual.vue'
  145. import filesListWidthMixin from '../mixins/filesListWidth.ts'
  146. import filesSortingMixin from '../mixins/filesSorting.ts'
  147. import logger from '../logger.js'
  148. import DragAndDropNotice from '../components/DragAndDropNotice.vue'
  149. import debounce from 'debounce'
  150. const isSharingEnabled = (getCapabilities() as { files_sharing?: boolean })?.files_sharing !== undefined
  151. export default defineComponent({
  152. name: 'FilesList',
  153. components: {
  154. BreadCrumbs,
  155. DragAndDropNotice,
  156. FilesListVirtual,
  157. LinkIcon,
  158. ListViewIcon,
  159. NcAppContent,
  160. NcButton,
  161. NcEmptyContent,
  162. NcIconSvgWrapper,
  163. NcLoadingIcon,
  164. PlusIcon,
  165. AccountPlusIcon,
  166. UploadPicker,
  167. ViewGridIcon,
  168. },
  169. mixins: [
  170. filesListWidthMixin,
  171. filesSortingMixin,
  172. ],
  173. setup() {
  174. const filesStore = useFilesStore()
  175. const pathsStore = usePathsStore()
  176. const selectionStore = useSelectionStore()
  177. const uploaderStore = useUploaderStore()
  178. const userConfigStore = useUserConfigStore()
  179. const viewConfigStore = useViewConfigStore()
  180. const enableGridView = (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true)
  181. return {
  182. filesStore,
  183. pathsStore,
  184. selectionStore,
  185. uploaderStore,
  186. userConfigStore,
  187. viewConfigStore,
  188. enableGridView,
  189. }
  190. },
  191. data() {
  192. return {
  193. filterText: '',
  194. loading: true,
  195. promise: null,
  196. Type,
  197. _unsubscribeStore: () => {},
  198. }
  199. },
  200. computed: {
  201. userConfig(): UserConfig {
  202. return this.userConfigStore.userConfig
  203. },
  204. currentView(): View {
  205. return this.$navigation.active || this.$navigation.views.find((view) => view.id === (this.$route.params?.view ?? 'files'))
  206. },
  207. pageHeading(): string {
  208. return this.currentView?.name ?? this.t('files', 'Files')
  209. },
  210. /**
  211. * The current directory query.
  212. */
  213. dir(): string {
  214. // Remove any trailing slash but leave root slash
  215. return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
  216. },
  217. /**
  218. * The current folder.
  219. */
  220. currentFolder(): Folder | undefined {
  221. if (!this.currentView?.id) {
  222. return
  223. }
  224. if (this.dir === '/') {
  225. return this.filesStore.getRoot(this.currentView.id)
  226. }
  227. const fileId = this.pathsStore.getPath(this.currentView.id, this.dir)
  228. return this.filesStore.getNode(fileId)
  229. },
  230. /**
  231. * Directory content sorting parameters
  232. * Provided by an extra computed property for caching
  233. */
  234. sortingParameters() {
  235. const identifiers = [
  236. // 1: Sort favorites first if enabled
  237. ...(this.userConfig.sort_favorites_first ? [v => v.attributes?.favorite !== 1] : []),
  238. // 2: Sort folders first if sorting by name
  239. ...(this.userConfig.sort_folders_first ? [v => v.type !== 'folder'] : []),
  240. // 3: Use sorting mode if NOT basename (to be able to use displayName too)
  241. ...(this.sortingMode !== 'basename' ? [v => v[this.sortingMode]] : []),
  242. // 4: Use displayName if available, fallback to name
  243. v => v.attributes?.displayName || v.basename,
  244. // 5: Finally, use basename if all previous sorting methods failed
  245. v => v.basename,
  246. ]
  247. const orders = [
  248. // (for 1): always sort favorites before normal files
  249. ...(this.userConfig.sort_favorites_first ? ['asc'] : []),
  250. // (for 2): always sort folders before files
  251. ...(this.userConfig.sort_folders_first ? ['asc'] : []),
  252. // (for 3): Reverse if sorting by mtime as mtime higher means edited more recent -> lower
  253. ...(this.sortingMode === 'mtime' ? [this.isAscSorting ? 'desc' : 'asc'] : []),
  254. // (also for 3 so make sure not to conflict with 2 and 3)
  255. ...(this.sortingMode !== 'mtime' && this.sortingMode !== 'basename' ? [this.isAscSorting ? 'asc' : 'desc'] : []),
  256. // for 4: use configured sorting direction
  257. this.isAscSorting ? 'asc' : 'desc',
  258. // for 5: use configured sorting direction
  259. this.isAscSorting ? 'asc' : 'desc',
  260. ]
  261. return [identifiers, orders] as const
  262. },
  263. /**
  264. * The current directory contents.
  265. */
  266. dirContentsSorted(): Node[] {
  267. if (!this.currentView) {
  268. return []
  269. }
  270. let filteredDirContent = [...this.dirContents]
  271. // Filter based on the filterText obtained from nextcloud:unified-search.search event.
  272. if (this.filterText) {
  273. filteredDirContent = filteredDirContent.filter(node => {
  274. return node.attributes.basename.toLowerCase().includes(this.filterText.toLowerCase())
  275. })
  276. console.debug('Files view filtered', filteredDirContent)
  277. }
  278. const customColumn = (this.currentView?.columns || [])
  279. .find(column => column.id === this.sortingMode)
  280. // Custom column must provide their own sorting methods
  281. if (customColumn?.sort && typeof customColumn.sort === 'function') {
  282. const results = [...this.dirContents].sort(customColumn.sort)
  283. return this.isAscSorting ? results : results.reverse()
  284. }
  285. return orderBy(
  286. filteredDirContent,
  287. ...this.sortingParameters,
  288. )
  289. },
  290. dirContents(): Node[] {
  291. const showHidden = this.userConfigStore?.userConfig.show_hidden
  292. return (this.currentFolder?._children || [])
  293. .map(this.getNode)
  294. .filter(file => {
  295. if (!showHidden) {
  296. return file && file?.attributes?.hidden !== true && !file?.basename.startsWith('.')
  297. }
  298. return !!file
  299. })
  300. },
  301. /**
  302. * The current directory is empty.
  303. */
  304. isEmptyDir(): boolean {
  305. return this.dirContents.length === 0
  306. },
  307. /**
  308. * We are refreshing the current directory.
  309. * But we already have a cached version of it
  310. * that is not empty.
  311. */
  312. isRefreshing(): boolean {
  313. return this.currentFolder !== undefined
  314. && !this.isEmptyDir
  315. && this.loading
  316. },
  317. /**
  318. * Route to the previous directory.
  319. */
  320. toPreviousDir(): Route {
  321. const dir = this.dir.split('/').slice(0, -1).join('/') || '/'
  322. return { ...this.$route, query: { dir } }
  323. },
  324. shareAttributes(): number[] | undefined {
  325. if (!this.currentFolder?.attributes?.['share-types']) {
  326. return undefined
  327. }
  328. return Object.values(this.currentFolder?.attributes?.['share-types'] || {}).flat() as number[]
  329. },
  330. shareButtonLabel() {
  331. if (!this.shareAttributes) {
  332. return this.t('files', 'Share')
  333. }
  334. if (this.shareButtonType === Type.SHARE_TYPE_LINK) {
  335. return this.t('files', 'Shared by link')
  336. }
  337. return this.t('files', 'Shared')
  338. },
  339. shareButtonType(): Type | null {
  340. if (!this.shareAttributes) {
  341. return null
  342. }
  343. // If all types are links, show the link icon
  344. if (this.shareAttributes.some(type => type === Type.SHARE_TYPE_LINK)) {
  345. return Type.SHARE_TYPE_LINK
  346. }
  347. return Type.SHARE_TYPE_USER
  348. },
  349. gridViewButtonLabel() {
  350. return this.userConfig.grid_view
  351. ? this.t('files', 'Switch to list view')
  352. : this.t('files', 'Switch to grid view')
  353. },
  354. /**
  355. * Check if the current folder has create permissions
  356. */
  357. canUpload() {
  358. return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) !== 0
  359. },
  360. isQuotaExceeded() {
  361. return this.currentFolder?.attributes?.['quota-available-bytes'] === 0
  362. },
  363. cantUploadLabel() {
  364. if (this.isQuotaExceeded) {
  365. return this.t('files', 'Your have used your space quota and cannot upload files anymore')
  366. }
  367. return this.t('files', 'You don’t have permission to upload or create files here')
  368. },
  369. /**
  370. * Check if current folder has share permissions
  371. */
  372. canShare() {
  373. return isSharingEnabled
  374. && this.currentFolder && (this.currentFolder.permissions & Permission.SHARE) !== 0
  375. },
  376. },
  377. watch: {
  378. currentView(newView, oldView) {
  379. if (newView?.id === oldView?.id) {
  380. return
  381. }
  382. logger.debug('View changed', { newView, oldView })
  383. this.selectionStore.reset()
  384. this.resetSearch()
  385. this.fetchContent()
  386. },
  387. dir(newDir, oldDir) {
  388. logger.debug('Directory changed', { newDir, oldDir })
  389. // TODO: preserve selection on browsing?
  390. this.selectionStore.reset()
  391. this.resetSearch()
  392. this.fetchContent()
  393. // Scroll to top, force virtual scroller to re-render
  394. if (this.$refs?.filesListVirtual?.$el) {
  395. this.$refs.filesListVirtual.$el.scrollTop = 0
  396. }
  397. },
  398. dirContents(contents) {
  399. logger.debug('Directory contents changed', { view: this.currentView, folder: this.currentFolder, contents })
  400. emit('files:list:updated', { view: this.currentView, folder: this.currentFolder, contents })
  401. },
  402. },
  403. mounted() {
  404. this.fetchContent()
  405. subscribe('files:node:updated', this.onUpdatedNode)
  406. subscribe('nextcloud:unified-search.search', this.onSearch)
  407. subscribe('nextcloud:unified-search.reset', this.onSearch)
  408. // reload on settings change
  409. this._unsubscribeStore = this.userConfigStore.$subscribe(() => this.fetchContent(), { deep: true })
  410. },
  411. unmounted() {
  412. unsubscribe('files:node:updated', this.onUpdatedNode)
  413. unsubscribe('nextcloud:unified-search.search', this.onSearch)
  414. unsubscribe('nextcloud:unified-search.reset', this.onSearch)
  415. this._unsubscribeStore()
  416. },
  417. methods: {
  418. async fetchContent() {
  419. this.loading = true
  420. const dir = this.dir
  421. const currentView = this.currentView
  422. if (!currentView) {
  423. logger.debug('The current view doesn\'t exists or is not ready.', { currentView })
  424. return
  425. }
  426. // If we have a cancellable promise ongoing, cancel it
  427. if (typeof this.promise?.cancel === 'function') {
  428. this.promise.cancel()
  429. logger.debug('Cancelled previous ongoing fetch')
  430. }
  431. // Fetch the current dir contents
  432. this.promise = currentView.getContents(dir) as Promise<ContentsWithRoot>
  433. try {
  434. const { folder, contents } = await this.promise
  435. logger.debug('Fetched contents', { dir, folder, contents })
  436. // Update store
  437. this.filesStore.updateNodes(contents)
  438. // Define current directory children
  439. // TODO: make it more official
  440. this.$set(folder, '_children', contents.map(node => node.fileid))
  441. // If we're in the root dir, define the root
  442. if (dir === '/') {
  443. this.filesStore.setRoot({ service: currentView.id, root: folder })
  444. } else {
  445. // Otherwise, add the folder to the store
  446. if (folder.fileid) {
  447. this.filesStore.updateNodes([folder])
  448. this.pathsStore.addPath({ service: currentView.id, fileid: folder.fileid, path: dir })
  449. } else {
  450. // If we're here, the view API messed up
  451. logger.error('Invalid root folder returned', { dir, folder, currentView })
  452. }
  453. }
  454. // Update paths store
  455. const folders = contents.filter(node => node.type === 'folder')
  456. folders.forEach(node => {
  457. this.pathsStore.addPath({ service: currentView.id, fileid: node.fileid, path: join(dir, node.basename) })
  458. })
  459. } catch (error) {
  460. logger.error('Error while fetching content', { error })
  461. } finally {
  462. this.loading = false
  463. }
  464. },
  465. /**
  466. * Get a cached note from the store
  467. *
  468. * @param {number} fileId the file id to get
  469. * @return {Folder|File}
  470. */
  471. getNode(fileId) {
  472. return this.filesStore.getNode(fileId)
  473. },
  474. /**
  475. * The upload manager have finished handling the queue
  476. * @param {Upload} upload the uploaded data
  477. */
  478. onUpload(upload: Upload) {
  479. // Let's only refresh the current Folder
  480. // Navigating to a different folder will refresh it anyway
  481. const destinationSource = dirname(upload.source)
  482. const needsRefresh = destinationSource === this.currentFolder?.source
  483. // TODO: fetch uploaded files data only
  484. // Use parseInt(upload.response?.headers?.['oc-fileid']) to get the fileid
  485. if (needsRefresh) {
  486. // fetchContent will cancel the previous ongoing promise
  487. this.fetchContent()
  488. }
  489. },
  490. async onUploadFail(upload: Upload) {
  491. const status = upload.response?.status || 0
  492. // Check known status codes
  493. if (status === 507) {
  494. showError(this.t('files', 'Not enough free space'))
  495. return
  496. } else if (status === 404 || status === 409) {
  497. showError(this.t('files', 'Target folder does not exist any more'))
  498. return
  499. } else if (status === 403) {
  500. showError(this.t('files', 'Operation is blocked by access control'))
  501. return
  502. }
  503. // Else we try to parse the response error message
  504. try {
  505. const parser = new Parser({ trim: true, explicitRoot: false })
  506. const response = await parser.parseStringPromise(upload.response?.data)
  507. const message = response['s:message'][0] as string
  508. if (typeof message === 'string' && message.trim() !== '') {
  509. // The server message is also translated
  510. showError(this.t('files', 'Error during upload: {message}', { message }))
  511. return
  512. }
  513. } catch (error) {
  514. logger.error('Error while parsing', { error })
  515. }
  516. // Finally, check the status code if we have one
  517. if (status !== 0) {
  518. showError(this.t('files', 'Error during upload, status code {status}', { status }))
  519. return
  520. }
  521. showError(this.t('files', 'Unknown error during upload'))
  522. },
  523. /**
  524. * Refreshes the current folder on update.
  525. *
  526. * @param node is the file/folder being updated.
  527. */
  528. onUpdatedNode(node?: Node) {
  529. if (node?.fileid === this.currentFolder?.fileid) {
  530. this.fetchContent()
  531. }
  532. },
  533. /**
  534. * Handle search event from unified search.
  535. *
  536. * @param searchEvent is event object.
  537. */
  538. onSearch: debounce(function(searchEvent) {
  539. console.debug('Files app handling search event from unified search...', searchEvent)
  540. this.filterText = searchEvent.query
  541. }, 500),
  542. /**
  543. * Reset the search query
  544. */
  545. resetSearch() {
  546. this.filterText = ''
  547. },
  548. openSharingSidebar() {
  549. if (!this.currentFolder) {
  550. logger.debug('No current folder found for opening sharing sidebar')
  551. return
  552. }
  553. if (window?.OCA?.Files?.Sidebar?.setActiveTab) {
  554. window.OCA.Files.Sidebar.setActiveTab('sharing')
  555. }
  556. sidebarAction.exec(this.currentFolder, this.currentView, this.currentFolder.path)
  557. },
  558. toggleGridView() {
  559. this.userConfigStore.update('grid_view', !this.userConfig.grid_view)
  560. },
  561. t: translate,
  562. n: translatePlural,
  563. },
  564. })
  565. </script>
  566. <style scoped lang="scss">
  567. .app-content {
  568. // Virtual list needs to be full height and is scrollable
  569. display: flex;
  570. overflow: hidden;
  571. flex-direction: column;
  572. max-height: 100%;
  573. position: relative !important;
  574. }
  575. .files-list {
  576. &__header {
  577. display: flex;
  578. align-items: center;
  579. // Do not grow or shrink (vertically)
  580. flex: 0 0;
  581. max-width: 100%;
  582. // Align with the navigation toggle icon
  583. margin-block: var(--app-navigation-padding, 4px);
  584. margin-inline: calc(var(--default-clickable-area, 44px) + 2 * var(--app-navigation-padding, 4px)) var(--app-navigation-padding, 4px);
  585. >* {
  586. // Do not grow or shrink (horizontally)
  587. // Only the breadcrumbs shrinks
  588. flex: 0 0;
  589. }
  590. &-share-button {
  591. color: var(--color-text-maxcontrast) !important;
  592. &--shared {
  593. color: var(--color-main-text) !important;
  594. }
  595. }
  596. }
  597. &__refresh-icon {
  598. flex: 0 0 44px;
  599. width: 44px;
  600. height: 44px;
  601. }
  602. &__loading-icon {
  603. margin: auto;
  604. }
  605. }
  606. </style>