"target": "http://localhost:9000",
"secure": false
},
+ "/themes": {
+ "target": "http://localhost:9000",
+ "secure": false
+ },
"/static": {
"target": "http://localhost:9000",
"secure": false
get availableThemes () {
return this.serverService.getConfig().theme.registered
+ .map(t => t.name)
}
getResolutionKey (resolution: string) {
<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>
get availableThemes () {
return this.serverService.getConfig().theme.registered
+ .map(t => t.name)
}
ngOnInit () {
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)
this.serverService.loadVideoPlaylistPrivacies()
this.loadPlugins()
+ this.themeService.initialize()
// Do not display menu on small screens
if (this.screenService.isInSmallView()) {
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'))
])
}
}
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
private plugins: ServerConfigPlugin[] = []
private scopes: { [ scopeName: string ]: { plugin: ServerConfigPlugin, clientScript: ClientScript }[] } = {}
private loadedScripts: { [ script: string ]: boolean } = {}
+ private loadedScopes: PluginScope[] = []
private hooks: { [ name: string ]: HookStructValue[] } = {}
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
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)
}
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)
}
}
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)
}
}
<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>
this.hotkeysService.cheatSheetToggle.next(!this.helpVisible)
}
- toggleDarkTheme () {
- this.themeService.toggleDarkTheme()
- }
-
private computeIsUserHasAdminAccess () {
const right = this.getFirstAdminRightAvailable()
<!-- 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 -->
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'
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: {
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
)
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))
}
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'
// ---------------------------------------------------------------------------
PREVIEWS_SIZE,
REMOTE_SCHEME,
FOLLOW_STATES,
+ DEFAULT_USER_THEME_NAME,
SERVER_ACTOR_NAME,
PLUGIN_GLOBAL_CSS_FILE_NAME,
PLUGIN_GLOBAL_CSS_PATH,
HLS_STREAMING_PLAYLIST_DIRECTORY,
FEEDS,
JOB_TTL,
- DEFAULT_THEME,
+ DEFAULT_THEME_NAME,
NSFW_POLICY_TYPES,
STATIC_MAX_AGE,
STATIC_PATHS,
const data = {
type: Sequelize.STRING,
allowNull: false,
- defaultValue: 'default'
+ defaultValue: 'instance-default'
}
await utils.queryInterface.addColumn('user', 'theme', data)
let html = buffer.toString()
html = ClientHtml.addCustomCSS(html)
+ html = ClientHtml.addPluginCSS(html)
ClientHtml.htmlCache[ path ] = html
}
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()
-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)
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'
videoQuotaDaily: number
@AllowNull(false)
- @Default(DEFAULT_THEME)
+ @Default(DEFAULT_THEME_NAME)
@Is('UserTheme', value => throwIfNotValid(value, isThemeValid, 'theme'))
@Column
theme: string
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,
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
}
theme: {
- registered: ServerConfigPlugin[]
+ registered: ServerConfigTheme[]
default: string
}
videoQuota: number
videoQuotaDaily: number
createdAt: Date
+
+ theme: string
+
account: Account
notificationSettings?: UserNotificationSetting
videoChannels?: VideoChannel[]