AdminTheme.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. <template lang='pug'>
  2. q-page.admin-theme
  3. .row.q-pa-md.items-center
  4. .col-auto
  5. img.admin-icon.animated.fadeInLeft(src='/_assets/icons/fluent-paint-roller-animated.svg')
  6. .col.q-pl-md
  7. .text-h5.text-primary.animated.fadeInLeft {{ t('admin.theme.title') }}
  8. .text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s {{ t('admin.theme.subtitle') }}
  9. .col-auto
  10. q-btn.q-mr-sm.acrylic-btn(
  11. icon='las la-question-circle'
  12. flat
  13. color='grey'
  14. type='a'
  15. :href='siteStore.docsBase + `/admin/theme`'
  16. target='_blank'
  17. )
  18. q-btn.q-mr-sm.acrylic-btn(
  19. icon='las la-redo-alt'
  20. flat
  21. color='secondary'
  22. :loading='state.loading > 0'
  23. @click='load'
  24. )
  25. q-btn(
  26. unelevated
  27. icon='mdi-check'
  28. :label='t(`common.actions.apply`)'
  29. color='secondary'
  30. @click='save'
  31. :loading='state.loading > 0'
  32. )
  33. q-separator(inset)
  34. .row.q-pa-md.q-col-gutter-md
  35. .col-6
  36. //- -----------------------
  37. //- Theme Options
  38. //- -----------------------
  39. q-card.shadow-1.q-pb-sm
  40. q-card-section.flex.items-center
  41. .text-subtitle1 {{t('admin.theme.appearance')}}
  42. q-space
  43. q-btn.acrylic-btn(
  44. icon='las la-redo-alt'
  45. :label='t(`admin.theme.resetDefaults`)'
  46. flat
  47. size='sm'
  48. color='pink'
  49. @click='resetColors'
  50. )
  51. q-item(tag='label')
  52. blueprint-icon(icon='light-on')
  53. q-item-section
  54. q-item-label {{t(`admin.theme.darkMode`)}}
  55. q-item-label(caption) {{t(`admin.theme.darkModeHint`)}}
  56. q-item-section(avatar)
  57. q-toggle(
  58. v-model='state.config.dark'
  59. color='primary'
  60. checked-icon='las la-check'
  61. unchecked-icon='las la-times'
  62. :aria-label='t(`admin.theme.darkMode`)'
  63. )
  64. template(v-for='(cl, idx) of colorKeys', :key='cl')
  65. q-separator.q-my-sm(inset)
  66. q-item
  67. blueprint-icon(icon='fill-color')
  68. q-item-section
  69. q-item-label {{t(`admin.theme.` + cl + `Color`)}}
  70. q-item-label(caption) {{t(`admin.theme.` + cl + `ColorHint`)}}
  71. q-item-section(side)
  72. .text-caption.text-grey-6 {{state.config[`color` + startCase(cl)]}}
  73. q-item-section(side)
  74. q-btn.q-mr-sm(
  75. :key='`btnpick-` + cl'
  76. glossy
  77. padding='xs md'
  78. no-caps
  79. size='sm'
  80. :style='`background-color: ` + state.config[`color` + startCase(cl)] + `;`'
  81. text-color='white'
  82. )
  83. q-icon(name='las la-fill', size='xs', left)
  84. span Pick...
  85. q-menu
  86. q-color(
  87. v-model='state.config[`color` + startCase(cl)]'
  88. )
  89. //- -----------------------
  90. //- Theme Layout
  91. //- -----------------------
  92. q-card.shadow-1.q-pb-sm.q-mt-md
  93. q-card-section
  94. .text-subtitle1 {{t('admin.theme.layout')}}
  95. q-item
  96. blueprint-icon(icon='width')
  97. q-item-section
  98. q-item-label {{t(`admin.theme.contentWidth`)}}
  99. q-item-label(caption) {{t(`admin.theme.contentWidthHint`)}}
  100. q-item-section.col-auto
  101. q-btn-toggle(
  102. v-model='state.config.contentWidth'
  103. push
  104. glossy
  105. no-caps
  106. toggle-color='primary'
  107. :options='widthOptions'
  108. )
  109. q-separator.q-my-sm(inset)
  110. q-item
  111. blueprint-icon(icon='right-navigation-toolbar')
  112. q-item-section
  113. q-item-label {{t(`admin.theme.sidebarPosition`)}}
  114. q-item-label(caption) {{t(`admin.theme.sidebarPositionHint`)}}
  115. q-item-section.col-auto
  116. q-btn-toggle(
  117. v-model='state.config.sidebarPosition'
  118. push
  119. glossy
  120. no-caps
  121. toggle-color='primary'
  122. :options='rightLeftOptions'
  123. )
  124. q-separator.q-my-sm(inset)
  125. q-item
  126. blueprint-icon(icon='index')
  127. q-item-section
  128. q-item-label {{t(`admin.theme.tocPosition`)}}
  129. q-item-label(caption) {{t(`admin.theme.tocPositionHint`)}}
  130. q-item-section.col-auto
  131. q-btn-toggle(
  132. v-model='state.config.tocPosition'
  133. push
  134. glossy
  135. no-caps
  136. toggle-color='primary'
  137. :options='rightLeftOptions'
  138. )
  139. q-separator.q-my-sm(inset)
  140. q-item(tag='label')
  141. blueprint-icon(icon='share')
  142. q-item-section
  143. q-item-label {{t(`admin.theme.showSharingMenu`)}}
  144. q-item-label(caption) {{t(`admin.theme.showSharingMenuHint`)}}
  145. q-item-section(avatar)
  146. q-toggle(
  147. v-model='state.config.showSharingMenu'
  148. color='primary'
  149. checked-icon='las la-check'
  150. unchecked-icon='las la-times'
  151. :aria-label='t(`admin.theme.showSharingMenu`)'
  152. )
  153. q-separator.q-my-sm(inset)
  154. q-item(tag='label')
  155. blueprint-icon(icon='print')
  156. q-item-section
  157. q-item-label {{t(`admin.theme.showPrintBtn`)}}
  158. q-item-label(caption) {{t(`admin.theme.showPrintBtnHint`)}}
  159. q-item-section(avatar)
  160. q-toggle(
  161. v-model='state.config.showPrintBtn'
  162. color='primary'
  163. checked-icon='las la-check'
  164. unchecked-icon='las la-times'
  165. :aria-label='t(`admin.theme.showPrintBtn`)'
  166. )
  167. .col-6
  168. //- -----------------------
  169. //- Fonts
  170. //- -----------------------
  171. q-card.shadow-1.q-pb-sm
  172. q-card-section.flex.items-center
  173. .text-subtitle1 {{t('admin.theme.fonts')}}
  174. q-space
  175. q-btn.acrylic-btn(
  176. icon='las la-redo-alt'
  177. :label='t(`admin.theme.resetDefaults`)'
  178. flat
  179. size='sm'
  180. color='pink'
  181. @click='resetFonts'
  182. )
  183. q-item
  184. blueprint-icon(icon='fonts-app')
  185. q-item-section
  186. q-item-label {{t(`admin.theme.baseFont`)}}
  187. q-item-label(caption) {{t(`admin.theme.baseFontHint`)}}
  188. q-item-section
  189. q-select(
  190. outlined
  191. v-model='state.config.baseFont'
  192. :options='fonts'
  193. emit-value
  194. map-options
  195. dense
  196. :aria-label='t(`admin.theme.baseFont`)'
  197. )
  198. q-item
  199. blueprint-icon(icon='fonts-app')
  200. q-item-section
  201. q-item-label {{t(`admin.theme.contentFont`)}}
  202. q-item-label(caption) {{t(`admin.theme.contentFontHint`)}}
  203. q-item-section
  204. q-select(
  205. outlined
  206. v-model='state.config.contentFont'
  207. :options='fonts'
  208. emit-value
  209. map-options
  210. dense
  211. :aria-label='t(`admin.theme.contentFont`)'
  212. )
  213. //- -----------------------
  214. //- Code Injection
  215. //- -----------------------
  216. q-card.shadow-1.q-pb-sm.q-mt-md
  217. q-card-section
  218. .text-subtitle1 {{t('admin.theme.codeInjection')}}
  219. q-item
  220. blueprint-icon(icon='css')
  221. q-item-section
  222. q-item-label {{t(`admin.theme.cssOverride`)}}
  223. q-item-label(caption) {{t(`admin.theme.cssOverrideHint`)}}
  224. q-item
  225. q-item-section
  226. q-no-ssr(:placeholder='t(`common.loading`)')
  227. util-code-editor.admin-theme-cm(
  228. ref='cmCSS'
  229. v-model='state.config.injectCSS'
  230. language='css'
  231. )
  232. q-separator.q-my-sm(inset)
  233. q-item
  234. blueprint-icon(icon='html')
  235. q-item-section
  236. q-item-label {{t(`admin.theme.headHtmlInjection`)}}
  237. q-item-label(caption) {{t(`admin.theme.headHtmlInjectionHint`)}}
  238. q-item
  239. q-item-section
  240. q-no-ssr(:placeholder='t(`common.loading`)')
  241. util-code-editor.admin-theme-cm(
  242. ref='cmHead'
  243. v-model='state.config.injectHead'
  244. language='html'
  245. )
  246. q-separator.q-my-sm(inset)
  247. q-item
  248. blueprint-icon(icon='html')
  249. q-item-section
  250. q-item-label {{t(`admin.theme.bodyHtmlInjection`)}}
  251. q-item-label(caption) {{t(`admin.theme.bodyHtmlInjectionHint`)}}
  252. q-item
  253. q-item-section
  254. q-no-ssr(:placeholder='t(`common.loading`)')
  255. util-code-editor.admin-theme-cm(
  256. ref='cmBody'
  257. v-model='state.config.injectBody'
  258. language='html'
  259. )
  260. </template>
  261. <script setup>
  262. import gql from 'graphql-tag'
  263. import { cloneDeep, startCase } from 'lodash-es'
  264. import { useI18n } from 'vue-i18n'
  265. import { setCssVar, useMeta, useQuasar } from 'quasar'
  266. import { onMounted, reactive, watch } from 'vue'
  267. import { useAdminStore } from 'src/stores/admin'
  268. import { useSiteStore } from 'src/stores/site'
  269. import UtilCodeEditor from '../components/UtilCodeEditor.vue'
  270. // QUASAR
  271. const $q = useQuasar()
  272. // STORES
  273. const adminStore = useAdminStore()
  274. const siteStore = useSiteStore()
  275. // I18N
  276. const { t } = useI18n()
  277. // META
  278. useMeta({
  279. title: t('admin.theme.title')
  280. })
  281. // DATA
  282. const state = reactive({
  283. loading: 0,
  284. config: {
  285. dark: false,
  286. injectCSS: '',
  287. injectHead: '',
  288. injectBody: '',
  289. colorPrimary: '#1976D2',
  290. colorSecondary: '#02C39A',
  291. colorAccent: '#f03a47',
  292. colorHeader: '#000',
  293. colorSidebar: '#1976D2',
  294. contentWidth: 'full',
  295. sidebarPosition: 'left',
  296. tocPosition: 'right',
  297. showSharingMenu: true,
  298. showPrintBtn: true,
  299. baseFont: '',
  300. contentFont: ''
  301. }
  302. })
  303. const colorKeys = [
  304. 'primary',
  305. 'secondary',
  306. 'accent',
  307. 'header',
  308. 'sidebar'
  309. ]
  310. const widthOptions = [
  311. { label: 'Full Width', value: 'full' },
  312. { label: 'Centered', value: 'centered' }
  313. ]
  314. const rightLeftOptions = [
  315. { label: 'Hide', value: 'off' },
  316. { label: 'Left', value: 'left' },
  317. { label: 'Right', value: 'right' }
  318. ]
  319. const fonts = [
  320. { label: 'Inter', value: 'inter' },
  321. { label: 'Open Sans', value: 'opensans' },
  322. { label: 'Montserrat', value: 'montserrat' },
  323. { label: 'Roboto', value: 'roboto' },
  324. { label: 'Rubik', value: 'rubik' },
  325. { label: 'Tajawal', value: 'tajawal' },
  326. { label: 'User System Defaults', value: 'user' }
  327. ]
  328. // WATCHERS
  329. watch(() => adminStore.currentSiteId, (newValue) => {
  330. load()
  331. })
  332. // METHODS
  333. function resetColors () {
  334. state.config.dark = false
  335. state.config.colorPrimary = '#1976D2'
  336. state.config.colorSecondary = '#02C39A'
  337. state.config.colorAccent = '#FF9800'
  338. state.config.colorHeader = '#000'
  339. state.config.colorSidebar = '#1976D2'
  340. }
  341. function resetFonts () {
  342. state.config.baseFont = 'roboto'
  343. state.config.contentFont = 'roboto'
  344. }
  345. async function load () {
  346. state.loading++
  347. $q.loading.show()
  348. try {
  349. const resp = await APOLLO_CLIENT.query({
  350. query: gql`
  351. query fetchThemeConfig (
  352. $id: UUID!
  353. ) {
  354. siteById(
  355. id: $id
  356. ) {
  357. theme {
  358. baseFont
  359. contentFont
  360. colorPrimary
  361. colorSecondary
  362. colorAccent
  363. colorHeader
  364. colorSidebar
  365. dark
  366. injectCSS
  367. injectHead
  368. injectBody
  369. contentWidth
  370. sidebarPosition
  371. tocPosition
  372. showSharingMenu
  373. showPrintBtn
  374. }
  375. }
  376. }
  377. `,
  378. variables: {
  379. id: adminStore.currentSiteId
  380. },
  381. fetchPolicy: 'network-only'
  382. })
  383. if (!resp?.data?.siteById?.theme) {
  384. throw new Error('Failed to fetch theme config.')
  385. }
  386. state.config = cloneDeep(resp.data.siteById.theme)
  387. } catch (err) {
  388. $q.notify({
  389. type: 'negative',
  390. message: 'Failed to fetch site theme config'
  391. })
  392. }
  393. $q.loading.hide()
  394. state.loading--
  395. }
  396. async function save () {
  397. state.loading++
  398. try {
  399. const patchTheme = {
  400. dark: state.config.dark,
  401. colorPrimary: state.config.colorPrimary,
  402. colorSecondary: state.config.colorSecondary,
  403. colorAccent: state.config.colorAccent,
  404. colorHeader: state.config.colorHeader,
  405. colorSidebar: state.config.colorSidebar,
  406. injectCSS: state.config.injectCSS,
  407. injectHead: state.config.injectHead,
  408. injectBody: state.config.injectBody,
  409. contentWidth: state.config.contentWidth,
  410. sidebarPosition: state.config.sidebarPosition,
  411. tocPosition: state.config.tocPosition,
  412. showSharingMenu: state.config.showSharingMenu,
  413. showPrintBtn: state.config.showPrintBtn,
  414. baseFont: state.config.baseFont,
  415. contentFont: state.config.contentFont
  416. }
  417. const respRaw = await APOLLO_CLIENT.mutate({
  418. mutation: gql`
  419. mutation saveTheme (
  420. $id: UUID!
  421. $patch: SiteUpdateInput!
  422. ) {
  423. updateSite (
  424. id: $id,
  425. patch: $patch
  426. ) {
  427. operation {
  428. succeeded
  429. slug
  430. message
  431. }
  432. }
  433. }
  434. `,
  435. variables: {
  436. id: adminStore.currentSiteId,
  437. patch: {
  438. theme: patchTheme
  439. }
  440. }
  441. })
  442. if (respRaw?.data?.updateSite?.operation?.succeeded) {
  443. if (adminStore.currentSiteId === siteStore.id) {
  444. siteStore.$patch({
  445. theme: patchTheme
  446. })
  447. $q.dark.set(state.config.dark)
  448. setCssVar('primary', state.config.colorPrimary)
  449. setCssVar('secondary', state.config.colorSecondary)
  450. setCssVar('accent', state.config.colorAccent)
  451. setCssVar('header', state.config.colorHeader)
  452. setCssVar('sidebar', state.config.colorSidebar)
  453. }
  454. $q.notify({
  455. type: 'positive',
  456. message: t('admin.theme.saveSuccess')
  457. })
  458. } else {
  459. throw new Error(respRaw?.data?.updateSite?.operation?.message || 'An unexpected error occured.')
  460. }
  461. } catch (err) {
  462. $q.notify({
  463. type: 'negative',
  464. message: 'Failed to save site theme config',
  465. caption: err.message
  466. })
  467. }
  468. state.loading--
  469. }
  470. // MOUNTED
  471. onMounted(() => {
  472. if (adminStore.currentSiteId) {
  473. load()
  474. }
  475. })
  476. </script>
  477. <style lang='scss'>
  478. .admin-theme-cm {
  479. border: 1px solid #CCC;
  480. border-radius: 5px;
  481. overflow: hidden;
  482. > .CodeMirror {
  483. height: 150px;
  484. }
  485. }
  486. </style>