WIP plugins: load theme on client side
authorChocobozzz <me@florianbigard.com>
Wed, 10 Jul 2019 12:06:19 +0000 (14:06 +0200)
committerChocobozzz <chocobozzz@cpy.re>
Wed, 24 Jul 2019 08:58:16 +0000 (10:58 +0200)
19 files changed:
client/proxy.config.json
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.html
client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.ts
client/src/app/app.component.ts
client/src/app/core/plugins/plugin.service.ts
client/src/app/core/theme/theme.service.ts
client/src/app/menu/menu.component.html
client/src/app/menu/menu.component.ts
client/src/index.html
server/controllers/api/config.ts
server/controllers/themes.ts
server/initializers/constants.ts
server/initializers/migrations/0400-user-theme.ts
server/lib/client-html.ts
server/lib/plugins/theme-utils.ts
server/models/account/user.ts
shared/models/server/server-config.model.ts
shared/models/users/user.model.ts

index c6300a4126b93c09bfa366a541cea737a76ba662..1c5a84c852db696088e32c44ecb571caa5a6bb63 100644 (file)
@@ -7,6 +7,10 @@
     "target": "http://localhost:9000",
     "secure": false
   },
+  "/themes": {
+    "target": "http://localhost:9000",
+    "secure": false
+  },
   "/static": {
     "target": "http://localhost:9000",
     "secure": false
index 19a40842505cb9cd69fe365dd1b25724b9cd6bf2..8bd7f7cf6e3ad12254c7af568067d97f24a71bd9 100644 (file)
@@ -75,6 +75,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
 
   get availableThemes () {
     return this.serverService.getConfig().theme.registered
+      .map(t => t.name)
   }
 
   getResolutionKey (resolution: string) {
index f34e77f6a4eaa06cac23e554beab2e74fcc647bb..f034c6bb386b4bef33680e4482d63f9256b425ec 100644 (file)
@@ -4,10 +4,13 @@
 
     <div class="peertube-select-container">
       <select formControlName="theme" id="theme">
-        <option i18n value="default">default</option>
+        <option i18n value="instance-default">instance default</option>
+        <option i18n value="default">peertube default</option>
 
         <option *ngFor="let theme of availableThemes" [value]="theme">{{ theme }}</option>
       </select>
     </div>
   </div>
+
+  <input type="submit" i18n-value value="Save" [disabled]="!form.valid">
 </form>
index f7055072f462e022e11126e91119b29eda9a797a..5ec1c9f8fc31b95ee6d372d283363569a792d2d5 100644 (file)
@@ -29,6 +29,7 @@ export class MyAccountInterfaceSettingsComponent extends FormReactive implements
 
   get availableThemes () {
     return this.serverService.getConfig().theme.registered
+               .map(t => t.name)
   }
 
   ngOnInit () {
@@ -53,9 +54,9 @@ export class MyAccountInterfaceSettingsComponent extends FormReactive implements
 
     this.userService.updateMyProfile(details).subscribe(
       () => {
-        this.notifier.success(this.i18n('Interface settings updated.'))
+        this.authService.refreshUserInformation()
 
-        window.location.reload()
+        this.notifier.success(this.i18n('Interface settings updated.'))
       },
 
       err => this.notifier.error(err.message)
index 548173f61eb2e88e3746e7878c1832146db50ec6..0ebd628fce46b1f5da440a7276cefa813323f820 100644 (file)
@@ -72,6 +72,7 @@ export class AppComponent implements OnInit {
     this.serverService.loadVideoPlaylistPrivacies()
 
     this.loadPlugins()
+    this.themeService.initialize()
 
     // Do not display menu on small screens
     if (this.screenService.isInSmallView()) {
@@ -237,11 +238,7 @@ export class AppComponent implements OnInit {
       new Hotkey('g u', (event: KeyboardEvent): boolean => {
         this.router.navigate([ '/videos/upload' ])
         return false
-      }, undefined, this.i18n('Go to the videos upload page')),
-      new Hotkey('shift+t', (event: KeyboardEvent): boolean => {
-        this.themeService.toggleDarkTheme()
-        return false
-      }, undefined, this.i18n('Toggle Dark theme'))
+      }, undefined, this.i18n('Go to the videos upload page'))
     ])
   }
 }
index 7f751f479c7630fa46f2a7c8fe3671027a117577..4abe9ee8d202a59f416316044126e3858b815b32 100644 (file)
@@ -7,7 +7,7 @@ import { PluginScope } from '@shared/models/plugins/plugin-scope.type'
 import { environment } from '../../../environments/environment'
 import { RegisterHookOptions } from '@shared/models/plugins/register.model'
 import { ReplaySubject } from 'rxjs'
-import { first } from 'rxjs/operators'
+import { first, shareReplay } from 'rxjs/operators'
 
 interface HookStructValue extends RegisterHookOptions {
   plugin: ServerConfigPlugin
@@ -21,6 +21,7 @@ export class PluginService {
   private plugins: ServerConfigPlugin[] = []
   private scopes: { [ scopeName: string ]: { plugin: ServerConfigPlugin, clientScript: ClientScript }[] } = {}
   private loadedScripts: { [ script: string ]: boolean } = {}
+  private loadedScopes: PluginScope[] = []
 
   private hooks: { [ name: string ]: HookStructValue[] } = {}
 
@@ -43,14 +44,48 @@ export class PluginService {
 
   ensurePluginsAreLoaded () {
     return this.pluginsLoaded.asObservable()
-               .pipe(first())
+               .pipe(first(), shareReplay())
                .toPromise()
   }
 
+  addPlugin (plugin: ServerConfigPlugin) {
+    for (const key of Object.keys(plugin.clientScripts)) {
+      const clientScript = plugin.clientScripts[key]
+
+      for (const scope of clientScript.scopes) {
+        if (!this.scopes[scope]) this.scopes[scope] = []
+
+        this.scopes[scope].push({
+          plugin,
+          clientScript: {
+            script: environment.apiUrl + `/plugins/${plugin.name}/${plugin.version}/client-scripts/${clientScript.script}`,
+            scopes: clientScript.scopes
+          }
+        })
+
+        this.loadedScripts[clientScript.script] = false
+      }
+    }
+  }
+
+  removePlugin (plugin: ServerConfigPlugin) {
+    for (const key of Object.keys(this.scopes)) {
+      this.scopes[key] = this.scopes[key].filter(o => o.plugin.name !== plugin.name)
+    }
+  }
+
+  async reloadLoadedScopes () {
+    for (const scope of this.loadedScopes) {
+      await this.loadPluginsByScope(scope)
+    }
+  }
+
   async loadPluginsByScope (scope: PluginScope) {
     try {
       await this.ensurePluginsAreLoaded()
 
+      this.loadedScopes.push(scope)
+
       const toLoad = this.scopes[ scope ]
       if (!Array.isArray(toLoad)) return
 
@@ -63,7 +98,7 @@ export class PluginService {
         this.loadedScripts[ clientScript.script ] = true
       }
 
-      return Promise.all(promises)
+      await Promise.all(promises)
     } catch (err) {
       console.error('Cannot load plugins by scope %s.', scope, err)
     }
@@ -101,29 +136,14 @@ export class PluginService {
 
     console.log('Loading script %s of plugin %s.', clientScript.script, plugin.name)
 
-    const url = environment.apiUrl + `/plugins/${plugin.name}/${plugin.version}/client-scripts/${clientScript.script}`
-
-    return import(/* webpackIgnore: true */ url)
+    return import(/* webpackIgnore: true */ clientScript.script)
       .then(script => script.register({ registerHook }))
       .then(() => this.sortHooksByPriority())
   }
 
   private buildScopeStruct () {
     for (const plugin of this.plugins) {
-      for (const key of Object.keys(plugin.clientScripts)) {
-        const clientScript = plugin.clientScripts[key]
-
-        for (const scope of clientScript.scopes) {
-          if (!this.scopes[scope]) this.scopes[scope] = []
-
-          this.scopes[scope].push({
-            plugin,
-            clientScript
-          })
-
-          this.loadedScripts[clientScript.script] = false
-        }
-      }
+      this.addPlugin(plugin)
     }
   }
 
index 50c19ecac38f3404f5645400231cd0c9c8a7f7ca..ad59c203be07d79abe8a826a4407ee9d9be93777 100644 (file)
 import { Injectable } from '@angular/core'
-import { peertubeLocalStorage } from '@app/shared/misc/peertube-local-storage'
+import { AuthService } from '@app/core/auth'
+import { ServerService } from '@app/core/server'
+import { environment } from '../../../environments/environment'
+import { PluginService } from '@app/core/plugins/plugin.service'
+import { ServerConfigTheme } from '@shared/models'
 
 @Injectable()
 export class ThemeService {
-  private theme = document.querySelector('body')
-  private darkTheme = false
-  private previousTheme: { [ id: string ]: string } = {}
-
-  constructor () {
-    // initialise the alternative theme with dark theme colors
-    this.previousTheme['mainBackgroundColor'] = '#111111'
-    this.previousTheme['mainForegroundColor'] = '#fff'
-    this.previousTheme['submenuColor'] = 'rgb(32,32,32)'
-    this.previousTheme['inputColor'] = 'gray'
-    this.previousTheme['inputPlaceholderColor'] = '#fff'
-
-    this.darkTheme = (peertubeLocalStorage.getItem('theme') === 'dark')
-    if (this.darkTheme) this.toggleDarkTheme(false)
+
+  private oldThemeName: string
+  private themes: ServerConfigTheme[] = []
+
+  constructor (
+    private auth: AuthService,
+    private pluginService: PluginService,
+    private server: ServerService
+  ) {}
+
+  initialize () {
+    this.server.configLoaded
+        .subscribe(() => {
+          this.injectThemes()
+
+          this.listenUserTheme()
+        })
+  }
+
+  private injectThemes () {
+    this.themes = this.server.getConfig().theme.registered
+
+    console.log('Injecting %d themes.', this.themes.length)
+
+    const head = document.getElementsByTagName('head')[0]
+
+    for (const theme of this.themes) {
+
+      for (const css of theme.css) {
+        const link = document.createElement('link')
+
+        const href = environment.apiUrl + `/themes/${theme.name}/${theme.version}/css/${css}`
+        link.setAttribute('href', href)
+        link.setAttribute('rel', 'alternate stylesheet')
+        link.setAttribute('type', 'text/css')
+        link.setAttribute('title', theme.name)
+        link.setAttribute('disabled', '')
+
+        head.appendChild(link)
+      }
+    }
+  }
+
+  private getCurrentTheme () {
+    if (this.auth.isLoggedIn()) {
+      const theme = this.auth.getUser().theme
+      if (theme !== 'instance-default') return theme
+    }
+
+    return this.server.getConfig().theme.default
   }
 
-  toggleDarkTheme (setLocalStorage = true) {
-    // switch properties
-    this.switchProperty('mainBackgroundColor')
-    this.switchProperty('mainForegroundColor')
-    this.switchProperty('submenuColor')
-    this.switchProperty('inputColor')
-    this.switchProperty('inputPlaceholderColor')
-
-    if (setLocalStorage) {
-      this.darkTheme = !this.darkTheme
-      peertubeLocalStorage.setItem('theme', (this.darkTheme) ? 'dark' : 'default')
+  private loadTheme (name: string) {
+    const links = document.getElementsByTagName('link')
+    for (let i = 0; i < links.length; i++) {
+      const link = links[ i ]
+      if (link.getAttribute('rel').indexOf('style') !== -1 && link.getAttribute('title')) {
+        link.disabled = link.getAttribute('title') !== name
+      }
+    }
+  }
+
+  private updateCurrentTheme () {
+    if (this.oldThemeName) {
+      const oldTheme = this.getTheme(this.oldThemeName)
+      if (oldTheme) {
+        console.log('Removing scripts of old theme %s.', this.oldThemeName)
+        this.pluginService.removePlugin(oldTheme)
+      }
+    }
+
+    const currentTheme = this.getCurrentTheme()
+
+    console.log('Enabling %s theme.', currentTheme)
+
+    this.loadTheme(currentTheme)
+    const theme = this.getTheme(currentTheme)
+    if (theme) {
+      console.log('Adding scripts of theme %s.', currentTheme)
+      this.pluginService.addPlugin(theme)
+
+      this.pluginService.reloadLoadedScopes()
     }
+
+    this.oldThemeName = currentTheme
+  }
+
+  private listenUserTheme () {
+    this.auth.userInformationLoaded
+      .subscribe(() => this.updateCurrentTheme())
   }
 
-  private switchProperty (property: string, newValue?: string) {
-    const propertyOldvalue = window.getComputedStyle(this.theme).getPropertyValue('--' + property)
-    this.theme.style.setProperty('--' + property, (newValue) ? newValue : this.previousTheme[property])
-    this.previousTheme[property] = propertyOldvalue
+  private getTheme (name: string) {
+    return this.themes.find(t => t.name === name)
   }
 }
index 588cb8548059a9f5fc416ca38a5c72122a5205bd..7eb6f7b35a00ec919d3374cd27d2606c217889dd 100644 (file)
       <span class="language">
         <span tabindex="0" (keyup.enter)="openLanguageChooser()" (click)="openLanguageChooser()" i18n-title title="Change the language" class="icon icon-language"></span>
       </span>
+
       <span class="shortcuts">
         <span tabindex="0" (keyup.enter)="openHotkeysCheatSheet()" (click)="openHotkeysCheatSheet()" i18n-title title="Show keyboard shortcuts" class="icon icon-shortcuts"></span>
       </span>
-      <span class="color-palette">
-        <span tabindex="0" (keyup.enter)="toggleDarkTheme()" (click)="toggleDarkTheme()" i18n-title title="Toggle dark interface" class="icon icon-moonsun"></span>
-      </span>
     </div>
   </menu>
 </div>
index 371beb4a579620775cf8a152fc162349ceaa2d8a..ede64b7eb473c63d700beccd59f26eb8487019ec 100644 (file)
@@ -112,10 +112,6 @@ export class MenuComponent implements OnInit {
     this.hotkeysService.cheatSheetToggle.next(!this.helpVisible)
   }
 
-  toggleDarkTheme () {
-    this.themeService.toggleDarkTheme()
-  }
-
   private computeIsUserHasAdminAccess () {
     const right = this.getFirstAdminRightAvailable()
 
index 6aa885eb7d4e683e7bf312b04982a1e22b3467da..0b610c55a0850fee514ce10d40c56e07bf7ae50b 100644 (file)
@@ -9,19 +9,19 @@
     <!-- Web Manifest file -->
     <link rel="manifest" href="/manifest.webmanifest">
 
-    <!-- /!\ The following comment is used by the server to prerender some tags /!\ -->
-
-      <!-- title tag -->
-      <!-- description tag -->
-      <!-- custom css tag -->
-      <!-- meta tags -->
-
-    <!-- /!\ Do not remove it /!\ -->
-
     <link rel="icon" type="image/png" href="/client/assets/images/favicon.png" />
 
     <!-- base url -->
     <base href="/">
+
+    <!-- /!\ The following comment is used by the server to prerender some tags /!\ -->
+
+    <!-- title tag -->
+    <!-- description tag -->
+    <!-- custom css tag -->
+    <!-- meta tags -->
+
+    <!-- /!\ Do not remove it /!\ -->
   </head>
 
   <!-- 3. Display the application -->
index 088234074e00fac61fe501bfb7a1e1e2c050b8b8..81518bbb54332a3987ace41c61542e35a569de60 100644 (file)
@@ -4,7 +4,7 @@ import { ServerConfig, UserRight } from '../../../shared'
 import { About } from '../../../shared/models/server/about.model'
 import { CustomConfig } from '../../../shared/models/server/custom-config.model'
 import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../helpers/signup'
-import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
+import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME } from '../../initializers/constants'
 import { asyncMiddleware, authenticate, ensureUserHasRight } from '../../middlewares'
 import { customConfigUpdateValidator } from '../../middlewares/validators/config'
 import { ClientHtml } from '../../lib/client-html'
@@ -69,10 +69,11 @@ async function getConfig (req: express.Request, res: express.Response) {
                                 name: t.name,
                                 version: t.version,
                                 description: t.description,
+                                css: t.css,
                                 clientScripts: t.clientScripts
                               }))
 
-  const defaultTheme = getThemeOrDefault(CONFIG.THEME.DEFAULT)
+  const defaultTheme = getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
 
   const json: ServerConfig = {
     instance: {
index 20e7062d03a5cbd588ba128b72a3c555fd7fb8ad..104c285ad373b492400b61b9526c756d00121908 100644 (file)
@@ -1,13 +1,11 @@
 import * as express from 'express'
-import { PLUGIN_GLOBAL_CSS_PATH } from '../initializers/constants'
 import { join } from 'path'
 import { RegisteredPlugin } from '../lib/plugins/plugin-manager'
-import { servePluginStaticDirectoryValidator } from '../middlewares/validators/plugins'
 import { serveThemeCSSValidator } from '../middlewares/validators/themes'
 
 const themesRouter = express.Router()
 
-themesRouter.get('/:themeName/:themeVersion/css/:staticEndpoint',
+themesRouter.get('/:themeName/:themeVersion/css/:staticEndpoint(*)',
   serveThemeCSSValidator,
   serveThemeCSSDirectory
 )
@@ -24,5 +22,9 @@ function serveThemeCSSDirectory (req: express.Request, res: express.Response) {
   const plugin: RegisteredPlugin = res.locals.registeredPlugin
   const staticEndpoint = req.params.staticEndpoint
 
-  return express.static(join(plugin.path, staticEndpoint), { fallthrough: false })
+  if (plugin.css.includes(staticEndpoint) === false) {
+    return res.sendStatus(404)
+  }
+
+  return res.sendFile(join(plugin.path, staticEndpoint))
 }
index 9d61ed537127bea86db14cecc04d1860126b7116..e5f88b71d7bb0d91f7185cffc2604e442077d190 100644 (file)
@@ -585,7 +585,8 @@ const P2P_MEDIA_LOADER_PEER_VERSION = 2
 const PLUGIN_GLOBAL_CSS_FILE_NAME = 'plugins-global.css'
 const PLUGIN_GLOBAL_CSS_PATH = join(CONFIG.STORAGE.TMP_DIR, PLUGIN_GLOBAL_CSS_FILE_NAME)
 
-const DEFAULT_THEME = 'default'
+const DEFAULT_THEME_NAME = 'default'
+const DEFAULT_USER_THEME_NAME = 'instance-default'
 
 // ---------------------------------------------------------------------------
 
@@ -660,6 +661,7 @@ export {
   PREVIEWS_SIZE,
   REMOTE_SCHEME,
   FOLLOW_STATES,
+  DEFAULT_USER_THEME_NAME,
   SERVER_ACTOR_NAME,
   PLUGIN_GLOBAL_CSS_FILE_NAME,
   PLUGIN_GLOBAL_CSS_PATH,
@@ -669,7 +671,7 @@ export {
   HLS_STREAMING_PLAYLIST_DIRECTORY,
   FEEDS,
   JOB_TTL,
-  DEFAULT_THEME,
+  DEFAULT_THEME_NAME,
   NSFW_POLICY_TYPES,
   STATIC_MAX_AGE,
   STATIC_PATHS,
index 2c176389032c0588e25c7c9ae8109b35a31e031f..f74d76115319394553b59efa7907eedc281cad57 100644 (file)
@@ -9,7 +9,7 @@ async function up (utils: {
   const data = {
     type: Sequelize.STRING,
     allowNull: false,
-    defaultValue: 'default'
+    defaultValue: 'instance-default'
   }
 
   await utils.queryInterface.addColumn('user', 'theme', data)
index 516827a054f926ae0a77c22bf0bafe2491784470..ccc963514ac46acde0bda562ac93ff06d4895460 100644 (file)
@@ -92,6 +92,7 @@ export class ClientHtml {
     let html = buffer.toString()
 
     html = ClientHtml.addCustomCSS(html)
+    html = ClientHtml.addPluginCSS(html)
 
     ClientHtml.htmlCache[ path ] = html
 
@@ -138,11 +139,17 @@ export class ClientHtml {
   }
 
   private static addCustomCSS (htmlStringPage: string) {
-    const styleTag = '<style class="custom-css-style">' + CONFIG.INSTANCE.CUSTOMIZATIONS.CSS + '</style>'
+    const styleTag = `<style class="custom-css-style">${CONFIG.INSTANCE.CUSTOMIZATIONS.CSS}</style>`
 
     return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.CUSTOM_CSS, styleTag)
   }
 
+  private static addPluginCSS (htmlStringPage: string) {
+    const linkTag = `<link rel="stylesheet" href="/plugins/global.css" />`
+
+    return htmlStringPage.replace('</head>', linkTag + '</head>')
+  }
+
   private static addVideoOpenGraphAndOEmbedTags (htmlStringPage: string, video: VideoModel) {
     const previewUrl = WEBSERVER.URL + video.getPreviewStaticPath()
     const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
index 066339e65e32065a50d8285960b6e566188f7227..76c671f1cee6e56bd024aa14be50c89008782b55 100644 (file)
@@ -1,18 +1,18 @@
-import { DEFAULT_THEME } from '../../initializers/constants'
+import { DEFAULT_THEME_NAME, DEFAULT_USER_THEME_NAME } from '../../initializers/constants'
 import { PluginManager } from './plugin-manager'
 import { CONFIG } from '../../initializers/config'
 
-function getThemeOrDefault (name: string) {
+function getThemeOrDefault (name: string, defaultTheme: string) {
   if (isThemeRegistered(name)) return name
 
   // Fallback to admin default theme
-  if (name !== CONFIG.THEME.DEFAULT) return getThemeOrDefault(CONFIG.THEME.DEFAULT)
+  if (name !== CONFIG.THEME.DEFAULT) return getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
 
-  return DEFAULT_THEME
+  return defaultTheme
 }
 
 function isThemeRegistered (name: string) {
-  if (name === DEFAULT_THEME) return true
+  if (name === DEFAULT_THEME_NAME || name === DEFAULT_USER_THEME_NAME) return true
 
   return !!PluginManager.Instance.getRegisteredThemes()
                         .find(r => r.name === name)
index b8ca1dd5c343168c1b4571023ec53680db0f741f..6f0b0e00f4f477599ce47134318e768d3ecf75bc 100644 (file)
@@ -44,7 +44,7 @@ import { VideoChannelModel } from '../video/video-channel'
 import { AccountModel } from './account'
 import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type'
 import { values } from 'lodash'
-import { DEFAULT_THEME, NSFW_POLICY_TYPES } from '../../initializers/constants'
+import { DEFAULT_THEME_NAME, DEFAULT_USER_THEME_NAME, NSFW_POLICY_TYPES } from '../../initializers/constants'
 import { clearCacheByUserId } from '../../lib/oauth-model'
 import { UserNotificationSettingModel } from './user-notification-setting'
 import { VideoModel } from '../video/video'
@@ -190,7 +190,7 @@ export class UserModel extends Model<UserModel> {
   videoQuotaDaily: number
 
   @AllowNull(false)
-  @Default(DEFAULT_THEME)
+  @Default(DEFAULT_THEME_NAME)
   @Is('UserTheme', value => throwIfNotValid(value, isThemeValid, 'theme'))
   @Column
   theme: string
@@ -568,7 +568,7 @@ export class UserModel extends Model<UserModel> {
       autoPlayVideo: this.autoPlayVideo,
       videoLanguages: this.videoLanguages,
       role: this.role,
-      theme: getThemeOrDefault(this.theme),
+      theme: getThemeOrDefault(this.theme, DEFAULT_USER_THEME_NAME),
       roleLabel: USER_ROLE_LABELS[ this.role ],
       videoQuota: this.videoQuota,
       videoQuotaDaily: this.videoQuotaDaily,
index d6c660aac05c56bda8d32ab237c1e7ed8b9b0873..3498f86d77be9db4439a05d042baf51c8db8d67c 100644 (file)
@@ -1,13 +1,17 @@
 import { NSFWPolicyType } from '../videos/nsfw-policy.type'
 import { ClientScript } from '../plugins/plugin-package-json.model'
 
-export type ServerConfigPlugin = {
+export interface ServerConfigPlugin {
   name: string
   version: string
   description: string
   clientScripts: { [name: string]: ClientScript }
 }
 
+export interface ServerConfigTheme extends ServerConfigPlugin {
+  css: string[]
+}
+
 export interface ServerConfig {
   serverVersion: string
   serverCommit?: string
@@ -29,7 +33,7 @@ export interface ServerConfig {
   }
 
   theme: {
-    registered: ServerConfigPlugin[]
+    registered: ServerConfigTheme[]
     default: string
   }
 
index b5823b47a65af08c55009fea87204821487f8161..de9825e1f7583b0d6dad62787aa9f82680f60478 100644 (file)
@@ -25,6 +25,9 @@ export interface User {
   videoQuota: number
   videoQuotaDaily: number
   createdAt: Date
+
+  theme: string
+
   account: Account
   notificationSettings?: UserNotificationSetting
   videoChannels?: VideoChannel[]