WIP plugins: load theme on client side

This commit is contained in:
Chocobozzz 2019-07-10 14:06:19 +02:00 committed by Chocobozzz
parent 7cd4d2ba10
commit ffb321bedc
19 changed files with 194 additions and 91 deletions

View 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

View 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) {

View 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>

View 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)

View 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'))
])
}
}

View 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)
}
}

View File

@ -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)
}
}

View File

@ -101,12 +101,10 @@
<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>

View 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()

View 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 -->

View 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: {

View 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))
}

View 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,

View 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)

View 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()

View 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)

View 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,

View 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
}

View File

@ -25,6 +25,9 @@ export interface User {
videoQuota: number
videoQuotaDaily: number
createdAt: Date
theme: string
account: Account
notificationSettings?: UserNotificationSetting
videoChannels?: VideoChannel[]