UserThemes.vue 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. <!--
  2. - @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
  3. - @copyright Copyright (c) 2022 Greta Doci <gretadoci@gmail.com>
  4. -
  5. - @author Christopher Ng <chrng8@gmail.com>
  6. -
  7. - @license AGPL-3.0-or-later
  8. -
  9. - This program is free software: you can redistribute it and/or modify
  10. - it under the terms of the GNU Affero General Public License as
  11. - published by the Free Software Foundation, either version 3 of the
  12. - License, or (at your option) any later version.
  13. -
  14. - This program is distributed in the hope that it will be useful,
  15. - but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. - GNU Affero General Public License for more details.
  18. -
  19. - You should have received a copy of the GNU Affero General Public License
  20. - along with this program. If not, see <http://www.gnu.org/licenses/>.
  21. -
  22. -->
  23. <template>
  24. <section>
  25. <NcSettingsSection :name="t('theming', 'Appearance and accessibility')"
  26. :limit-width="false"
  27. class="theming">
  28. <!-- eslint-disable-next-line vue/no-v-html -->
  29. <p v-html="description" />
  30. <!-- eslint-disable-next-line vue/no-v-html -->
  31. <p v-html="descriptionDetail" />
  32. <div class="theming__preview-list">
  33. <ItemPreview v-for="theme in themes"
  34. :key="theme.id"
  35. :enforced="theme.id === enforceTheme"
  36. :selected="selectedTheme.id === theme.id"
  37. :theme="theme"
  38. :unique="themes.length === 1"
  39. type="theme"
  40. @change="changeTheme" />
  41. </div>
  42. <div class="theming__preview-list">
  43. <ItemPreview v-for="theme in fonts"
  44. :key="theme.id"
  45. :selected="theme.enabled"
  46. :theme="theme"
  47. :unique="fonts.length === 1"
  48. type="font"
  49. @change="changeFont" />
  50. </div>
  51. </NcSettingsSection>
  52. <NcSettingsSection :name="t('theming', 'Background')"
  53. class="background"
  54. data-user-theming-background-disabled>
  55. <template v-if="isUserThemingDisabled">
  56. <p>{{ t('theming', 'Customization has been disabled by your administrator') }}</p>
  57. </template>
  58. <template v-else>
  59. <p>{{ t('theming', 'Set a custom background') }}</p>
  60. <BackgroundSettings class="background__grid" @update:background="refreshGlobalStyles" />
  61. </template>
  62. </NcSettingsSection>
  63. <NcSettingsSection :name="t('theming', 'Keyboard shortcuts')">
  64. <p>{{ t('theming', 'In some cases keyboard shortcuts can interfere with accessibility tools. In order to allow focusing on your tool correctly you can disable all keyboard shortcuts here. This will also disable all available shortcuts in apps.') }}</p>
  65. <NcCheckboxRadioSwitch class="theming__preview-toggle"
  66. :checked.sync="shortcutsDisabled"
  67. name="shortcuts_disabled"
  68. type="switch"
  69. @change="changeShortcutsDisabled">
  70. {{ t('theming', 'Disable all keyboard shortcuts') }}
  71. </NcCheckboxRadioSwitch>
  72. </NcSettingsSection>
  73. <UserAppMenuSection />
  74. </section>
  75. </template>
  76. <script>
  77. import { generateOcsUrl } from '@nextcloud/router'
  78. import { loadState } from '@nextcloud/initial-state'
  79. import axios from '@nextcloud/axios'
  80. import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
  81. import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'
  82. import BackgroundSettings from './components/BackgroundSettings.vue'
  83. import ItemPreview from './components/ItemPreview.vue'
  84. import UserAppMenuSection from './components/UserAppMenuSection.vue'
  85. const availableThemes = loadState('theming', 'themes', [])
  86. const enforceTheme = loadState('theming', 'enforceTheme', '')
  87. const shortcutsDisabled = loadState('theming', 'shortcutsDisabled', false)
  88. const isUserThemingDisabled = loadState('theming', 'isUserThemingDisabled')
  89. export default {
  90. name: 'UserThemes',
  91. components: {
  92. ItemPreview,
  93. NcCheckboxRadioSwitch,
  94. NcSettingsSection,
  95. BackgroundSettings,
  96. UserAppMenuSection,
  97. },
  98. data() {
  99. return {
  100. availableThemes,
  101. // Admin defined configs
  102. enforceTheme,
  103. shortcutsDisabled,
  104. isUserThemingDisabled,
  105. }
  106. },
  107. computed: {
  108. themes() {
  109. return this.availableThemes.filter(theme => theme.type === 1)
  110. },
  111. fonts() {
  112. return this.availableThemes.filter(theme => theme.type === 2)
  113. },
  114. // Selected theme, fallback on first (default) if none
  115. selectedTheme() {
  116. return this.themes.find(theme => theme.enabled === true) || this.themes[0]
  117. },
  118. description() {
  119. // using the `t` replace method escape html, we have to do it manually :/
  120. return t(
  121. 'theming',
  122. 'Universal access is very important to us. We follow web standards and check to make everything usable also without mouse, and assistive software such as screenreaders. We aim to be compliant with the {guidelines}Web Content Accessibility Guidelines{linkend} 2.1 on AA level, with the high contrast theme even on AAA level.',
  123. )
  124. .replace('{guidelines}', this.guidelinesLink)
  125. .replace('{linkend}', '</a>')
  126. },
  127. guidelinesLink() {
  128. return '<a target="_blank" href="https://www.w3.org/WAI/standards-guidelines/wcag/" rel="noreferrer nofollow">'
  129. },
  130. descriptionDetail() {
  131. return t(
  132. 'theming',
  133. 'If you find any issues, do not hesitate to report them on {issuetracker}our issue tracker{linkend}. And if you want to get involved, come join {designteam}our design team{linkend}!',
  134. )
  135. .replace('{issuetracker}', this.issuetrackerLink)
  136. .replace('{designteam}', this.designteamLink)
  137. .replace(/\{linkend\}/g, '</a>')
  138. },
  139. issuetrackerLink() {
  140. return '<a target="_blank" href="https://github.com/nextcloud/server/issues/" rel="noreferrer nofollow">'
  141. },
  142. designteamLink() {
  143. return '<a target="_blank" href="https://nextcloud.com/design" rel="noreferrer nofollow">'
  144. },
  145. },
  146. watch: {
  147. shortcutsDisabled(newState) {
  148. this.changeShortcutsDisabled(newState)
  149. },
  150. },
  151. methods: {
  152. // Refresh server-side generated theming CSS
  153. refreshGlobalStyles() {
  154. [...document.head.querySelectorAll('link.theme')].forEach(theme => {
  155. const url = new URL(theme.href)
  156. url.searchParams.set('v', Date.now())
  157. const newTheme = theme.cloneNode()
  158. newTheme.href = url.toString()
  159. newTheme.onload = () => theme.remove()
  160. document.head.append(newTheme)
  161. })
  162. },
  163. updateBackground(data) {
  164. this.background = (data.type === 'custom' || data.type === 'default') ? data.type : data.value
  165. this.refreshGlobalStyles()
  166. },
  167. changeTheme({ enabled, id }) {
  168. // Reset selected and select new one
  169. this.themes.forEach(theme => {
  170. if (theme.id === id && enabled) {
  171. theme.enabled = true
  172. return
  173. }
  174. theme.enabled = false
  175. })
  176. this.updateBodyAttributes()
  177. this.selectItem(enabled, id)
  178. },
  179. changeFont({ enabled, id }) {
  180. // Reset selected and select new one
  181. this.fonts.forEach(font => {
  182. if (font.id === id && enabled) {
  183. font.enabled = true
  184. return
  185. }
  186. font.enabled = false
  187. })
  188. this.updateBodyAttributes()
  189. this.selectItem(enabled, id)
  190. },
  191. async changeShortcutsDisabled(newState) {
  192. if (newState) {
  193. await axios({
  194. url: generateOcsUrl('apps/provisioning_api/api/v1/config/users/{appId}/{configKey}', {
  195. appId: 'theming',
  196. configKey: 'shortcuts_disabled',
  197. }),
  198. data: {
  199. configValue: 'yes',
  200. },
  201. method: 'POST',
  202. })
  203. } else {
  204. await axios({
  205. url: generateOcsUrl('apps/provisioning_api/api/v1/config/users/{appId}/{configKey}', {
  206. appId: 'theming',
  207. configKey: 'shortcuts_disabled',
  208. }),
  209. method: 'DELETE',
  210. })
  211. }
  212. },
  213. updateBodyAttributes() {
  214. const enabledThemesIDs = this.themes.filter(theme => theme.enabled === true).map(theme => theme.id)
  215. const enabledFontsIDs = this.fonts.filter(font => font.enabled === true).map(font => font.id)
  216. this.themes.forEach(theme => {
  217. document.body.toggleAttribute(`data-theme-${theme.id}`, theme.enabled)
  218. })
  219. this.fonts.forEach(font => {
  220. document.body.toggleAttribute(`data-theme-${font.id}`, font.enabled)
  221. })
  222. document.body.setAttribute('data-themes', [...enabledThemesIDs, ...enabledFontsIDs].join(','))
  223. },
  224. /**
  225. * Commit a change and force reload css
  226. * Fetching the file again will trigger the server update
  227. *
  228. * @param {boolean} enabled the theme state
  229. * @param {string} themeId the theme ID to change
  230. */
  231. async selectItem(enabled, themeId) {
  232. try {
  233. if (enabled) {
  234. await axios({
  235. url: generateOcsUrl('apps/theming/api/v1/theme/{themeId}/enable', { themeId }),
  236. method: 'PUT',
  237. })
  238. } else {
  239. await axios({
  240. url: generateOcsUrl('apps/theming/api/v1/theme/{themeId}', { themeId }),
  241. method: 'DELETE',
  242. })
  243. }
  244. } catch (err) {
  245. console.error(err, err.response)
  246. OC.Notification.showTemporary(t('theming', err.response.data.ocs.meta.message + '. Unable to apply the setting.'))
  247. }
  248. },
  249. },
  250. }
  251. </script>
  252. <style lang="scss" scoped>
  253. .theming {
  254. // Limit width of settings sections for readability
  255. p {
  256. max-width: 800px;
  257. }
  258. // Proper highlight for links and focus feedback
  259. &::v-deep a {
  260. font-weight: bold;
  261. &:hover,
  262. &:focus {
  263. text-decoration: underline;
  264. }
  265. }
  266. &__preview-list {
  267. --gap: 30px;
  268. display: grid;
  269. margin-top: var(--gap);
  270. column-gap: var(--gap);
  271. row-gap: var(--gap);
  272. grid-template-columns: 1fr 1fr;
  273. }
  274. }
  275. .background {
  276. &__grid {
  277. margin-top: 30px;
  278. }
  279. }
  280. @media (max-width: 1440px) {
  281. .theming__preview-list {
  282. display: flex;
  283. flex-direction: column;
  284. }
  285. }
  286. </style>