diff --git a/assets/images/plus-square.svg b/assets/images/plus-square.svg new file mode 100644 index 00000000..c380e24b --- /dev/null +++ b/assets/images/plus-square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/x-square.svg b/assets/images/x-square.svg new file mode 100644 index 00000000..7677c387 --- /dev/null +++ b/assets/images/x-square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/build-client.js b/build-client.js index 5246a3ab..0ea4745d 100644 --- a/build-client.js +++ b/build-client.js @@ -39,23 +39,10 @@ function loadLocs() { return r } -function loadMustaches () { - // Loading mustache templates, dans filling constants. - const r = [] - r['MUSTACHE_CONFIGURATION_HOME'] = loadMustache('client/common/configuration/templates/home.mustache') - r['MUSTACHE_CONFIGURATION_CHANNEL'] = loadMustache('client/common/configuration/templates/channel.mustache') - return r -} - -function loadMustache (file) { - const filePath = path.resolve(__dirname, file) - return JSON.stringify(fs.readFileSync(filePath).toString()) -} - const define = Object.assign({ PLUGIN_CHAT_PACKAGE_NAME: JSON.stringify(packagejson.name), PLUGIN_CHAT_SHORT_NAME: JSON.stringify(packagejson.name.replace(/^peertube-plugin-/, '')) -}, loadLocs(), loadMustaches()) +}, loadLocs()) const configs = clientFiles.map(f => ({ entryPoints: [ path.resolve(__dirname, 'client', f + '.ts') ], diff --git a/client/common/configuration/register.ts b/client/common/configuration/register.ts index d3b09cf5..64f66a89 100644 --- a/client/common/configuration/register.ts +++ b/client/common/configuration/register.ts @@ -29,7 +29,8 @@ async function registerConfiguration (clientOptions: RegisterClientOptions): Pro onMount: async ({ rootEl }) => { const urlParams = new URLSearchParams(window.location.search) const channelId = urlParams.get('channelId') ?? '' - render(html``, rootEl) + render(html``, rootEl) } }) diff --git a/client/common/configuration/templates/ChannelConfigurationElement.ts b/client/common/configuration/templates/ChannelConfigurationElement.ts index 1cc5ea95..a034d2f3 100644 --- a/client/common/configuration/templates/ChannelConfigurationElement.ts +++ b/client/common/configuration/templates/ChannelConfigurationElement.ts @@ -1,33 +1,53 @@ import { RegisterClientOptions } from '@peertube/peertube-types/client' -import { html, LitElement } from 'lit' +import { css, html, LitElement } from 'lit' import { repeat } from 'lit-html/directives/repeat.js' -import { customElement, property } from 'lit/decorators.js' +import { customElement, property, state } from 'lit/decorators.js' import { ptTr } from './TranslationDirective' import { localizedHelpUrl } from '../../../utils/help' import './DynamicTableFormElement' import './PluginConfigurationRow' +import './HelpButtonElement' import { until } from 'async' import { Task } from '@lit/task'; +import { ChannelConfiguration } from 'shared/lib/types' +import { ChannelConfigurationService } from './ChannelConfigurationService' +import { createContext, provide } from '@lit/context' +import { getGlobalStyleSheets } from '../../global-styles' +export const registerClientOptionsContext = createContext(Symbol('register-client-options')); +export const channelConfigurationContext = createContext(Symbol('channel-configuration')); +export const channelConfigurationServiceContext = createContext(Symbol('channel-configuration-service')); @customElement('channel-configuration') export class ChannelConfigurationElement extends LitElement { + @provide({ context: registerClientOptionsContext }) @property({ attribute: false }) public registerClientOptions: RegisterClientOptions | undefined - createRenderRoot = () => { - return this - } + @property({ attribute: false }) + public channelId: number | undefined + + @provide({ context: channelConfigurationContext }) + @state() + public _channelConfiguration: ChannelConfiguration | undefined + + @provide({ context: channelConfigurationServiceContext }) + private _configurationService: ChannelConfigurationService | undefined + + static styles = [ + ...getGlobalStyleSheets() + ]; + + @state() + public _formStatus: boolean | any = undefined private _asyncTaskRender = new Task(this, { task: async ([registerClientOptions], {signal}) => { - let link = registerClientOptions ? await localizedHelpUrl(registerClientOptions, { page: 'documentation/user/streamers/bot/forbidden_words' }) : ''; - - return { - url : new URL(link), - title: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_DESC) + if (this.registerClientOptions) { + this._configurationService = new ChannelConfigurationService(this.registerClientOptions) + this._channelConfiguration = await this._configurationService.fetchConfiguration(this.channelId ?? 0) } }, @@ -35,112 +55,278 @@ export class ChannelConfigurationElement extends LitElement { }); + private _saveConfig = () => { + if(this._configurationService && this._channelConfiguration) { + this._configurationService.saveOptions(this._channelConfiguration.channel.id, this._channelConfiguration.configuration) + .then((value) => { + this._formStatus = { success: true } + console.log(`Configuration has been updated`) + this.requestUpdate('_formStatus') + }) + .catch((error) => { + this._formStatus = error + console.log(`An error occurred : ${JSON.stringify(this._formStatus)}`) + this.requestUpdate('_formStatus') + }); + } + } + render = () => { - let tableHeader = { - words: { - colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_LABEL), - description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_DESC2) + let tableHeaderList = { + forbiddenWords: { + entries: { + colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_LABEL), + description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_DESC2) + }, + regex: { + colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REGEXP_LABEL), + description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REGEXP_DESC) + }, + applyToModerators: { + colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_APPLYTOMODERATORS_LABEL), + description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_APPLYTOMODERATORS_DESC) + }, + label: { + colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_LABEL_LABEL), + description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_LABEL_DESC) + }, + reason: { + colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REASON_LABEL), + description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REASON_DESC) + }, + comments: { + colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_COMMENTS_LABEL), + description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_COMMENTS_DESC) + } }, - regex: { - colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REGEXP_LABEL), - description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REGEXP_DESC) + quotes: { + messages: { + colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_QUOTE_LABEL2), + description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_QUOTE_DESC2) + }, + delay: { + colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_QUOTE_DELAY_LABEL), + description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_QUOTE_DELAY_DESC) + } }, - applyToModerators: { - colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_APPLYTOMODERATORS_LABEL), - description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_APPLYTOMODERATORS_DESC) - }, - label: { - colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_LABEL_LABEL), - description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_LABEL_DESC) - }, - reason: { - colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REASON_LABEL), - description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REASON_DESC) - }, - comments: { - colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_COMMENTS_LABEL), - description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_COMMENTS_DESC) + commands: { + command: { + colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_COMMAND_CMD_LABEL), + description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_COMMAND_CMD_DESC) + }, + message: { + colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_COMMAND_MESSAGE_LABEL), + description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_COMMAND_MESSAGE_DESC) + } } } let tableSchema = { - words: { - inputType: 'text', - default: 'helloqwesad' + forbiddenWords: { + entries: { + inputType: 'textarea', + default: ['helloqwesad'], + separator: '\n', + }, + regex: { + inputType: 'text', + default: 'helloaxzca', + }, + applyToModerators: { + inputType: 'checkbox', + default: true + }, + label: { + inputType: 'text', + default: 'helloasx' + }, + reason: { + inputType: 'text', + default: 'transphobia', + datalist: ['Racism', 'Sexism', 'Transphobia', 'Bigotry'] + }, + comments: { + inputType: 'textarea', + default: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in + culpa qui officia deserunt mollit anim id est laborum.` + }, }, - regex: { - inputType: 'text', - default: 'helloaxzca' - }, - applyToModerators: { - inputType: 'checkbox', - default: true - }, - label: { - inputType: 'text', - default: 'helloasx' - }, - reason: { - inputType: 'select', - default: 'transphobia', - label: 'choose your poison', - options: {'racism': 'Racism', 'sexism': 'Sexism', 'transphobia': 'Transphobia', 'bigotry': 'Bigotry'} - }, - comments: { - inputType: 'textarea', - default: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, - sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris - nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla - pariatur. Excepteur sint occaecat cupidatat non proident, sunt in - culpa qui officia deserunt mollit anim id est laborum.` + quotes: { + messages: { + inputType: 'textarea', + default: ['default message'], + separator: '\n', + }, + delay: { + inputType: 'number', + default: 100, + } }, + commands: { + command: { + inputType: 'text', + default: 'default command', + }, + message: { + inputType: 'text', + default: 'default message', + } + } } - let tableRows = [ - { - words: 'teweqwst', - regex: 'tesdgst', - applyToModerators: false, - label: 'teswet', - reason: 'sexism', - comments: 'tsdaswest', - }, - { - words: 'tedsadst', - regex: 'tezxccst', - applyToModerators: true, - label: 'tewest', - reason: 'racism', - comments: 'tesxzct', - }, - { - words: 'tesadsdxst', - regex: 'dsfsdf', - applyToModerators: false, - label: 'tesdadst', - reason: 'bigotry', - comments: 'tsadest', - }, - ] return this._asyncTaskRender.render({ - complete: (helpLink) => html` - - - - - - - ` + complete: () => html` + + + ${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_TITLE)}: + + ${this._channelConfiguration?.channel.displayName} + ${this._channelConfiguration?.channel.name} + + + + + ${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_DESC)} + + + + + + { + if (event?.target && this._channelConfiguration) + this._channelConfiguration.configuration.slowMode.duration = Number((event.target as HTMLInputElement).value) + this.requestUpdate('_channelConfiguration') + } + } + value="${this._channelConfiguration?.configuration.slowMode.duration}" + /> + + + + + + + { + if (event?.target && this._channelConfiguration) + this._channelConfiguration.configuration.bot.enabled = (event.target as HTMLInputElement).checked + this.requestUpdate('_channelConfiguration') + } + } + .value=${this._channelConfiguration?.configuration.bot.enabled} + ?checked=${this._channelConfiguration?.configuration.bot.enabled} + /> + ${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_ENABLE_BOT_LABEL)} + + + ${this._channelConfiguration?.configuration.bot.enabled ? + html` + ${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_BOT_NICKNAME)} + { + if (event?.target && this._channelConfiguration) + this._channelConfiguration.configuration.bot.nickname = (event.target as HTMLInputElement).value + this.requestUpdate('_channelConfiguration') + } + } + value="${this._channelConfiguration?.configuration.bot.nickname}" + /> + ` + : '' + } + + ${this._channelConfiguration?.configuration.bot.enabled ? + html` + { + if (this._channelConfiguration) this._channelConfiguration.configuration.bot.forbiddenWords = e.detail + this.requestUpdate('_channelConfiguration') + } + } + .formName=${'forbidden-words'}> + + + + { + if (this._channelConfiguration) this._channelConfiguration.configuration.bot.quotes = e.detail + this.requestUpdate('_channelConfiguration') + } + } + .formName=${'quote'}> + + + + { + if (this._channelConfiguration) this._channelConfiguration.configuration.bot.commands = e.detail + this.requestUpdate('_channelConfiguration') + } + } + .formName=${'command'}> + + ` + : '' + } + + ${ptTr(LOC_SAVE)} + + ${(this._formStatus && this._formStatus.success === undefined) ? + html` + An error occurred : ${JSON.stringify(this._formStatus)} + ` + : '' + } + ${(this._formStatus && this._formStatus.success === true) ? + html` + Configuration has been updated + ` + : '' + } + + ${JSON.stringify(this._channelConfiguration)}` }) } } diff --git a/client/common/configuration/templates/ChannelConfigurationService.ts b/client/common/configuration/templates/ChannelConfigurationService.ts new file mode 100644 index 00000000..e7075a00 --- /dev/null +++ b/client/common/configuration/templates/ChannelConfigurationService.ts @@ -0,0 +1,60 @@ +import { RegisterClientOptions } from "@peertube/peertube-types/client" +import { ChannelConfiguration, ChannelConfigurationOptions } from "shared/lib/types" +import { getBaseRoute } from "../../../utils/uri" + + +export class ChannelConfigurationService { + + public _registerClientOptions: RegisterClientOptions + + private _headers : any = {} + + constructor(registerClientOptions: RegisterClientOptions) { + this._registerClientOptions = registerClientOptions + + this._headers = this._registerClientOptions.peertubeHelpers.getAuthHeader() ?? {} + this._headers['content-type'] = 'application/json;charset=UTF-8' + } + + validateOptions = (channelConfigurationOptions: ChannelConfigurationOptions) => { + return true + } + + saveOptions = async (channelId: number, channelConfigurationOptions: ChannelConfigurationOptions) => { + if (!await this.validateOptions(channelConfigurationOptions)) { + throw new Error('Invalid form data') + } + + + const response = await fetch( + getBaseRoute(this._registerClientOptions) + '/api/configuration/channel/' + encodeURIComponent(channelId), + { + method: 'POST', + headers: this._headers, + body: JSON.stringify(channelConfigurationOptions) + } + ) + + if (!response.ok) { + throw new Error('Failed to save configuration options.') + } + + return await response.json() + } + + fetchConfiguration = async (channelId: number): Promise => { + const response = await fetch( + getBaseRoute(this._registerClientOptions) + '/api/configuration/channel/' + encodeURIComponent(channelId), + { + method: 'GET', + headers: this._headers + } + ) + + if (!response.ok) { + throw new Error('Can\'t get channel configuration options.') + } + + return await response.json() + } +} \ No newline at end of file diff --git a/client/common/configuration/templates/DynamicTableFormElement.ts b/client/common/configuration/templates/DynamicTableFormElement.ts index 4b7bed0b..55ba9a67 100644 --- a/client/common/configuration/templates/DynamicTableFormElement.ts +++ b/client/common/configuration/templates/DynamicTableFormElement.ts @@ -1,31 +1,46 @@ -import { html, LitElement, TemplateResult } from 'lit' +import { css, html, LitElement, nothing, TemplateResult } from 'lit' import { repeat } from 'lit/directives/repeat.js' import { customElement, property, state } from 'lit/decorators.js' -import { unsafeHTML } from 'lit/directives/unsafe-html.js' import { ifDefined } from 'lit/directives/if-defined.js' -import { StaticValue, unsafeStatic } from 'lit/static-html.js' +import { getGlobalStyleSheets } from '../../global-styles' +import { unsafeHTML } from 'lit/directives/unsafe-html.js' -type DynamicTableAcceptedTypes = number | string | boolean | Date + // This content comes from the file assets/images/plus-square.svg, from the Feather icons set https://feathericons.com/ +const AddSVG: string = + ` + + + ` + + // This content comes from the file assets/images/x-square.svg, from the Feather icons set https://feathericons.com/ +const RemoveSVG: string = + ` + + + ` + + +type DynamicTableAcceptedTypes = number | string | boolean | Date | Array type DynamicTableAcceptedInputTypes = 'textarea' - | 'select' - | 'checkbox' - | 'range' - | 'color' - | 'date' - | 'datetime' - | 'datetime-local' - | 'email' - | 'file' - | 'image' - | 'month' - | 'number' - | 'password' - | 'tel' - | 'text' - | 'time' - | 'url' - | 'week' + | 'select' + | 'checkbox' + | 'range' + | 'color' + | 'date' + | 'datetime' + | 'datetime-local' + | 'email' + | 'file' + | 'image' + | 'month' + | 'number' + | 'password' + | 'tel' + | 'text' + | 'time' + | 'url' + | 'week' interface CellDataSchema { @@ -36,6 +51,8 @@ interface CellDataSchema { size?: number label?: string options?: { [key: string]: string } + datalist?: DynamicTableAcceptedTypes[] + separator?: string inputType?: DynamicTableAcceptedInputTypes default?: DynamicTableAcceptedTypes } @@ -44,55 +61,100 @@ interface CellDataSchema { export class DynamicTableFormElement extends LitElement { @property({ attribute: false }) - public header: { [key : string]: { colName: TemplateResult, description: TemplateResult } } = {} + public header: { [key: string]: { colName: TemplateResult, description: TemplateResult } } = {} @property({ attribute: false }) - public schema: { [key : string]: CellDataSchema } = {} + public schema: { [key: string]: CellDataSchema } = {} + @property({ attribute: false }) + public rows: { [key: string]: DynamicTableAcceptedTypes }[] = [] - @property({ reflect: true }) - public rows: { _id: number; [key : string]: DynamicTableAcceptedTypes }[] = [] - + @state() + public _rowsById: { _id: number; row: { [key: string]: DynamicTableAcceptedTypes } }[] = [] @property({ attribute: false }) public formName: string = '' - @state() private _lastRowId = 1 - createRenderRoot = () => { - return this + @property({ attribute: false }) + private _colOrder: string[] = [] + + static styles = [ + ...getGlobalStyleSheets(), + css` + :host table { + table-layout: fixed; + text-align: center; + } + + :host table td, table th { + word-wrap:break-word; + vertical-align: top; + padding: 5px 7px; + } + + :host table tbody > :nth-child(odd) { + background-color: var(--greySecondaryBackgroundColor); + } + + :host button { + padding: 2px; + } + + :host .dynamic-table-add-row { + background-color: var(--bs-green); + } + + :host .dynamic-table-remove-row { + background-color: var(--bs-orange); + } + ` + ]; + + // fixes situations when list has been reinitialized or changed outside of CustomElement + private _updateLastRowId = () => { + for (let rowById of this._rowsById) { + this._lastRowId = Math.max(this._lastRowId, rowById._id + 1); + } } - private _getDefaultRow = () => { - return Object.fromEntries([...Object.entries(this.schema).map((entry) => [entry[0], entry[1].default ?? '']), ['_id', this._lastRowId++]]) + private _getDefaultRow = () : { [key: string]: DynamicTableAcceptedTypes } => { + this._updateLastRowId() + + return Object.fromEntries([...Object.entries(this.schema).map((entry) => [entry[0], entry[1].default ?? ''])]) } private _addRow = () => { - this.rows.push(this._getDefaultRow()) - - this.requestUpdate('rows') - + let newRow = this._getDefaultRow() + this._rowsById.push({_id:this._lastRowId++, row: newRow}) + this.rows.push(newRow) + this.requestUpdate('rows') + this.dispatchEvent(new CustomEvent('update', { detail: this.rows })) } private _removeRow = (rowId: number) => { - this.rows = this.rows.filter((x) => x._id != rowId) - - this.requestUpdate('rows') - + let rowToRemove = this._rowsById.filter(rowById => rowById._id == rowId).map(rowById => rowById.row)[0] + this._rowsById = this._rowsById.filter((rowById) => rowById._id !== rowId) + this.rows = this.rows.filter((row) => row !== rowToRemove) + this.requestUpdate('rows') + this.dispatchEvent(new CustomEvent('update', { detail: this.rows })) } - render = () => { - const inputId = `peertube-livechat-${this.formName.replaceAll('_','-')}-table` + const inputId = `peertube-livechat-${this.formName.replaceAll('_', '-')}-table` - for(let row of this.rows) { - if (!row._id) { - row._id = this._lastRowId++ + this._updateLastRowId() + + this._rowsById.filter(rowById => this.rows.includes(rowById.row)) + + for (let row of this.rows) { + if (this._rowsById.filter(rowById => rowById.row === row).length == 0) { + this._rowsById.push({_id: this._lastRowId++, row }) } } @@ -100,52 +162,63 @@ export class DynamicTableFormElement extends LitElement { ${this._renderHeader()} - ${repeat(this.rows,(row) => row._id, this._renderDataRow)} + ${repeat(this._rowsById, (rowById) => rowById._id, this._renderDataRow)} - - Add Row - + ${this._renderFooter()} ` - } private _renderHeader = () => { + if (this._colOrder.length !== Object.keys(this.header).length) { + this._colOrder = this._colOrder.filter(key => Object.keys(this.header).includes(key)) + this._colOrder.push(...Object.keys(this.header).filter(key => !this._colOrder.includes(key))) + } + return html` - - ${Object.values(this.header).map(this._renderHeaderCell)} - Remove Row + ${Object.entries(this.header).sort(([k1,_1], [k2,_2]) => this._colOrder.indexOf(k1) - this._colOrder.indexOf(k2)) + .map(([k,v]) => this._renderHeaderCell(v))} + ` - } private _renderHeaderCell = (headerCellData: { colName: TemplateResult, description: TemplateResult }) => { return html` - ${headerCellData.colName} + ${headerCellData.colName} ` } - private _renderDataRow = (rowData: { _id: number; [key : string]: DynamicTableAcceptedTypes }) => { - const inputId = `peertube-livechat-${this.formName.replaceAll('_','-')}-row-${rowData._id}` + private _renderDataRow = (rowData: { _id: number; row: {[key: string]: DynamicTableAcceptedTypes} }) => { + const inputId = `peertube-livechat-${this.formName.replaceAll('_', '-')}-row-${rowData._id}` return html` - - ${Object.entries(rowData).filter(([k,v]) => k != '_id').map((data) => this.renderDataCell(data, rowData._id))} - this._removeRow(rowData._id)}>Remove + ${Object.entries(rowData.row).filter(([k, v]) => k != '_id') + .sort(([k1,_1], [k2,_2]) => this._colOrder.indexOf(k1) - this._colOrder.indexOf(k2)) + .map((data) => this.renderDataCell(data, rowData._id))} + this._removeRow(rowData._id)}>${unsafeHTML(RemoveSVG)} ` } + private _renderFooter = () => { + return html` + + ${Object.values(this.header).map(() => html``)} + ${unsafeHTML(AddSVG)} + + ` + } + renderDataCell = (property: [string, DynamicTableAcceptedTypes], rowId: number) => { const [propertyName, propertyValue] = property const propertySchema = this.schema[propertyName] ?? {} let formElement - const inputName = `${this.formName.replaceAll('-','_')}_${propertyName.toString().replaceAll('-','_')}_${rowId}` - const inputId = `peertube-livechat-${this.formName.replaceAll('_','-')}-${propertyName.toString().replaceAll('_','-')}-${rowId}` + const inputName = `${this.formName.replaceAll('-', '_')}_${propertyName.toString().replaceAll('-', '_')}_${rowId}` + const inputId = `peertube-livechat-${this.formName.replaceAll('_', '-')}-${propertyName.toString().replaceAll('_', '-')}-${rowId}` switch (propertyValue.constructor) { case String: @@ -169,76 +242,32 @@ export class DynamicTableFormElement extends LitElement { case 'time': case 'url': case 'week': - formElement = html` this._updatePropertyFromValue(event, propertyName, rowId)} - .value=${propertyValue as string} - />` + formElement = this._renderInput(rowId, inputId, inputName, propertyName, propertySchema, propertyValue as string) break case 'textarea': - formElement = html` this._updatePropertyFromValue(event, propertyName, rowId)} - .value=${propertyValue as string} - >` + formElement = this._renderTextarea(rowId, inputId, inputName, propertyName, propertySchema, propertyValue as string) break case 'select': - formElement = html` this._updatePropertyFromValue(event, propertyName, rowId)} - > - ${propertySchema?.label ?? 'Choose your option'} - ${Object.entries(propertySchema?.options ?? {}) - ?.map(([value,name]) => - html`${name}` - )} - ` + formElement = this._renderSelect(rowId, inputId, inputName, propertyName, propertySchema, propertyValue as string) break - } break - case Date: - switch (propertySchema.inputType) { - case undefined: - propertySchema.inputType = 'datetime' + case Date: + switch (propertySchema.inputType) { + case undefined: + propertySchema.inputType = 'datetime' - case 'date': - case 'datetime': - case 'datetime-local': - case 'time': - formElement = html` this._updatePropertyFromValue(event, propertyName, rowId)} - .value=${(propertyValue as Date).toISOString()} - />` - break - - } - break + case 'date': + case 'datetime': + case 'datetime-local': + case 'time': + formElement = this._renderInput(rowId, inputId, inputName, propertyName, propertySchema, (propertyValue as Date).toISOString()) + break + } + break case Number: switch (propertySchema.inputType) { @@ -247,20 +276,8 @@ export class DynamicTableFormElement extends LitElement { case 'number': case 'range': - formElement = html` this._updatePropertyFromValue(event, propertyName, rowId)} - .value=${propertyValue as String} - />` + formElement = this._renderInput(rowId, inputId, inputName, propertyName, propertySchema, propertyValue as string) break - } break @@ -270,44 +287,130 @@ export class DynamicTableFormElement extends LitElement { propertySchema.inputType = 'checkbox' case 'checkbox': - formElement = html` this._updatePropertyFromValue(event, propertyName, rowId)} - .value=${propertyValue as String} - ?checked=${propertyValue as Boolean} - />` + formElement = this._renderCheckbox(rowId, inputId, inputName, propertyName, propertySchema, propertyValue as boolean) break - } break + case Array: + switch (propertySchema.inputType) { + case undefined: + propertySchema.inputType = 'text' + + case 'text': + case 'color': + case 'date': + case 'datetime': + case 'datetime-local': + case 'email': + case 'file': + case 'image': + case 'month': + case 'number': + case 'password': + case 'range': + case 'tel': + case 'time': + case 'url': + case 'week': + formElement = this._renderInput(rowId, inputId, inputName, propertyName, propertySchema, + (propertyValue as Array).join(propertySchema.separator ?? ',')) + break + case 'textarea': + formElement = this._renderTextarea(rowId, inputId, inputName, propertyName, propertySchema, + (propertyValue as Array).join(propertySchema.separator ?? ',')) + break + } } if (!formElement) { console.warn(`value type '${propertyValue.constructor}' is incompatible` - + `with field type '${propertySchema.inputType}' for form entry '${propertyName.toString()}'.`) - + + `with field type '${propertySchema.inputType}' for form entry '${propertyName.toString()}'.`) } return html`${formElement}` + } + _renderInput = (rowId: number, inputId: string, inputName: string, propertyName: string, propertySchema: CellDataSchema, propertyValue: string) => { + return html` this._updatePropertyFromValue(event, propertyName, propertySchema, rowId)} + .value=${propertyValue} + /> + ${(propertySchema?.datalist) ? html` + ${(propertySchema?.datalist ?? []).map((value) => html``)} + ` : nothing} + ` + } + + _renderTextarea = (rowId: number, inputId: string, inputName: string, propertyName: string, propertySchema: CellDataSchema, propertyValue: string) => { + return html` this._updatePropertyFromValue(event, propertyName, propertySchema, rowId)} + .value=${propertyValue} + >` + } + + _renderCheckbox = (rowId: number, inputId: string, inputName: string, propertyName: string, propertySchema: CellDataSchema, propertyValue: boolean) => { + return html` this._updatePropertyFromValue(event, propertyName, propertySchema, rowId)} + .value=${propertyValue} + ?checked=${propertyValue} + />` + } + + _renderSelect = (rowId: number, inputId: string, inputName: string, propertyName: string, propertySchema: CellDataSchema, propertyValue: string) => { + return html` this._updatePropertyFromValue(event, propertyName, propertySchema, rowId)} + > + ${propertySchema?.label ?? 'Choose your option'} + ${Object.entries(propertySchema?.options ?? {}) + ?.map(([value, name]) => + html`${name}` + )} + ` } - _updatePropertyFromValue(event: Event, propertyName: string, rowId : number) { + _updatePropertyFromValue = (event: Event, propertyName: string, propertySchema: CellDataSchema, rowId: number) => { let target = event?.target as (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement) let value = (target && target instanceof HTMLInputElement && target.type == "checkbox") ? !!(target?.checked) : target?.value - if(value !== undefined) { - for(let row of this.rows) { - if(row._id === rowId) { - row[propertyName] = value + if (value !== undefined) { + for (let rowById of this._rowsById) { + if (rowById._id === rowId) { + switch (rowById.row[propertyName].constructor) { + case Array: + rowById.row[propertyName] = (value as string).split(propertySchema.separator ?? ',') + default: + rowById.row[propertyName] = value + } + + this.rows = this._rowsById.map(rowById => rowById.row) this.requestUpdate('rows') - + this.requestUpdate('rowsById') + this.dispatchEvent(new CustomEvent('update', { detail: this.rows })) return } } diff --git a/client/common/configuration/templates/HelpButtonElement.ts b/client/common/configuration/templates/HelpButtonElement.ts new file mode 100644 index 00000000..518efcc5 --- /dev/null +++ b/client/common/configuration/templates/HelpButtonElement.ts @@ -0,0 +1,50 @@ +import { css, html, LitElement } from 'lit' +import { customElement, property, state } from 'lit/decorators.js' +import { unsafeHTML } from 'lit/directives/unsafe-html.js' +import { helpButtonSVG } from '../../../videowatch/buttons' +import { consume } from '@lit/context' +import { registerClientOptionsContext } from './ChannelConfigurationElement' +import { RegisterClientOptions } from '@peertube/peertube-types/client' +import { Task } from '@lit/task' +import { localizedHelpUrl } from '../../../utils/help' +import { ptTr } from './TranslationDirective' +import { DirectiveResult } from 'lit/directive' +import { getGlobalStyleSheets } from '../../global-styles' + +@customElement('help-button') +export class HelpButtonElement extends LitElement { + + @consume({context: registerClientOptionsContext}) + public registerClientOptions: RegisterClientOptions | undefined + + @property({ attribute: false }) + public buttonTitle: string | DirectiveResult = ptTr(LOC_ONLINE_HELP) + + @property({ attribute: false }) + public page: string = '' + + @state() + public url: URL = new URL('https://lmddgtfy.net/') + + static styles = [ + ...getGlobalStyleSheets() + ]; + + private _asyncTaskRender = new Task(this, { + task: async ([registerClientOptions], {signal}) => { + this.url = new URL(registerClientOptions ? await localizedHelpUrl(registerClientOptions, { page: this.page }) : '') + }, + args: () => [this.registerClientOptions] + }); + + render() { + return this._asyncTaskRender.render({ + complete: () => html`${unsafeHTML(helpButtonSVG())}` + }) + } +} diff --git a/client/common/configuration/templates/PluginConfigurationRow.ts b/client/common/configuration/templates/PluginConfigurationRow.ts index b275d21b..d1b5bf75 100644 --- a/client/common/configuration/templates/PluginConfigurationRow.ts +++ b/client/common/configuration/templates/PluginConfigurationRow.ts @@ -1,11 +1,10 @@ -import { html, LitElement } from 'lit' +import { css, html, LitElement } from 'lit' import { customElement, property } from 'lit/decorators.js' -import { unsafeSVG } from 'lit/directives/unsafe-svg.js' -import { StaticValue } from 'lit/static-html.js' -import { helpButtonSVG } from '../../../videowatch/buttons' +import './HelpButtonElement' +import { getGlobalStyleSheets } from '../../global-styles' @customElement('plugin-configuration-row') -export class PLuginConfigurationRow extends LitElement { +export class PluginConfigurationRow extends LitElement { @property({ attribute: false }) public title: string = `title` @@ -14,24 +13,20 @@ export class PLuginConfigurationRow extends LitElement { public description: string = `Here's a description` @property({ attribute: false }) - public helpLink: { url: URL, title: string } = { url : new URL('https://lmddgtfy.net/'), title: 'Online Help'} + public helpPage: string = 'documentation' - createRenderRoot = () => { - return this - } + static styles = [ + ...getGlobalStyleSheets() + ]; render() { return html` - - ${this.title} - ${this.description} - ${unsafeSVG(helpButtonSVG())} + + ${this.title} + ${this.description} + + Nothing in this row. diff --git a/client/common/configuration/templates/TranslationDirective.ts b/client/common/configuration/templates/TranslationDirective.ts index 94644a03..a360ae4b 100644 --- a/client/common/configuration/templates/TranslationDirective.ts +++ b/client/common/configuration/templates/TranslationDirective.ts @@ -1,6 +1,9 @@ -import { PartInfo, directive } from 'lit/directive.js' +import { PartInfo, PartType, directive } from 'lit/directive.js' import { AsyncDirective } from 'lit/async-directive.js' import { RegisterClientHelpers } from '@peertube/peertube-types/client'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { html } from 'lit'; +import { unsafeStatic } from 'lit/static-html.js'; export class TranslationDirective extends AsyncDirective { @@ -9,22 +12,35 @@ export class TranslationDirective extends AsyncDirective { private _translatedValue : string = '' private _localizationId : string = '' + private _allowUnsafeHTML = false + constructor(partInfo: PartInfo) { super(partInfo); //_peertubeOptionsPromise.then((options) => this._peertubeHelpers = options.peertubeHelpers) } - override render = (locId: string) => { + // update = (part: ElementPart) => { + // if (part) console.log(`Element : ${part?.element?.getAttributeNames?.().join(' ')}`); + // return this.render(this._localizationId); + // } + + override render = (locId: string, allowHTML: boolean = false) => { this._localizationId = locId // TODO Check current component for context (to infer the prefix) + this._allowUnsafeHTML = allowHTML + if (this._translatedValue === '') { this._translatedValue = locId } this._asyncUpdateTranslation() - return this._translatedValue + return this._internalRender() + } + + _internalRender = () => { + return this._allowUnsafeHTML ? html`${unsafeHTML(this._translatedValue)}` : this._translatedValue } _asyncUpdateTranslation = async () => { @@ -32,7 +48,7 @@ export class TranslationDirective extends AsyncDirective { if (newValue !== '' && newValue !== this._translatedValue) { this._translatedValue = newValue - this.setValue(newValue) + this.setValue(this._internalRender()) } } } diff --git a/client/common/configuration/templates/channel.mustache b/client/common/configuration/templates/channel.mustache deleted file mode 100644 index 09e717eb..00000000 --- a/client/common/configuration/templates/channel.mustache +++ /dev/null @@ -1,233 +0,0 @@ - - - {{title}}: - - {{channelConfiguration.channel.displayName}} - {{channelConfiguration.channel.name}} - - {{{helpButton}}} - - {{description}} - - - - {{slowModeLabel}} - {{{slowModeDesc}}} - {{{helpButtonSlowMode}}} - - - - - - - - - - - {{botOptions}} - {{{helpButtonBot}}} - - - - - - {{enableBot}} - - - - {{botNickname}} - - - - - - - - {{forbiddenWords}} #{{displayNumber}} - {{#displayHelp}} - {{forbiddenWordsDesc}} {{moreInfo}} - {{{helpButtonForbiddenWords}}} - {{/displayHelp}} - - - - - {{forbiddenWords}} {{forbiddenWordsDesc2}} - {{forbiddenWordsRegexp}} {{forbiddenWordsRegexpDesc}} - {{forbiddenWordsApplyToModerators}} {{forbiddenWordsApplyToModeratorsDesc}} - {{forbiddenWordsLabel}} {{forbiddenWordsLabelDesc}} - {{forbiddenWordsReason}} {{forbiddenWordsReasonDesc}} - {{forbiddenWordsComments}} {{forbiddenWordsCommentsDesc}} - Remove Remove Row - - - {{#forbiddenWordsArray}}{{! iterating on forbiddenWordsArray to display N fields }} - - - - {{! warning: don't add extra line break in textarea! }} - {{joinedEntries}} - - - - - - - - - - - - - - - {{! warning: don't add extra line break in textarea! }} - {{comments}} - - - x - - - {{/forbiddenWordsArray}} - - + - - - - - - {{#quotesArray}}{{! iterating on quotesArray to display N fields }} - - - {{quoteLabel}} #{{displayNumber}} - {{#displayHelp}} - {{quoteDesc}} {{moreInfo}} - {{{helpButtonQuotes}}} - {{/displayHelp}} - - - - {{quoteLabel2}} - {{! warning: don't add extra line break in textarea! }} - {{joinedMessages}} - {{quoteDesc2}} - - - {{quoteDelayLabel}} - - {{quoteDelayDesc}} - - - - {{/quotesArray}} - - {{#cmdsArray}}{{! iterating on cmdsArray to display N fields }} - - - {{commandLabel}} #{{displayNumber}} - {{#displayHelp}} - {{commandDesc}} {{moreInfo}} - {{{helpButtonCommands}}} - {{/displayHelp}} - - - - {{commandCmdLabel}} - - {{commandCmdDesc}} - - - {{commandMessageLabel}} - - {{commandMessageDesc}} - - - - {{/cmdsArray}} - - - - - - diff --git a/client/common/configuration/templates/channel.ts b/client/common/configuration/templates/channel.ts deleted file mode 100644 index dc1ca49a..00000000 --- a/client/common/configuration/templates/channel.ts +++ /dev/null @@ -1,246 +0,0 @@ -// SPDX-FileCopyrightText: 2024 John Livingston -// -// SPDX-License-Identifier: AGPL-3.0-only - -import type { RegisterClientHelpers, RegisterClientOptions } from '@peertube/peertube-types/client' -import { localizedHelpUrl } from '../../../utils/help' -import { helpButtonSVG } from '../../../videowatch/buttons' -import { getConfigurationChannelViewData } from './logic/channel' -import { TemplateResult, html } from 'lit' -import { unsafeHTML } from 'lit/directives/unsafe-html.js' -import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; -// Must use require for mustache, import seems buggy. -const Mustache = require('mustache') - -import './DynamicTableFormElement' -import './ChannelConfigurationElement' -import './PluginConfigurationRow' -import { ptTr } from './TranslationDirective' - -/** - * Renders the configuration settings page for a given channel, - * and set it as innerHTML to rootEl. - * The page content can be empty. In such case, the notifier will be used to display a message. - * @param registerClientOptions Peertube client options - * @param channelId The channel id - * @param rootEl The HTMLElement in which insert the generated DOM. - */ -async function renderConfigurationChannel ( - registerClientOptions: RegisterClientOptions, - channelId: string, - rootEl: HTMLElement -): Promise { - const peertubeHelpers = registerClientOptions.peertubeHelpers - - try { - const view : {[key: string] : any} = await getConfigurationChannelViewData(registerClientOptions, channelId) - await fillViewHelpButtons(registerClientOptions, view) - await fillLabels(registerClientOptions, view) - - //await vivifyConfigurationChannel(registerClientOptions, rootEl, channelId) - - let tableHeader = { - words: { - colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_LABEL), - description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_DESC2) - }, - regex: { - colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REGEXP_LABEL), - description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REGEXP_DESC) - }, - applyToModerators: { - colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_APPLYTOMODERATORS_LABEL), - description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_APPLYTOMODERATORS_DESC) - }, - label: { - colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_LABEL_LABEL), - description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_LABEL_DESC) - }, - reason: { - colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REASON_LABEL), - description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REASON_DESC) - }, - comments: { - colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_COMMENTS_LABEL), - description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_COMMENTS_DESC) - } - } - let tableSchema = { - words: { - inputType: 'text', - default: 'helloqwesad' - }, - regex: { - inputType: 'text', - default: 'helloaxzca' - }, - applyToModerators: { - inputType: 'checkbox', - default: true - }, - label: { - inputType: 'text', - default: 'helloasx' - }, - reason: { - inputType: 'select', - default: 'transphobia', - label: 'choose your poison', - options: {'racism': 'Racism', 'sexism': 'Sexism', 'transphobia': 'Transphobia', 'bigotry': 'Bigotry'} - }, - comments: { - inputType: 'textarea', - default: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, - sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris - nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla - pariatur. Excepteur sint occaecat cupidatat non proident, sunt in - culpa qui officia deserunt mollit anim id est laborum.` - }, - } - let tableRows = [ - { - words: 'teweqwst', - regex: 'tesdgst', - applyToModerators: false, - label: 'teswet', - reason: 'sexism', - comments: 'tsdaswest', - }, - { - words: 'tedsadst', - regex: 'tezxccst', - applyToModerators: true, - label: 'tewest', - reason: 'racism', - comments: 'tesxzct', - }, - { - words: 'tesadsdxst', - regex: 'dsfsdf', - applyToModerators: false, - label: 'tesdadst', - reason: 'bigotry', - comments: 'tsadest', - }, - ] - - let helpLink = { - url : new URL(await localizedHelpUrl(registerClientOptions, { page: 'documentation/user/streamers/bot/forbidden_words' })), - title: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_DESC) - } - - return html` - - - - - - - ` - } catch (err: any) { - peertubeHelpers.notifier.error(err.toString()) - return html`` - } -} - -async function fillViewHelpButtons ( - registerClientOptions: RegisterClientOptions, - view: {[key: string]: string} -): Promise { - const title = await registerClientOptions.peertubeHelpers.translate(LOC_ONLINE_HELP) - - const button = async (page: string): Promise => { - const helpUrl = await localizedHelpUrl(registerClientOptions, { - page - }) - const helpIcon = helpButtonSVG() - return `${helpIcon}` - } - - view.helpButton = await button('documentation/user/streamers/channel') - view.helpButtonBot = await button('documentation/user/streamers/bot') - view.helpButtonForbiddenWords = await button('documentation/user/streamers/bot/forbidden_words') - view.helpButtonQuotes = await button('documentation/user/streamers/bot/quotes') - view.helpButtonCommands = await button('documentation/user/streamers/bot/commands') - view.helpButtonSlowMode = await button('documentation/user/streamers/slow_mode') -} - -async function fillLabels ( - registerClientOptions: RegisterClientOptions, - view: {[key: string] : string} -): Promise { - const peertubeHelpers = registerClientOptions.peertubeHelpers - - view.title = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_TITLE) - view.description = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_DESC) - - view.slowModeLabel = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_SLOW_MODE_LABEL) - view.slowModeDesc = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_SLOW_MODE_DESC) - view.enableBot = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_ENABLE_BOT_LABEL) - view.botOptions = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_BOT_OPTIONS_TITLE) - view.forbiddenWords = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_LABEL) - view.forbiddenWordsDesc = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_DESC) - view.forbiddenWordsDesc2 = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_DESC2) - view.forbiddenWordsReason = await peertubeHelpers.translate( - LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REASON_LABEL - ) - view.forbiddenWordsReasonDesc = await peertubeHelpers.translate( - LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REASON_DESC - ) - view.forbiddenWordsRegexp = await peertubeHelpers.translate( - LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REGEXP_LABEL - ) - view.forbiddenWordsRegexpDesc = await peertubeHelpers.translate( - LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REGEXP_DESC - ) - view.forbiddenWordsApplyToModerators = await peertubeHelpers.translate( - LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_APPLYTOMODERATORS_LABEL - ) - view.forbiddenWordsApplyToModeratorsDesc = await peertubeHelpers.translate( - LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_APPLYTOMODERATORS_DESC - ) - view.forbiddenWordsComments = await peertubeHelpers.translate( - LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_COMMENTS_LABEL - ) - view.forbiddenWordsCommentsDesc = await peertubeHelpers.translate( - LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_COMMENTS_DESC - ) - view.quoteLabel = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_QUOTE_LABEL) - view.quoteLabel2 = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_QUOTE_LABEL2) - view.quoteDesc = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_QUOTE_DESC) - view.quoteDesc2 = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_QUOTE_DESC2) - view.quoteDelayLabel = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_QUOTE_DELAY_LABEL) - view.quoteDelayDesc = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_QUOTE_DELAY_DESC) - view.commandLabel = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_COMMAND_LABEL) - view.commandDesc = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_COMMAND_DESC) - view.commandCmdLabel = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_COMMAND_CMD_LABEL) - view.commandCmdDesc = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_COMMAND_CMD_DESC) - view.commandMessageLabel = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_COMMAND_MESSAGE_LABEL) - view.commandMessageDesc = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_COMMAND_MESSAGE_DESC) - // view.bannedJIDs = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_BANNED_JIDS_LABEL) - - view.save = await peertubeHelpers.translate(LOC_SAVE) - view.cancel = await peertubeHelpers.translate(LOC_CANCEL) - view.botNickname = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_BOT_NICKNAME) - view.moreInfo = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FOR_MORE_INFO) -} - -export { - renderConfigurationChannel -} diff --git a/client/common/configuration/templates/home.mustache b/client/common/configuration/templates/home.mustache deleted file mode 100644 index 314948ae..00000000 --- a/client/common/configuration/templates/home.mustache +++ /dev/null @@ -1,28 +0,0 @@ - - - {{title}} - {{{helpButton}}} - - {{description}} - {{please_select}} - - {{#channels}} - - - {{#avatar}} - - {{/avatar}} - {{^avatar}} - - {{/avatar}} - - - - {{displayName}} - {{name}} - - - - {{/channels}} - - diff --git a/client/common/configuration/templates/home.ts b/client/common/configuration/templates/home.ts index 342e24ce..814421ec 100644 --- a/client/common/configuration/templates/home.ts +++ b/client/common/configuration/templates/home.ts @@ -6,9 +6,7 @@ import type { RegisterClientOptions } from '@peertube/peertube-types/client' import { localizedHelpUrl } from '../../../utils/help' import { helpButtonSVG } from '../../../videowatch/buttons' import { TemplateResult, html } from 'lit' -import { unsafeHTML } from 'lit/directives/unsafe-html.js' -import { ptTr } from './TranslationDirective' -import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; interface HomeViewData { title: string @@ -89,7 +87,7 @@ async function _fillViewHelpButtons ( // TODO: refactor with the similar functio return html`${unsafeHTML(helpIcon)}` } diff --git a/client/common/configuration/templates/logic/channel.ts b/client/common/configuration/templates/logic/channel.ts deleted file mode 100644 index 2c6c9e68..00000000 --- a/client/common/configuration/templates/logic/channel.ts +++ /dev/null @@ -1,432 +0,0 @@ -// SPDX-FileCopyrightText: 2024 John Livingston -// -// SPDX-License-Identifier: AGPL-3.0-only - -import type { RegisterClientOptions } from '@peertube/peertube-types/client' -import type { ChannelConfiguration, ChannelConfigurationOptions } from 'shared/lib/types' -import { getBaseRoute } from '../../../../utils/uri' - -/** - * Returns the data that can be feed into the template view - * @param registerClientOptions - * @param channelId - */ -async function getConfigurationChannelViewData ( - registerClientOptions: RegisterClientOptions, - channelId: string -): Promise<{[key: string] : any}> { - if (!channelId || !/^\d+$/.test(channelId)) { - throw new Error('Missing or invalid channel id.') - } - - const { peertubeHelpers } = registerClientOptions - const response = await fetch( - getBaseRoute(registerClientOptions) + '/api/configuration/channel/' + encodeURIComponent(channelId), - { - method: 'GET', - headers: peertubeHelpers.getAuthHeader() - } - ) - if (!response.ok) { - throw new Error('Can\'t get channel configuration options.') - } - const channelConfiguration: ChannelConfiguration = await (response).json() - - // Basic testing that channelConfiguration has the correct format - if ((typeof channelConfiguration !== 'object') || !channelConfiguration.channel) { - throw new Error('Invalid channel configuration options.') - } - - const forbiddenWordsArray: Object[] = [] - for (let i = 0; i < channelConfiguration.configuration.bot.forbiddenWords.length; i++) { - const fw = channelConfiguration.configuration.bot.forbiddenWords[i] - forbiddenWordsArray.push({ - displayNumber: i + 1, - fieldNumber: i, - displayHelp: i === 0, - joinedEntries: fw.entries.join('\n'), - regexp: !!fw.regexp, - applyToModerators: fw.applyToModerators, - label:fw.label, - reason: fw.reason, - comments: fw.comments - }) - } - // Ensuring we have at least N blocks: - while (forbiddenWordsArray.length < 1) { - const i = forbiddenWordsArray.length - // default value - forbiddenWordsArray.push({ - displayNumber: i + 1, - fieldNumber: i, - displayHelp: i === 0, - joinedEntries: '', - regexp: false, - applyToModerators: false, - label:'', - reason: '', - comments: '' - }) - continue - } - - const quotesArray: Object[] = [] - for (let i = 0; i < channelConfiguration.configuration.bot.quotes.length; i++) { - const qs = channelConfiguration.configuration.bot.quotes[i] - quotesArray.push({ - displayNumber: i + 1, - fieldNumber: i, - displayHelp: i === 0, - joinedMessages: qs.messages.join('\n'), - delay: Math.round(qs.delay / 60) // converting to minutes - }) - } - // Ensuring we have at least N blocks: - while (quotesArray.length < 1) { - const i = quotesArray.length - // default value - quotesArray.push({ - displayNumber: i + 1, - fieldNumber: i, - displayHelp: i === 0, - joinedMessages: '', - delay: 5 - }) - continue - } - - const cmdsArray: Object[] = [] - for (let i = 0; i < channelConfiguration.configuration.bot.commands.length; i++) { - const cs = channelConfiguration.configuration.bot.commands[i] - cmdsArray.push({ - displayNumber: i + 1, - fieldNumber: i, - displayHelp: i === 0, - message: cs.message, - command: cs.command - }) - } - // Ensuring we have at least N blocks: - while (cmdsArray.length < 1) { - const i = cmdsArray.length - // default value - cmdsArray.push({ - displayNumber: i + 1, - fieldNumber: i, - displayHelp: i === 0, - message: '', - command: '' - }) - continue - } - - return { - channelConfiguration, - forbiddenWordsArray, - quotesArray, - cmdsArray - } -} - -/** - * Adds the front-end logic on the generated html for the channel configuration options. - * @param clientOptions Peertube client options - * @param rootEl The root element in which the template was rendered - */ -async function vivifyConfigurationChannel ( - clientOptions: RegisterClientOptions, - rootEl: HTMLElement, - channelId: string -): Promise { - const form = rootEl.querySelector('form[livechat-configuration-channel-options]') as HTMLFormElement - if (!form) { return } - const translate = clientOptions.peertubeHelpers.translate - const labelSaved = await translate(LOC_SUCCESSFULLY_SAVED) - const labelError = await translate(LOC_ERROR) - const enableBotCB = form.querySelector('input[name=bot]') as HTMLInputElement - const botEnabledEl = form.querySelectorAll('[livechat-configuration-channel-options-bot-enabled]') - - const dataClasses = ['forbidden-words', 'command', 'quote'] - type ChannelConfigClass = (typeof dataClasses)[number] - - type ChannelRowData = Record - - const populateRowData: Function = () => { - let modifiers : ChannelRowData = {}; - for (let dataClass in dataClasses) { - let rows : HTMLTableRowElement[] = []; - let removeButtons : HTMLButtonElement[] = []; - - for (let i = 0, row : HTMLTableRowElement; row = form.querySelector(`button.peertube-livechat-${dataClass}-${i}-row`) as HTMLTableRowElement; i++) { - rows.push(row) - } - - for (let i = 0, button : HTMLButtonElement; button = form.querySelector(`button.peertube-livechat-${dataClass}-${i}-remove`) as HTMLButtonElement; i++) { - removeButtons.push(button) - } - - modifiers[dataClass] = { - rows, - addButton: form.querySelector(`button.peertube-livechat-${dataClass}-add`) as HTMLButtonElement, - removeButtons - } - } - return modifiers - } - - let rowDataRecords : ChannelRowData = populateRowData(); - - function removeRow(dataClass: ChannelConfigClass, index: number): any { - let {rows} = rowDataRecords[dataClass] - - let rowToDelete = rows.splice(index,1)[0] - - rowToDelete - - for (let i = index, row : HTMLTableRowElement; row = form.querySelector(`button.peertube-livechat-${dataClass}-${i}-row`) as HTMLTableRowElement; i++) { - rows.push(row) - } - } - - function addRow(dataClass: ChannelConfigClass): any { - throw new Error('Function not implemented.') - } - - const refresh: Function = () => { - botEnabledEl.forEach(el => { - if (enableBotCB.checked) { - (el as HTMLElement).style.removeProperty('display') - } else { - (el as HTMLElement).style.display = 'none' - } - }) - } - - const removeDisplayedErrors = (): void => { - form.querySelectorAll('.form-error').forEach(el => el.remove()) - } - - const displayError = async (fieldSelector: string, message: string): Promise => { - form.querySelectorAll(fieldSelector).forEach(el => { - const erEl = document.createElement('div') - erEl.classList.add('form-error') - erEl.textContent = message - el.after(erEl) - }) - } - - const validateData: Function = async (channelConfigurationOptions: ChannelConfigurationOptions): Promise => { - const botConf = channelConfigurationOptions.bot - const slowModeDuration = channelConfigurationOptions.slowMode.duration - const errorFieldSelectors = [] - - if ( - (typeof slowModeDuration !== 'number') || - isNaN(slowModeDuration) || - slowModeDuration < 0 || - slowModeDuration > 1000 - ) { - const selector = '#peertube-livechat-slow-mode-duration' - errorFieldSelectors.push(selector) - await displayError(selector, await translate(LOC_INVALID_VALUE)) - } - - // If !bot.enabled, we don't have to validate these fields: - // The backend will ignore those values. - if (botConf.enabled) { - if (/[^\p{L}\p{N}\p{Z}_-]/u.test(botConf.nickname ?? '')) { - const selector = '#peertube-livechat-bot-nickname' - errorFieldSelectors.push(selector) - await displayError(selector, await translate(LOC_INVALID_VALUE)) - } - - for (let iFw = 0; iFw < botConf.forbiddenWords.length; iFw++) { - const fw = botConf.forbiddenWords[iFw] - if (fw.regexp) { - for (const v of fw.entries) { - if (v === '' || /^\s+$/.test(v)) { continue } - try { - // eslint-disable-next-line no-new - new RegExp(v) - } catch (err) { - const selector = '#peertube-livechat-forbidden-words-' + iFw.toString() - errorFieldSelectors.push(selector) - let message = await translate(LOC_INVALID_VALUE) - message += ` "${v}": ${err as string}` - await displayError(selector, message) - } - } - } - } - - for (let iQt = 0; iQt < botConf.quotes.length; iQt++) { - const qt = botConf.quotes[iQt] - if (qt.messages.some(/\s+/.test)) { - const selector = '#peertube-livechat-quote-' + iQt.toString() - errorFieldSelectors.push(selector) - const message = await translate(LOC_INVALID_VALUE) - await displayError(selector, message) - } - } - - for (let iCd = 0; iCd < botConf.commands.length; iCd++) { - const cd = botConf.commands[iCd] - if (/\s+/.test(cd.command)) { - const selector = '#peertube-livechat-command-' + iCd.toString() - errorFieldSelectors.push(selector) - const message = await translate(LOC_INVALID_VALUE) - await displayError(selector, message) - } - } - } - - if (errorFieldSelectors.length) { - // Set the focus to the first in-error field: - const el: HTMLInputElement | HTMLTextAreaElement | null = document.querySelector(errorFieldSelectors[0]) - el?.focus() - return false - } - - return true - } - - const submitForm: Function = async () => { - const data = new FormData(form) - removeDisplayedErrors() - const channelConfigurationOptions: ChannelConfigurationOptions = { - slowMode: { - duration: parseInt(data.get('slow_mode_duration')?.toString() ?? '0') - }, - bot: { - enabled: data.get('bot') === '1', - nickname: data.get('bot_nickname')?.toString() ?? '', - // TODO bannedJIDs - forbiddenWords: [], - quotes: [], - commands: [] - } - } - - // Note: but data in order, because validateData assume index are okay to find associated fields. - for (let i = 0; data.has('forbidden_words_' + i.toString()); i++) { - const entries = (data.get('forbidden_words_' + i.toString())?.toString() ?? '') - .split(/\r?\n|\r|\n/g) - .filter(s => !/^\s*$/.test(s)) // filtering empty lines - const regexp = data.get('forbidden_words_regexp_' + i.toString()) - const applyToModerators = data.get('forbidden_words_applytomoderators_' + i.toString()) - const label = data.get('forbidden_words_label_' + i.toString())?.toString() - const reason = data.get('forbidden_words_reason_' + i.toString())?.toString() - const comments = data.get('forbidden_words_comments_' + i.toString())?.toString() - const fw: ChannelConfigurationOptions['bot']['forbiddenWords'][0] = { - entries, - applyToModerators: !!applyToModerators, - regexp: !!regexp - } - if (label) { - fw.label = label - } - if (reason) { - fw.reason = reason - } - if (comments) { - fw.comments = comments - } - channelConfigurationOptions.bot.forbiddenWords.push(fw) - } - - // Note: but data in order, because validateData assume index are okay to find associated fields. - for (let i = 0; data.has('quote_' + i.toString()); i++) { - const messages = (data.get('quote_' + i.toString())?.toString() ?? '') - .split(/\r?\n|\r|\n/g) - .filter(s => !/^\s*$/.test(s)) // filtering empty lines - let delay = parseInt(data.get('quote_delay_' + i.toString())?.toString() ?? '') - if (!delay || isNaN(delay) || delay < 1) { - delay = 5 - } - delay = delay * 60 // converting to seconds - const q: ChannelConfigurationOptions['bot']['quotes'][0] = { - messages, - delay - } - channelConfigurationOptions.bot.quotes.push(q) - } - - // Note: but data in order, because validateData assume index are okay to find associated fields. - for (let i = 0; data.has('command_' + i.toString()); i++) { - const command = (data.get('command_' + i.toString())?.toString() ?? '') - const message = (data.get('command_message_' + i.toString())?.toString() ?? '') - const c: ChannelConfigurationOptions['bot']['commands'][0] = { - command, - message - } - channelConfigurationOptions.bot.commands.push(c) - } - - if (!await validateData(channelConfigurationOptions)) { - throw new Error('Invalid form data') - } - - const headers: any = clientOptions.peertubeHelpers.getAuthHeader() ?? {} - headers['content-type'] = 'application/json;charset=UTF-8' - - const response = await fetch( - getBaseRoute(clientOptions) + '/api/configuration/channel/' + encodeURIComponent(channelId), - { - method: 'POST', - headers, - body: JSON.stringify(channelConfigurationOptions) - } - ) - - if (!response.ok) { - throw new Error('Failed to save configuration options.') - } - } - const toggleSubmit: Function = (disabled: boolean) => { - form.querySelectorAll('input[type=submit], input[type=reset]').forEach((el) => { - if (disabled) { - el.setAttribute('disabled', 'disabled') - } else { - el.removeAttribute('disabled') - } - }) - } - - enableBotCB.onclick = () => refresh() - - for(let [dataClass, rowData] of Object.entries(rowDataRecords)) { - rowData.addButton.onclick = () => addRow(dataClass) - - for (let i = 0; i < rowData.removeButtons.length; i++) { - rowData.removeButtons[i].onclick = () => removeRow(dataClass, i) - } - } - - form.onsubmit = () => { - toggleSubmit(true) - if (!form.checkValidity()) { - return false - } - submitForm().then( - () => { - clientOptions.peertubeHelpers.notifier.success(labelSaved) - toggleSubmit(false) - }, - () => { - clientOptions.peertubeHelpers.notifier.error(labelError) - toggleSubmit(false) - } - ) - return false - } - form.onreset = () => { - // Must refresh in a setTimeout, otherwise the checkbox state is not up to date. - setTimeout(() => refresh(), 1) - } - refresh() -} - -export { - getConfigurationChannelViewData, - vivifyConfigurationChannel -} \ No newline at end of file diff --git a/client/common/global-styles.ts b/client/common/global-styles.ts new file mode 100644 index 00000000..e8520ca4 --- /dev/null +++ b/client/common/global-styles.ts @@ -0,0 +1,21 @@ +let globalSheets: CSSStyleSheet[] | undefined = undefined; + +export function getGlobalStyleSheets() { + if (globalSheets === undefined) { + globalSheets = Array.from(document.styleSheets) + .map(x => { + const sheet = new CSSStyleSheet(); + const css = Array.from(x.cssRules).map(rule => rule.cssText).join(' '); + sheet.replaceSync(css); + return sheet; + }); + } + + return globalSheets; +} + +export function addGlobalStylesToShadowRoot(shadowRoot: ShadowRoot) { + shadowRoot.adoptedStyleSheets.push( + ...getGlobalStyleSheets() + ); +} \ No newline at end of file
${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_DESC)}
${this.description}
Nothing in this row.
{{description}}
{{{slowModeDesc}}}
{{forbiddenWordsDesc}} {{moreInfo}}
{{quoteDesc}} {{moreInfo}}
{{quoteDesc2}}
{{quoteDelayDesc}}
{{commandDesc}} {{moreInfo}}
{{commandCmdDesc}}
{{commandMessageDesc}}
{{please_select}}