diff --git a/client/proxy.config.json b/client/proxy.config.json
index c6300a412..1c5a84c85 100644
--- a/client/proxy.config.json
+++ b/client/proxy.config.json
@@ -7,6 +7,10 @@
"target": "http://localhost:9000",
"secure": false
},
+ "/themes": {
+ "target": "http://localhost:9000",
+ "secure": false
+ },
"/static": {
"target": "http://localhost:9000",
"secure": false
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
index 19a408425..8bd7f7cf6 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
@@ -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) {
diff --git a/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.html b/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.html
index f34e77f6a..f034c6bb3 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.html
+++ b/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.html
@@ -4,10 +4,13 @@
+
+
diff --git a/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.ts b/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.ts
index f7055072f..5ec1c9f8f 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.ts
@@ -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)
diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts
index 548173f61..0ebd628fc 100644
--- a/client/src/app/app.component.ts
+++ b/client/src/app/app.component.ts
@@ -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'))
])
}
}
diff --git a/client/src/app/core/plugins/plugin.service.ts b/client/src/app/core/plugins/plugin.service.ts
index 7f751f479..4abe9ee8d 100644
--- a/client/src/app/core/plugins/plugin.service.ts
+++ b/client/src/app/core/plugins/plugin.service.ts
@@ -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)
}
}
diff --git a/client/src/app/core/theme/theme.service.ts b/client/src/app/core/theme/theme.service.ts
index 50c19ecac..ad59c203b 100644
--- a/client/src/app/core/theme/theme.service.ts
+++ b/client/src/app/core/theme/theme.service.ts
@@ -1,41 +1,105 @@
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'
+ private oldThemeName: string
+ private themes: ServerConfigTheme[] = []
- this.darkTheme = (peertubeLocalStorage.getItem('theme') === 'dark')
- if (this.darkTheme) this.toggleDarkTheme(false)
+ constructor (
+ private auth: AuthService,
+ private pluginService: PluginService,
+ private server: ServerService
+ ) {}
+
+ initialize () {
+ this.server.configLoaded
+ .subscribe(() => {
+ this.injectThemes()
+
+ this.listenUserTheme()
+ })
}
- toggleDarkTheme (setLocalStorage = true) {
- // switch properties
- this.switchProperty('mainBackgroundColor')
- this.switchProperty('mainForegroundColor')
- this.switchProperty('submenuColor')
- this.switchProperty('inputColor')
- this.switchProperty('inputPlaceholderColor')
+ private injectThemes () {
+ this.themes = this.server.getConfig().theme.registered
- if (setLocalStorage) {
- this.darkTheme = !this.darkTheme
- peertubeLocalStorage.setItem('theme', (this.darkTheme) ? 'dark' : 'default')
+ 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 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 getCurrentTheme () {
+ if (this.auth.isLoggedIn()) {
+ const theme = this.auth.getUser().theme
+ if (theme !== 'instance-default') return theme
+ }
+
+ return this.server.getConfig().theme.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 getTheme (name: string) {
+ return this.themes.find(t => t.name === name)
}
}
diff --git a/client/src/app/menu/menu.component.html b/client/src/app/menu/menu.component.html
index 588cb8548..7eb6f7b35 100644
--- a/client/src/app/menu/menu.component.html
+++ b/client/src/app/menu/menu.component.html
@@ -101,12 +101,10 @@
+
-
-
-
diff --git a/client/src/app/menu/menu.component.ts b/client/src/app/menu/menu.component.ts
index 371beb4a5..ede64b7eb 100644
--- a/client/src/app/menu/menu.component.ts
+++ b/client/src/app/menu/menu.component.ts
@@ -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()
diff --git a/client/src/index.html b/client/src/index.html
index 6aa885eb7..0b610c55a 100644
--- a/client/src/index.html
+++ b/client/src/index.html
@@ -9,19 +9,19 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index 088234074..81518bbb5 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -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: {
diff --git a/server/controllers/themes.ts b/server/controllers/themes.ts
index 20e7062d0..104c285ad 100644
--- a/server/controllers/themes.ts
+++ b/server/controllers/themes.ts
@@ -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))
}
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 9d61ed537..e5f88b71d 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -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,
diff --git a/server/initializers/migrations/0400-user-theme.ts b/server/initializers/migrations/0400-user-theme.ts
index 2c1763890..f74d76115 100644
--- a/server/initializers/migrations/0400-user-theme.ts
+++ b/server/initializers/migrations/0400-user-theme.ts
@@ -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)
diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts
index 516827a05..ccc963514 100644
--- a/server/lib/client-html.ts
+++ b/server/lib/client-html.ts
@@ -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 = ''
+ const styleTag = ``
return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.CUSTOM_CSS, styleTag)
}
+ private static addPluginCSS (htmlStringPage: string) {
+ const linkTag = ``
+
+ return htmlStringPage.replace('', linkTag + '')
+ }
+
private static addVideoOpenGraphAndOEmbedTags (htmlStringPage: string, video: VideoModel) {
const previewUrl = WEBSERVER.URL + video.getPreviewStaticPath()
const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
diff --git a/server/lib/plugins/theme-utils.ts b/server/lib/plugins/theme-utils.ts
index 066339e65..76c671f1c 100644
--- a/server/lib/plugins/theme-utils.ts
+++ b/server/lib/plugins/theme-utils.ts
@@ -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)
diff --git a/server/models/account/user.ts b/server/models/account/user.ts
index b8ca1dd5c..6f0b0e00f 100644
--- a/server/models/account/user.ts
+++ b/server/models/account/user.ts
@@ -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 {
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 {
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,
diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts
index d6c660aac..3498f86d7 100644
--- a/shared/models/server/server-config.model.ts
+++ b/shared/models/server/server-config.model.ts
@@ -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
}
diff --git a/shared/models/users/user.model.ts b/shared/models/users/user.model.ts
index b5823b47a..de9825e1f 100644
--- a/shared/models/users/user.model.ts
+++ b/shared/models/users/user.model.ts
@@ -25,6 +25,9 @@ export interface User {
videoQuota: number
videoQuotaDaily: number
createdAt: Date
+
+ theme: string
+
account: Account
notificationSettings?: UserNotificationSetting
videoChannels?: VideoChannel[]