</ng-container>
+ <div i18n class="inner-form-title">Theme</div>
+
+ <ng-container formGroupName="theme">
+ <div class="form-group">
+ <label i18n for="themeDefault">Global theme</label>
+
+ <div class="peertube-select-container">
+ <select formControlName="default" id="themeDefault">
+ <option i18n value="default">default</option>
+
+ <option *ngFor="let theme of availableThemes" [value]="theme">{{ theme }}</option>
+ </select>
+ </div>
+ </div>
+ </ng-container>
+
+
<div i18n class="inner-form-title">Signup</div>
<ng-container formGroupName="signup">
return this.configService.videoQuotaDailyOptions
}
+ get availableThemes () {
+ return this.serverService.getConfig().theme.registered
+ }
+
getResolutionKey (resolution: string) {
return 'transcoding.resolutions.' + resolution
}
css: null
}
},
+ theme: {
+ default: null
+ },
services: {
twitter: {
username: this.customConfigValidatorsService.SERVICES_TWITTER_USERNAME,
--- /dev/null
+export * from './my-account-interface-settings.component'
--- /dev/null
+<form role="form" (ngSubmit)="updateInterfaceSettings()" [formGroup]="form">
+ <div class="form-group">
+ <label i18n for="theme">Theme</label>
+
+ <div class="peertube-select-container">
+ <select formControlName="theme" id="theme">
+ <option i18n value="default">default</option>
+
+ <option *ngFor="let theme of availableThemes" [value]="theme">{{ theme }}</option>
+ </select>
+ </div>
+ </div>
+</form>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+input[type=submit] {
+ @include peertube-button;
+ @include orange-button;
+
+ display: block;
+ margin-top: 15px;
+}
+
+.peertube-select-container {
+ @include peertube-select-container(340px);
+
+ margin-bottom: 30px;
+}
--- /dev/null
+import { Component, Input, OnInit } from '@angular/core'
+import { Notifier, ServerService } from '@app/core'
+import { UserUpdateMe } from '../../../../../../shared'
+import { AuthService } from '../../../core'
+import { FormReactive, User, UserService } from '../../../shared'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
+import { Subject } from 'rxjs'
+
+@Component({
+ selector: 'my-account-interface-settings',
+ templateUrl: './my-account-interface-settings.component.html',
+ styleUrls: [ './my-account-interface-settings.component.scss' ]
+})
+export class MyAccountInterfaceSettingsComponent extends FormReactive implements OnInit {
+ @Input() user: User = null
+ @Input() userInformationLoaded: Subject<any>
+
+ constructor (
+ protected formValidatorService: FormValidatorService,
+ private authService: AuthService,
+ private notifier: Notifier,
+ private userService: UserService,
+ private serverService: ServerService,
+ private i18n: I18n
+ ) {
+ super()
+ }
+
+ get availableThemes () {
+ return this.serverService.getConfig().theme.registered
+ }
+
+ ngOnInit () {
+ this.buildForm({
+ theme: null
+ })
+
+ this.userInformationLoaded
+ .subscribe(() => {
+ this.form.patchValue({
+ theme: this.user.theme
+ })
+ })
+ }
+
+ updateInterfaceSettings () {
+ const theme = this.form.value['theme']
+
+ const details: UserUpdateMe = {
+ theme
+ }
+
+ this.userService.updateMyProfile(details).subscribe(
+ () => {
+ this.notifier.success(this.i18n('Interface settings updated.'))
+
+ window.location.reload()
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+}
<div i18n class="account-title">Video settings</div>
<my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings>
-<div i18n class="account-title" id="notifications">Notifications</div>
+<div i18n class="account-title">Notifications</div>
<my-account-notification-preferences [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-notification-preferences>
+<div i18n class="account-title">Interface</div>
+<my-account-interface-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-interface-settings>
+
<div i18n class="account-title">Password</div>
<my-account-change-password></my-account-change-password>
-import { Component, OnInit, ViewChild } from '@angular/core'
+import { Component, OnInit } from '@angular/core'
import { Notifier } from '@app/core'
import { BytesPipe } from 'ngx-pipes'
import { AuthService } from '../../core'
import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component'
import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component'
import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-account-settings/my-account-notification-preferences'
-import {
- MyAccountVideoPlaylistCreateComponent
-} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component'
-import {
- MyAccountVideoPlaylistUpdateComponent
-} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
+import { MyAccountVideoPlaylistCreateComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component'
+import { MyAccountVideoPlaylistUpdateComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component'
-import {
- MyAccountVideoPlaylistElementsComponent
-} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
+import { MyAccountVideoPlaylistElementsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
import { DragDropModule } from '@angular/cdk/drag-drop'
import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-settings/my-account-change-email'
import { MultiSelectModule } from 'primeng/primeng'
+import { MyAccountInterfaceSettingsComponent } from '@app/+my-account/my-account-settings/my-account-interface'
@NgModule({
imports: [
MyAccountVideoSettingsComponent,
MyAccountProfileComponent,
MyAccountChangeEmailComponent,
+ MyAccountInterfaceSettingsComponent,
MyAccountVideosComponent,
initializePlugins () {
this.server.configLoaded
.subscribe(() => {
- this.plugins = this.server.getConfig().plugins
+ this.plugins = this.server.getConfig().plugin.registered
this.buildScopeStruct()
css: ''
}
},
- plugins: [],
+ plugin: {
+ registered: []
+ },
+ theme: {
+ registered: [],
+ default: 'default'
+ },
email: {
enabled: false
},
videoChannels: VideoChannel[]
createdAt: Date
+ theme: string
+
adminFlags?: UserAdminFlag
blocked: boolean
this.autoPlayVideo = hash.autoPlayVideo
this.createdAt = hash.createdAt
+ this.theme = hash.theme
+
this.adminFlags = hash.adminFlags
this.blocked = hash.blocked
enabled: true
# Whether or not an administrator must manually validate a new follower
manual_approval: false
+
+theme:
+ default: 'default'
enabled: true
# Whether or not an administrator must manually validate a new follower
manual_approval: false
+
+theme:
+ default: 'default'
updateStreamingPlaylistsInfohashesIfNeeded()
.catch(err => logger.error('Cannot update streaming playlist infohashes.', { err }))
- await PluginManager.Instance.registerPlugins()
+ await PluginManager.Instance.registerPluginsAndThemes()
// Make server listening
server.listen(port, hostname, () => {
import * as express from 'express'
import { snakeCase } from 'lodash'
-import { ServerConfig, ServerConfigPlugin, UserRight } from '../../../shared'
+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 { objectConverter } from '../../helpers/core-utils'
import { CONFIG, reloadConfig } from '../../initializers/config'
import { PluginManager } from '../../lib/plugins/plugin-manager'
-import { PluginType } from '../../../shared/models/plugins/plugin.type'
+import { getThemeOrDefault } from '../../lib/plugins/theme-utils'
const packageJSON = require('../../../../package.json')
const configRouter = express.Router()
.filter(key => CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.RESOLUTIONS[key] === true)
.map(r => parseInt(r, 10))
- const plugins: ServerConfigPlugin[] = []
const registeredPlugins = PluginManager.Instance.getRegisteredPlugins()
- for (const pluginName of Object.keys(registeredPlugins)) {
- const plugin = registeredPlugins[ pluginName ]
- if (plugin.type !== PluginType.PLUGIN) continue
-
- plugins.push({
- name: plugin.name,
- version: plugin.version,
- description: plugin.description,
- clientScripts: plugin.clientScripts
- })
- }
+ .map(p => ({
+ name: p.name,
+ version: p.version,
+ description: p.description,
+ clientScripts: p.clientScripts
+ }))
+
+ const registeredThemes = PluginManager.Instance.getRegisteredThemes()
+ .map(t => ({
+ name: t.name,
+ version: t.version,
+ description: t.description,
+ clientScripts: t.clientScripts
+ }))
+
+ const defaultTheme = getThemeOrDefault(CONFIG.THEME.DEFAULT)
const json: ServerConfig = {
instance: {
css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
}
},
- plugins,
+ plugin: {
+ registered: registeredPlugins
+ },
+ theme: {
+ registered: registeredThemes,
+ default: defaultTheme
+ },
email: {
enabled: Emailer.isEnabled()
},
javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT
}
},
+ theme: {
+ default: CONFIG.THEME.DEFAULT
+ },
services: {
twitter: {
username: CONFIG.SERVICES.TWITTER.USERNAME,
if (body.autoPlayVideo !== undefined) user.autoPlayVideo = body.autoPlayVideo
if (body.videosHistoryEnabled !== undefined) user.videosHistoryEnabled = body.videosHistoryEnabled
if (body.videoLanguages !== undefined) user.videoLanguages = body.videoLanguages
+ if (body.theme !== undefined) user.theme = body.theme
if (body.email !== undefined) {
if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
import { PluginPackageJson } from '../../../shared/models/plugins/plugin-package-json.model'
import { isUrlValid } from './activitypub/misc'
+import { isThemeRegistered } from '../../lib/plugins/theme-utils'
const PLUGINS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.PLUGINS
return isArray(css) && css.every(c => isSafePath(c))
}
+function isThemeValid (name: string) {
+ return isPluginNameValid(name) && isThemeRegistered(name)
+}
+
function isPackageJSONValid (packageJSON: PluginPackageJson, pluginType: PluginType) {
return isNpmPluginNameValid(packageJSON.name) &&
isPluginDescriptionValid(packageJSON.description) &&
export {
isPluginTypeValid,
isPackageJSONValid,
+ isThemeValid,
isPluginVersionValid,
isPluginNameValid,
isPluginDescriptionValid,
'followers.instance.enabled', 'followers.instance.manual_approval',
'tracker.enabled', 'tracker.private', 'tracker.reject_too_many_announces',
'history.videos.max_age', 'views.videos.remote.max_age',
- 'rates_limit.login.window', 'rates_limit.login.max', 'rates_limit.ask_send_email.window', 'rates_limit.ask_send_email.max'
+ 'rates_limit.login.window', 'rates_limit.login.max', 'rates_limit.ask_send_email.window', 'rates_limit.ask_send_email.max',
+ 'theme.default'
]
const requiredAlternatives = [
[ // set
get ENABLED () { return config.get<boolean>('followers.instance.enabled') },
get MANUAL_APPROVAL () { return config.get<boolean>('followers.instance.manual_approval') }
}
+ },
+ THEME: {
+ get DEFAULT () { return config.get<string>('theme.default') }
}
}
// ---------------------------------------------------------------------------
-const LAST_MIGRATION_VERSION = 395
+const LAST_MIGRATION_VERSION = 400
// ---------------------------------------------------------------------------
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'
+
// ---------------------------------------------------------------------------
// Special constants for a test instance
HLS_STREAMING_PLAYLIST_DIRECTORY,
FEEDS,
JOB_TTL,
+ DEFAULT_THEME,
NSFW_POLICY_TYPES,
STATIC_MAX_AGE,
STATIC_PATHS,
--- /dev/null
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+ transaction: Sequelize.Transaction,
+ queryInterface: Sequelize.QueryInterface,
+ sequelize: Sequelize.Sequelize,
+ db: any
+}): Promise<void> {
+ const data = {
+ type: Sequelize.STRING,
+ allowNull: false,
+ defaultValue: 'default'
+ }
+
+ await utils.queryInterface.addColumn('user', 'theme', data)
+}
+
+function down (options) {
+ throw new Error('Not implemented.')
+}
+
+export {
+ up,
+ down
+}
import { PluginType } from '../../../shared/models/plugins/plugin.type'
import { installNpmPlugin, installNpmPluginFromDisk, removeNpmPlugin } from './yarn'
import { outputFile } from 'fs-extra'
+import { ServerConfigPlugin } from '../../../shared/models/server'
export interface RegisteredPlugin {
name: string
private constructor () {
}
- async registerPlugins () {
+ async registerPluginsAndThemes () {
await this.resetCSSGlobalFile()
const plugins = await PluginModel.listEnabledPluginsAndThemes()
this.sortHooksByPriority()
}
+ getRegisteredPluginOrTheme (name: string) {
+ return this.registeredPlugins[name]
+ }
+
getRegisteredPlugin (name: string) {
- return this.registeredPlugins[ name ]
+ const registered = this.getRegisteredPluginOrTheme(name)
+
+ if (!registered || registered.type !== PluginType.PLUGIN) return undefined
+
+ return registered
}
getRegisteredTheme (name: string) {
- const registered = this.getRegisteredPlugin(name)
+ const registered = this.getRegisteredPluginOrTheme(name)
if (!registered || registered.type !== PluginType.THEME) return undefined
}
getRegisteredPlugins () {
- return this.registeredPlugins
+ return this.getRegisteredPluginsOrThemes(PluginType.PLUGIN)
+ }
+
+ getRegisteredThemes () {
+ return this.getRegisteredPluginsOrThemes(PluginType.THEME)
}
async runHook (hookName: string, param?: any) {
}
}
+ private getRegisteredPluginsOrThemes (type: PluginType) {
+ const plugins: RegisteredPlugin[] = []
+
+ for (const pluginName of Object.keys(this.registeredPlugins)) {
+ const plugin = this.registeredPlugins[ pluginName ]
+ if (plugin.type !== type) continue
+
+ plugins.push(plugin)
+ }
+
+ return plugins
+ }
+
static get Instance () {
return this.instance || (this.instance = new this())
}
--- /dev/null
+import { DEFAULT_THEME } from '../../initializers/constants'
+import { PluginManager } from './plugin-manager'
+import { CONFIG } from '../../initializers/config'
+
+function getThemeOrDefault (name: string) {
+ if (isThemeRegistered(name)) return name
+
+ // Fallback to admin default theme
+ if (name !== CONFIG.THEME.DEFAULT) return getThemeOrDefault(CONFIG.THEME.DEFAULT)
+
+ return DEFAULT_THEME
+}
+
+function isThemeRegistered (name: string) {
+ if (name === DEFAULT_THEME) return true
+
+ return !!PluginManager.Instance.getRegisteredThemes()
+ .find(r => r.name === name)
+}
+
+export {
+ getThemeOrDefault,
+ isThemeRegistered
+}
import * as express from 'express'
import { body } from 'express-validator/check'
-import { isUserNSFWPolicyValid, isUserVideoQuotaValid, isUserVideoQuotaDailyValid } from '../../helpers/custom-validators/users'
+import { isUserNSFWPolicyValid, isUserVideoQuotaDailyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users'
import { logger } from '../../helpers/logger'
import { CustomConfig } from '../../../shared/models/server/custom-config.model'
import { Emailer } from '../../lib/emailer'
import { areValidationErrors } from './utils'
+import { isThemeValid } from '../../helpers/custom-validators/plugins'
const customConfigUpdateValidator = [
body('instance.name').exists().withMessage('Should have a valid instance name'),
body('followers.instance.enabled').isBoolean().withMessage('Should have a valid followers of instance boolean'),
body('followers.instance.manualApproval').isBoolean().withMessage('Should have a valid manual approval boolean'),
+ body('theme.default').custom(isThemeValid).withMessage('Should have a valid theme'),
+
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking customConfigUpdateValidator parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
- const plugin = PluginManager.Instance.getRegisteredPlugin(req.params.pluginName)
+ const plugin = PluginManager.Instance.getRegisteredPluginOrTheme(req.params.pluginName)
if (!plugin || plugin.version !== req.params.pluginVersion) {
return res.sendStatus(404)
import { isActorPreferredUsernameValid } from '../../helpers/custom-validators/activitypub/actor'
import { isVideoChannelNameValid } from '../../helpers/custom-validators/video-channels'
import { UserRegister } from '../../../shared/models/users/user-register.model'
+import { isThemeValid } from '../../helpers/custom-validators/plugins'
const usersAddValidator = [
body('username').custom(isUserUsernameValid).withMessage('Should have a valid username (lowercase alphanumeric characters)'),
body('videosHistoryEnabled')
.optional()
.custom(isUserVideosHistoryEnabledValid).withMessage('Should have a valid videos history enabled attribute'),
+ body('theme')
+ .optional()
+ .custom(isThemeValid).withMessage('Should have a valid theme'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking usersUpdateMe parameters', { parameters: omit(req.body, 'password') })
import { AccountModel } from './account'
import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type'
import { values } from 'lodash'
-import { NSFW_POLICY_TYPES } from '../../initializers/constants'
+import { DEFAULT_THEME, NSFW_POLICY_TYPES } from '../../initializers/constants'
import { clearCacheByUserId } from '../../lib/oauth-model'
import { UserNotificationSettingModel } from './user-notification-setting'
import { VideoModel } from '../video/video'
import { ActorFollowModel } from '../activitypub/actor-follow'
import { VideoImportModel } from '../video/video-import'
import { UserAdminFlag } from '../../../shared/models/users/user-flag.model'
+import { isThemeValid } from '../../helpers/custom-validators/plugins'
+import { getThemeOrDefault } from '../../lib/plugins/theme-utils'
enum ScopeNames {
WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL'
@Column(DataType.BIGINT)
videoQuotaDaily: number
+ @AllowNull(false)
+ @Default(DEFAULT_THEME)
+ @Is('UserTheme', value => throwIfNotValid(value, isThemeValid, 'theme'))
+ @Column
+ theme: string
+
@CreatedAt
createdAt: Date
autoPlayVideo: this.autoPlayVideo,
videoLanguages: this.videoLanguages,
role: this.role,
+ theme: getThemeOrDefault(this.theme),
roleLabel: USER_ROLE_LABELS[ this.role ],
videoQuota: this.videoQuota,
videoQuotaDaily: this.videoQuotaDaily,
css: 'body { background-color: red; }'
}
},
+ theme: {
+ default: 'default'
+ },
services: {
twitter: {
username: '@MySuperUsername',
css: 'body { background-color: red; }'
}
},
+ theme: {
+ default: 'default'
+ },
services: {
twitter: {
username: '@Kuja',
css: 'body { background-color: red; }'
}
},
+ theme: {
+ default: 'default'
+ },
services: {
twitter: {
username: '@MySuperUsername',
}
}
+ theme: {
+ default: string
+ }
+
services: {
twitter: {
username: string
}
}
- plugins: ServerConfigPlugin[]
+ plugin: {
+ registered: ServerConfigPlugin[]
+ }
+
+ theme: {
+ registered: ServerConfigPlugin[]
+ default: string
+ }
email: {
enabled: boolean
email?: string
currentPassword?: string
password?: string
+
+ theme?: string
}