diff options
51 files changed, 2086 insertions, 390 deletions
diff --git a/src/App.scss b/src/App.scss index 3f352e8d..ef68ac50 100644 --- a/src/App.scss +++ b/src/App.scss @@ -645,6 +645,20 @@ option { } } +.cards-list { + list-style: none; + display: grid; + grid-auto-flow: row dense; + grid-template-columns: 1fr 1fr; + + li { + border: 1px solid var(--border); + border-radius: var(--inputRadius); + padding: 0.5em; + margin: 0.25em; + } +} + .btn-block { display: block; width: 100%; @@ -655,16 +669,19 @@ option { display: inline-flex; vertical-align: middle; - button { + button, + .button-dropdown { position: relative; flex: 1 1 auto; - &:not(:last-child) { + &:not(:last-child), + &:not(:last-child) .button-default { border-top-right-radius: 0; border-bottom-right-radius: 0; } - &:not(:first-child) { + &:not(:first-child), + &:not(:first-child) .button-default { border-top-left-radius: 0; border-bottom-left-radius: 0; } diff --git a/src/components/desktop_nav/desktop_nav.js b/src/components/desktop_nav/desktop_nav.js index 745b1a81..f6a2e294 100644 --- a/src/components/desktop_nav/desktop_nav.js +++ b/src/components/desktop_nav/desktop_nav.js @@ -107,7 +107,10 @@ export default { this.searchBarHidden = hidden }, openSettingsModal () { - this.$store.dispatch('openSettingsModal') + this.$store.dispatch('openSettingsModal', 'user') + }, + openAdminModal () { + this.$store.dispatch('openSettingsModal', 'admin') } } } diff --git a/src/components/desktop_nav/desktop_nav.vue b/src/components/desktop_nav/desktop_nav.vue index dc8bbfd3..49382f8e 100644 --- a/src/components/desktop_nav/desktop_nav.vue +++ b/src/components/desktop_nav/desktop_nav.vue @@ -48,20 +48,19 @@ icon="cog" /> </button> - <a + <button v-if="currentUser && currentUser.role === 'admin'" - href="/pleroma/admin/#/login-pleroma" - class="nav-icon" + class="button-unstyled nav-icon" target="_blank" :title="$t('nav.administration')" - @click.stop + @click.stop="openAdminModal" > <FAIcon fixed-width class="fa-scale-110 fa-old-padding" icon="tachometer-alt" /> - </a> + </button> <span class="spacer" /> <button v-if="currentUser" diff --git a/src/components/interface_language_switcher/interface_language_switcher.vue b/src/components/interface_language_switcher/interface_language_switcher.vue index a57e8761..364791a1 100644 --- a/src/components/interface_language_switcher/interface_language_switcher.vue +++ b/src/components/interface_language_switcher/interface_language_switcher.vue @@ -36,7 +36,9 @@ <button class="button-default btn" @click="addLanguage" - >{{ $t('settings.add_language') }}</button> + > + {{ $t('settings.add_language') }} + </button> </li> </ul> </div> diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index 4d801c5e..13e7f9b8 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -162,8 +162,8 @@ </router-link> <button class="button-unstyled expand-icon" - :aria-expanded="statusExpanded" :title="$t('tool_tip.toggle_expand')" + :aria-expanded="statusExpanded" @click.prevent="toggleStatusExpanded" > <FAIcon diff --git a/src/components/settings_modal/admin_tabs/frontends_tab.js b/src/components/settings_modal/admin_tabs/frontends_tab.js new file mode 100644 index 00000000..a2c27c2a --- /dev/null +++ b/src/components/settings_modal/admin_tabs/frontends_tab.js @@ -0,0 +1,64 @@ +import BooleanSetting from '../helpers/boolean_setting.vue' +import ChoiceSetting from '../helpers/choice_setting.vue' +import IntegerSetting from '../helpers/integer_setting.vue' +import StringSetting from '../helpers/string_setting.vue' +import GroupSetting from '../helpers/group_setting.vue' +import Popover from 'src/components/popover/popover.vue' + +import SharedComputedObject from '../helpers/shared_computed_object.js' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faGlobe +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faGlobe +) + +const FrontendsTab = { + provide () { + return { + defaultDraftMode: true, + defaultSource: 'admin' + } + }, + components: { + BooleanSetting, + ChoiceSetting, + IntegerSetting, + StringSetting, + GroupSetting, + Popover + }, + created () { + if (this.user.rights.admin) { + this.$store.dispatch('loadFrontendsStuff') + } + }, + computed: { + frontends () { + return this.$store.state.adminSettings.frontends + }, + ...SharedComputedObject() + }, + methods: { + update (frontend, suggestRef) { + const ref = suggestRef || frontend.refs[0] + const { name } = frontend + const payload = { name, ref } + + this.$store.state.api.backendInteractor.installFrontend({ payload }) + .then((externalUser) => { + this.$store.dispatch('loadFrontendsStuff') + }) + }, + setDefault (frontend, suggestRef) { + const ref = suggestRef || frontend.refs[0] + const { name } = frontend + + this.$store.commit('updateAdminDraft', { path: [':pleroma', ':frontends', ':primary'], value: { name, ref } }) + } + } +} + +export default FrontendsTab diff --git a/src/components/settings_modal/admin_tabs/frontends_tab.scss b/src/components/settings_modal/admin_tabs/frontends_tab.scss new file mode 100644 index 00000000..e3e04bc6 --- /dev/null +++ b/src/components/settings_modal/admin_tabs/frontends_tab.scss @@ -0,0 +1,13 @@ +.frontends-tab { + .cards-list { + padding: 0; + } + + dd { + text-overflow: ellipsis; + word-wrap: nowrap; + white-space: nowrap; + overflow-x: hidden; + max-width: 10em; + } +} diff --git a/src/components/settings_modal/admin_tabs/frontends_tab.vue b/src/components/settings_modal/admin_tabs/frontends_tab.vue new file mode 100644 index 00000000..71bbfa69 --- /dev/null +++ b/src/components/settings_modal/admin_tabs/frontends_tab.vue @@ -0,0 +1,162 @@ +<template> + <div class="frontends-tab" :label="$t('admin_dash.tabs.frontends')"> + <div class="setting-item"> + <h2>{{ $t('admin_dash.tabs.frontends') }}</h2> + <p>{{ $t('admin_dash.frontend.wip_notice') }}</p> + <ul class="setting-list"> + <li> + <h3>{{ $t('admin_dash.frontend.default_frontend') }}</h3> + <p>{{ $t('admin_dash.frontend.default_frontend_tip') }}</p> + <p>{{ $t('admin_dash.frontend.default_frontend_tip2') }}</p> + </li> + <li> + <StringSetting path=":pleroma.:frontends.:primary.name"> + NAME + </StringSetting> + </li> + <li> + <StringSetting path=":pleroma.:frontends.:primary.ref"> + REF + </StringSetting> + </li> + <li> + <GroupSetting path=":pleroma.:frontends.:primary"/> + </li> + </ul> + <div class="setting-list"> + <h3>{{ $t('admin_dash.frontend.available_frontends') }}</h3> + <ul class="cards-list"> + <li v-for="frontend in frontends" :key="frontend.name"> + <strong>{{ frontend.name }}</strong> + {{ ' ' }} + <span v-if="adminDraft[':pleroma'][':frontends'][':primary'].name === frontend.name"> + <i18n-t + keypath="admin_dash.frontend.is_default" + v-if="adminDraft[':pleroma'][':frontends'][':primary'].ref === frontend.refs[0]" + /> + <i18n-t + keypath="admin_dash.frontend.is_default_custom" + v-else + > + <template #version> + <code>{{ adminDraft[':pleroma'][':frontends'][':primary'].ref }}</code> + </template> + </i18n-t> + </span> + <dl> + <dt>{{ $t('admin_dash.frontend.repository') }}</dt> + <dd><a :href="frontend.git" target="_blank">{{ frontend.git }}</a></dd> + <template v-if="expertLevel"> + <dt>{{ $t('admin_dash.frontend.versions') }}</dt> + <dd v-for="ref in frontend.refs" :key="ref"><code>{{ ref }}</code></dd> + </template> + <dt v-if="expertLevel">{{ $t('admin_dash.frontend.build_url') }}</dt> + <dd v-if="expertLevel"><a :href="frontend.build_url" target="_blank">{{ frontend.build_url }}</a></dd> + </dl> + <div> + <span class="btn-group"> + <button + class="button button-default btn" + type="button" + :title="$t('admin_dash.frontend.update')" + @click="update(frontend)" + > + {{ + frontend.installed + ? $t('admin_dash.frontend.reinstall') + : $t('admin_dash.frontend.install') + }} + </button> + <Popover + v-if="frontend.refs.length > 1" + trigger="click" + class="button-dropdown" + placement="bottom" + > + <template #content> + <div class="dropdown-menu"> + <button + v-for="ref in frontend.refs" + :key="ref" + class="button-default dropdown-item" + @click="update(frontend, ref)" + > + <i18n-t keypath="admin_dash.frontend.install_version"> + <template #version> + <code>{{ ref }}</code> + </template> + </i18n-t> + </button> + </div> + </template> + <template #trigger> + <button + class="button button-default btn dropdown-button" + type="button" + :title="$t('admin_dash.frontend.update')" + > + <FAIcon icon="chevron-down" /> + </button> + </template> + </Popover> + </span> + <span class="btn-group" v-if="frontend.name !== 'admin-fe'"> + <button + class="button button-default btn" + type="button" + :disabled=" + adminDraft[':pleroma'][':frontends'][':primary'].name === frontend.name && + adminDraft[':pleroma'][':frontends'][':primary'].ref === frontend.refs[0] + " + :title="$t('admin_dash.frontend.update')" + @click="setDefault(frontend)" + > + {{ + $t('admin_dash.frontend.set_default') + }} + </button> + {{ ' ' }} + <Popover + v-if="frontend.refs.length > 1" + trigger="click" + class="button-dropdown" + placement="bottom" + > + <template #content> + <div class="dropdown-menu"> + <button + v-for="ref in frontend.refs.slice(1)" + :key="ref" + class="button-default dropdown-item" + @click="setDefault(frontend, ref)" + > + <i18n-t keypath="admin_dash.frontend.set_default_version"> + <template #version> + <code>{{ ref }}</code> + </template> + </i18n-t> + </button> + </div> + </template> + <template #trigger> + <button + class="button button-default btn dropdown-button" + type="button" + :title="$t('admin_dash.frontend.update')" + > + <FAIcon icon="chevron-down" /> + </button> + </template> + </Popover> + </span> + </div> + </li> + </ul> + </div> + </div> + </div> +</template> + +<script src="./frontends_tab.js"></script> + +<style lang="scss" src="./frontends_tab.scss"></style> diff --git a/src/components/settings_modal/admin_tabs/instance_tab.js b/src/components/settings_modal/admin_tabs/instance_tab.js new file mode 100644 index 00000000..f702afcf --- /dev/null +++ b/src/components/settings_modal/admin_tabs/instance_tab.js @@ -0,0 +1,36 @@ +import BooleanSetting from '../helpers/boolean_setting.vue' +import ChoiceSetting from '../helpers/choice_setting.vue' +import IntegerSetting from '../helpers/integer_setting.vue' +import StringSetting from '../helpers/string_setting.vue' +import GroupSetting from '../helpers/group_setting.vue' + +import SharedComputedObject from '../helpers/shared_computed_object.js' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faGlobe +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faGlobe +) + +const InstanceTab = { + provide () { + return { + defaultDraftMode: true, + defaultSource: 'admin' + } + }, + components: { + BooleanSetting, + ChoiceSetting, + IntegerSetting, + StringSetting, + GroupSetting + }, + computed: { + ...SharedComputedObject() + } +} + +export default InstanceTab diff --git a/src/components/settings_modal/admin_tabs/instance_tab.vue b/src/components/settings_modal/admin_tabs/instance_tab.vue new file mode 100644 index 00000000..fc41af65 --- /dev/null +++ b/src/components/settings_modal/admin_tabs/instance_tab.vue @@ -0,0 +1,216 @@ +<template> + <div :label="$t('admin_dash.tabs.instance')"> + <div class="setting-item"> + <h2>{{ $t('admin_dash.instance.instance') }}</h2> + <ul class="setting-list"> + <li> + <StringSetting path=":pleroma.:instance.:name"> + NAME + </StringSetting> + </li> + <li> + <StringSetting path=":pleroma.:instance.:email"> + ADMIN EMAIL + </StringSetting> + </li> + <li> + <StringSetting path=":pleroma.:instance.:description"> + DESCRIPTION + </StringSetting> + </li> + <li> + <StringSetting path=":pleroma.:instance.:short_description"> + SHORT DESCRIPTION + </StringSetting> + </li> + <li> + <StringSetting path=":pleroma.:instance.:instance_thumbnail"> + INSTANCE THUMBNAIL + </StringSetting> + </li> + <li> + <StringSetting path=":pleroma.:instance.:background_image"> + BACKGROUND IMAGE + </StringSetting> + </li> + </ul> + </div> + <div class="setting-item"> + <h2>{{ $t('admin_dash.instance.access') }}</h2> + <ul class="setting-list"> + <li> + <BooleanSetting path=":pleroma.:instance.:public"> + PUBLIC + </BooleanSetting> + </li> + <li> + <h3>{{ $t('admin_dash.instance.restrict.header') }}</h3> + </li> + <li> + <ChoiceSetting path=":pleroma.:instance.:limit_to_local_content"> + SEARCH RESTRICTION + </ChoiceSetting> + </li> + <li> + <h4>{{ $t('admin_dash.instance.restrict.timelines') }}</h4> + </li> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:timelines.:local" + indeterminate-state=":if_instance_is_private" + > + LOCAL TIMELINES + </BooleanSetting> + </li> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:timelines.:federated" + indeterminate-state=":if_instance_is_private" + > + FED TIMELINES + </BooleanSetting> + </li> + <li> + <GroupSetting path=":pleroma.:restrict_unauthenticated.:timelines"> + TIMELINES + </GroupSetting> + </li> + <li> + <h4>{{ $t('admin_dash.instance.restrict.profiles') }}</h4> + </li> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:profiles.:local" + indeterminate-state=":if_instance_is_private" + > + LOCAL PROFILES + </BooleanSetting> + </li> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:profiles.:remote" + indeterminate-state=":if_instance_is_private" + > + FED PROFILES + </BooleanSetting> + </li> + <li> + <GroupSetting path=":pleroma.:restrict_unauthenticated.:profiles"> + PROFILES + </GroupSetting> + </li> + <li> + <h4>{{ $t('admin_dash.instance.restrict.activities') }}</h4> + </li> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:activities.:local" + indeterminate-state=":if_instance_is_private" + > + LOCAL STATUSES + </BooleanSetting> + </li> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:activities.:remote" + indeterminate-state=":if_instance_is_private" + > + FED STATUSES + </BooleanSetting> + </li> + <li> + <GroupSetting path=":pleroma.:restrict_unauthenticated.:activities"> + STATUSES + </GroupSetting> + </li> + </ul> + </div> + <div class="setting-item"> + <h2>{{ $t('admin_dash.instance.registrations') }}</h2> + <ul class="setting-list"> + <li> + <BooleanSetting path=":pleroma.:instance.:registrations_open"> + REGISTRATIONS OPEN + </BooleanSetting> + <ul class="setting-list suboptions"> + <li> + <BooleanSetting + path=":pleroma.:instance.:invites_enabled" + parent-path=":pleroma.:instance.:registrations_open" + parent-invert + > + INVITES ENABLED + </BooleanSetting> + </li> + </ul> + </li> + <li> + <BooleanSetting path=":pleroma.:instance.:birthday_required"> + BDEY REQUIRED + </BooleanSetting> + <ul class="setting-list suboptions"> + <li> + <IntegerSetting + path=":pleroma.:instance.:birthday_min_age" + parent-path=":pleroma.:instance.:birthday_required" + > + BDEY age of consent + </IntegerSetting> + </li> + </ul> + </li> + <li> + <BooleanSetting path=":pleroma.:instance.:account_activation_required"> + ACTIVATION REQUIRED + </BooleanSetting> + </li> + <li> + <BooleanSetting path=":pleroma.:instance.:account_approval_required"> + APPROVAL REQUIRED + </BooleanSetting> + </li> + <li> + <h3>{{ $t('admin_dash.instance.captcha_header') }}</h3> + </li> + <li> + <BooleanSetting :path="[':pleroma', 'Pleroma.Captcha', ':enabled']"> + CAPTCHA + </BooleanSetting> + <ul class="setting-list suboptions"> + <li> + <ChoiceSetting + :path="[':pleroma', 'Pleroma.Captcha', ':method']" + :parent-path="[':pleroma', 'Pleroma.Captcha', ':enabled']" + :option-label-map="{ + 'Pleroma.Captcha.Native': $t('admin_dash.captcha.native'), + 'Pleroma.Captcha.Kocaptcha': $t('admin_dash.captcha.kocaptcha') + }" + > + CAPTCHA TYPE + </ChoiceSetting> + <IntegerSetting + :path="[':pleroma', 'Pleroma.Captcha', ':seconds_valid']" + :parent-path="[':pleroma', 'Pleroma.Captcha', ':enabled']" + > + VALID + </IntegerSetting> + </li> + </ul> + <ul + v-if="adminDraft[':pleroma']['Pleroma.Captcha'][':enabled'] && adminDraft[':pleroma']['Pleroma.Captcha'][':method'] === 'Pleroma.Captcha.Kocaptcha'" + class="setting-list suboptions" + > + <h4>{{ $t('admin_dash.instance.kocaptcha') }}</h4> + <li> + <StringSetting :path="[':pleroma', 'Pleroma.Captcha.Kocaptcha', ':endpoint']"> + cockAPTCHA ENDPOINT + </StringSetting> + </li> + </ul> + </li> + </ul> + </div> + </div> +</template> + +<script src="./instance_tab.js"></script> diff --git a/src/components/settings_modal/admin_tabs/limits_tab.js b/src/components/settings_modal/admin_tabs/limits_tab.js new file mode 100644 index 00000000..684739c3 --- /dev/null +++ b/src/components/settings_modal/admin_tabs/limits_tab.js @@ -0,0 +1,29 @@ +import BooleanSetting from '../helpers/boolean_setting.vue' +import ChoiceSetting from '../helpers/choice_setting.vue' +import IntegerSetting from '../helpers/integer_setting.vue' +import StringSetting from '../helpers/string_setting.vue' + +import SharedComputedObject from '../helpers/shared_computed_object.js' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faGlobe +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faGlobe +) + +const LimitsTab = { + data () {}, + components: { + BooleanSetting, + ChoiceSetting, + IntegerSetting, + StringSetting + }, + computed: { + ...SharedComputedObject() + } +} + +export default LimitsTab diff --git a/src/components/settings_modal/admin_tabs/limits_tab.vue b/src/components/settings_modal/admin_tabs/limits_tab.vue new file mode 100644 index 00000000..f7a70c97 --- /dev/null +++ b/src/components/settings_modal/admin_tabs/limits_tab.vue @@ -0,0 +1,154 @@ +<template> + <div :label="$t('admin_dash.tabs.limits')"> + <div class="setting-item"> + <h2>{{ $t('admin_dash.limits.arbitrary_limits') }}</h2> + <ul class="setting-list"> + <li> + <h3>{{ $t('admin_dash.limits.posts') }}</h3> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:limit" + draft-mode + > + POST LIMIT + </IntegerSetting> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:remote_limit" + expert="1" + draft-mode + > + POST LIMIT (remote) + </IntegerSetting> + </li> + <li> + <h3>{{ $t('admin_dash.limits.uploads') }}</h3> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:description_limit" + draft-mode + > + IMAGE DESCRIPTION LIMIT + </IntegerSetting> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:upload_limit" + draft-mode + > + UPLOAD LIMIT KiB + </IntegerSetting> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:max_media_attachments" + draft-mode + > + MAX ATTACHMENTS + </IntegerSetting> + </li> + <li> + <h3>{{ $t('admin_dash.limits.users') }}</h3> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:max_pinned_statuses" + draft-mode + > + MAX PINNED POSTS + </IntegerSetting> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:user_bio_length" + draft-mode + > + BIO LENGTH + </IntegerSetting> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:user_name_length" + draft-mode + > + NAME LENGTH + </IntegerSetting> + </li> + <li> + <h4>{{ $t('admin_dash.limits.profile_fields') }}</h4> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:max_account_fields" + draft-mode + > + MAX ACCOUNT FIELDS + </IntegerSetting> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:max_remote_account_fields" + draft-mode + expert="1" + > + MAX ACCOUNT FIELDS (remote) + </IntegerSetting> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:account_field_name_length" + draft-mode + > + MAX ACCOUNT FIELD NAME + </IntegerSetting> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:account_field_value_length" + draft-mode + > + MAX ACCOUNT VALUE NAME + </IntegerSetting> + </li> + <li> + <h4>{{ $t('admin_dash.limits.user_uploads') }}</h4> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:avatar_upload_limit" + draft-mode + > + MAX AVATAR SIZE KiB + </IntegerSetting> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:banner_upload_limit" + draft-mode + > + MAX BANNER SIZE KiB + </IntegerSetting> + </li> + </ul> + </div> + </div> +</template> + +<script src="./limits_tab.js"></script> diff --git a/src/components/settings_modal/helpers/boolean_setting.js b/src/components/settings_modal/helpers/boolean_setting.js index 2e6992cb..199d3d0f 100644 --- a/src/components/settings_modal/helpers/boolean_setting.js +++ b/src/components/settings_modal/helpers/boolean_setting.js @@ -1,56 +1,31 @@ -import { get, set } from 'lodash' import Checkbox from 'src/components/checkbox/checkbox.vue' -import ModifiedIndicator from './modified_indicator.vue' -import ServerSideIndicator from './server_side_indicator.vue' +import Setting from './setting.js' + export default { + ...Setting, + props: { + ...Setting.props, + indeterminateState: [String, Object] + }, components: { - Checkbox, - ModifiedIndicator, - ServerSideIndicator + ...Setting.components, + Checkbox }, - props: [ - 'path', - 'disabled', - 'expert' - ], computed: { - pathDefault () { - const [firstSegment, ...rest] = this.path.split('.') - return [firstSegment + 'DefaultValue', ...rest].join('.') - }, - state () { - const value = get(this.$parent, this.path) - if (value === undefined) { - return this.defaultState - } else { - return value - } - }, - defaultState () { - return get(this.$parent, this.pathDefault) - }, - isServerSide () { - return this.path.startsWith('serverSide_') - }, - isChanged () { - return !this.path.startsWith('serverSide_') && this.state !== this.defaultState - }, - matchesExpertLevel () { - return (this.expert || 0) <= this.$parent.expertLevel + ...Setting.computed, + isIndeterminate () { + return this.visibleState === this.indeterminateState } }, methods: { - update (e) { - const [firstSegment, ...rest] = this.path.split('.') - set(this.$parent, this.path, e) - // Updating nested properties does not trigger update on its parent. - // probably still not as reliable, but works for depth=1 at least - if (rest.length > 0) { - set(this.$parent, firstSegment, { ...get(this.$parent, firstSegment) }) + ...Setting.methods, + getValue (e) { + // Basic tri-state toggle implementation + if (!!this.indeterminateState && !e && this.visibleState === true) { + // If we have indeterminate state, switching from true to false first goes through indeterminate + return this.indeterminateState } - }, - reset () { - set(this.$parent, this.path, this.defaultState) + return e } } } diff --git a/src/components/settings_modal/helpers/boolean_setting.vue b/src/components/settings_modal/helpers/boolean_setting.vue index 41142966..6307d526 100644 --- a/src/components/settings_modal/helpers/boolean_setting.vue +++ b/src/components/settings_modal/helpers/boolean_setting.vue @@ -4,23 +4,37 @@ class="BooleanSetting" > <Checkbox - :model-value="state" - :disabled="disabled" + :model-value="visibleState" + :disabled="shouldBeDisabled" + :indeterminate="isIndeterminate" @update:modelValue="update" > <span v-if="!!$slots.default" class="label" + :class="{ 'faint': shouldBeDisabled }" > - <slot /> + <template v-if="backendDescription"> + {{ backendDescriptionLabel }} + </template> + <template v-else> + <slot /> + </template> </span> - {{ ' ' }} - <ModifiedIndicator - :changed="isChanged" - :onclick="reset" - /> - <ServerSideIndicator :server-side="isServerSide" /> </Checkbox> + <ModifiedIndicator + :changed="isChanged" + :onclick="reset" + /> + <ProfileSettingIndicator :is-profile="isProfileSetting" /> + <DraftButtons /> + <p + v-if="backendDescriptionDescription" + class="setting-description" + :class="{ 'faint': shouldBeDisabled }" + > + {{ backendDescriptionDescription + ' ' }} + </p> </label> </template> diff --git a/src/components/settings_modal/helpers/choice_setting.js b/src/components/settings_modal/helpers/choice_setting.js index 3da559fe..bdeece76 100644 --- a/src/components/settings_modal/helpers/choice_setting.js +++ b/src/components/settings_modal/helpers/choice_setting.js @@ -1,51 +1,41 @@ -import { get, set } from 'lodash' import Select from 'src/components/select/select.vue' -import ModifiedIndicator from './modified_indicator.vue' -import ServerSideIndicator from './server_side_indicator.vue' +import Setting from './setting.js' + export default { + ...Setting, components: { - Select, - ModifiedIndicator, - ServerSideIndicator + ...Setting.components, + Select }, - props: [ - 'path', - 'disabled', - 'options', - 'expert' - ], - computed: { - pathDefault () { - const [firstSegment, ...rest] = this.path.split('.') - return [firstSegment + 'DefaultValue', ...rest].join('.') + props: { + ...Setting.props, + options: { + type: Array, + required: false }, - state () { - const value = get(this.$parent, this.path) - if (value === undefined) { - return this.defaultState - } else { - return value + optionLabelMap: { + type: Object, + required: false, + default: {} + } + }, + computed: { + ...Setting.computed, + realOptions () { + if (this.realSource === 'admin') { + return this.backendDescriptionSuggestions.map(x => ({ + key: x, + value: x, + label: this.optionLabelMap[x] || x + })) } - }, - defaultState () { - return get(this.$parent, this.pathDefault) - }, - isServerSide () { - return this.path.startsWith('serverSide_') - }, - isChanged () { - return !this.path.startsWith('serverSide_') && this.state !== this.defaultState - }, - matchesExpertLevel () { - return (this.expert || 0) <= this.$parent.expertLevel + return this.options } }, methods: { - update (e) { - set(this.$parent, this.path, e) - }, - reset () { - set(this.$parent, this.path, this.defaultState) + ...Setting.methods, + getValue (e) { + return e } } } diff --git a/src/components/settings_modal/helpers/choice_setting.vue b/src/components/settings_modal/helpers/choice_setting.vue index 8fdbb5d3..8713acf5 100644 --- a/src/components/settings_modal/helpers/choice_setting.vue +++ b/src/components/settings_modal/helpers/choice_setting.vue @@ -3,15 +3,20 @@ v-if="matchesExpertLevel" class="ChoiceSetting" > - <slot /> + <template v-if="backendDescription"> + {{ backendDescriptionLabel }} + </template> + <template v-else> + <slot /> + </template> {{ ' ' }} <Select - :model-value="state" + :model-value="realDraftMode ? draft :state" :disabled="disabled" @update:modelValue="update" > <option - v-for="option in options" + v-for="option in realOptions" :key="option.key" :value="option.value" > @@ -23,7 +28,14 @@ :changed="isChanged" :onclick="reset" /> - <ServerSideIndicator :server-side="isServerSide" /> + <ProfileSettingIndicator :is-profile="isProfileSetting" /> + <DraftButtons /> + <p + v-if="backendDescriptionDescription" + class="setting-description" + > + {{ backendDescriptionDescription + ' ' }} + </p> </label> </template> diff --git a/src/components/settings_modal/helpers/draft_buttons.vue b/src/components/settings_modal/helpers/draft_buttons.vue new file mode 100644 index 00000000..80da92e8 --- /dev/null +++ b/src/components/settings_modal/helpers/draft_buttons.vue @@ -0,0 +1,102 @@ +<!-- this is a helper exclusive to Setting components --> +<!-- TODO make it reusable --> +<template> + <span + class="DraftButtons" + > + <Popover + v-if="$parent.isDirty" + trigger="hover" + :trigger-attrs="{ 'aria-label': $t('settings.commit_value_tooltip') }" + > + <template #trigger> + + <button + class="button button-default btn" + type="button" + :title="$t('settings.commit_value')" + @click="$parent.commitDraft" + > + {{ $t('settings.commit_value') }} + </button> + </template> + <template #content> + <div class="modified-tooltip"> + {{ $t('settings.commit_value_tooltip') }} + </div> + </template> + </Popover> + <Popover + v-if="$parent.isDirty" + trigger="hover" + :trigger-attrs="{ 'aria-label': $t('settings.reset_value_tooltip') }" + > + <template #trigger> + + <button + class="button button-default btn" + type="button" + :title="$t('settings.reset_value')" + @click="$parent.reset" + > + {{ $t('settings.reset_value') }} + </button> + </template> + <template #content> + <div class="modified-tooltip"> + {{ $t('settings.reset_value_tooltip') }} + </div> + </template> + </Popover> + <Popover + v-if="$parent.canHardReset" + trigger="hover" + :trigger-attrs="{ 'aria-label': $t('settings.hard_reset_value_tooltip') }" + > + <template #trigger> + + <button + class="button button-default btn" + type="button" + :title="$t('settings.hard_reset_value')" + @click="$parent.hardReset" + > + {{ $t('settings.hard_reset_value') }} + </button> + </template> + <template #content> + <div class="modified-tooltip"> + {{ $t('settings.hard_reset_value_tooltip') }} + </div> + </template> + </Popover> + </span> +</template> + +<script> +import Popover from 'src/components/popover/popover.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { faWrench } from '@fortawesome/free-solid-svg-icons' + +library.add( + faWrench +) + +export default { + components: { Popover }, + props: ['changed'] +} +</script> + +<style lang="scss"> +.DraftButtons { + display: inline-block; + position: relative; +} + +.draft-tooltip { + margin: 0.5em 1em; + min-width: 10em; + text-align: center; +} +</style> diff --git a/src/components/settings_modal/helpers/group_setting.js b/src/components/settings_modal/helpers/group_setting.js new file mode 100644 index 00000000..23a2a202 --- /dev/null +++ b/src/components/settings_modal/helpers/group_setting.js @@ -0,0 +1,13 @@ +import { isEqual } from 'lodash' + +import Setting from './setting.js' + +export default { + ...Setting, + computed: { + ...Setting.computed, + isDirty () { + return !isEqual(this.state, this.draft) + } + } +} diff --git a/src/components/settings_modal/helpers/group_setting.vue b/src/components/settings_modal/helpers/group_setting.vue new file mode 100644 index 00000000..a4df4bf3 --- /dev/null +++ b/src/components/settings_modal/helpers/group_setting.vue @@ -0,0 +1,15 @@ +<template> + <span + v-if="matchesExpertLevel" + class="GroupSetting" + > + <ModifiedIndicator + :changed="isChanged" + :onclick="reset" + /> + <ProfileSettingIndicator :is-profile="isProfileSetting" /> + <DraftButtons /> + </span> +</template> + +<script src="./group_setting.js"></script> diff --git a/src/components/settings_modal/helpers/number_setting.js b/src/components/settings_modal/helpers/number_setting.js index 73c39948..676a0d22 100644 --- a/src/components/settings_modal/helpers/number_setting.js +++ b/src/components/settings_modal/helpers/number_setting.js @@ -1,56 +1,24 @@ -import { get, set } from 'lodash' -import ModifiedIndicator from './modified_indicator.vue' +import Setting from './setting.js' + export default { - components: { - ModifiedIndicator - }, + ...Setting, props: { - path: String, - disabled: Boolean, - min: Number, - step: Number, - truncate: Number, - expert: [Number, String] - }, - computed: { - pathDefault () { - const [firstSegment, ...rest] = this.path.split('.') - return [firstSegment + 'DefaultValue', ...rest].join('.') - }, - parent () { - return this.$parent.$parent - }, - state () { - const value = get(this.parent, this.path) - if (value === undefined) { - return this.defaultState - } else { - return value - } - }, - defaultState () { - return get(this.parent, this.pathDefault) - }, - isChanged () { - return this.state !== this.defaultState - }, - matchesExpertLevel () { - return (this.expert || 0) <= this.parent.expertLevel + ...Setting.props, + truncate: { + type: Number, + required: false, + default: 1 } }, methods: { - truncateValue (value) { - if (!this.truncate) { - return value + ...Setting.methods, + getValue (e) { + if (!this.truncate === 1) { + return parseInt(e.target.value) + } else if (this.truncate > 1) { + return Math.trunc(e.target.value / this.truncate) * this.truncate } - - return Math.trunc(value / this.truncate) * this.truncate - }, - update (e) { - set(this.parent, this.path, this.truncateValue(parseFloat(e.target.value))) - }, - reset () { - set(this.parent, this.path, this.defaultState) + return parseFloat(e.target.value) } } } diff --git a/src/components/settings_modal/helpers/number_setting.vue b/src/components/settings_modal/helpers/number_setting.vue index 3eab5178..2b90c336 100644 --- a/src/components/settings_modal/helpers/number_setting.vue +++ b/src/components/settings_modal/helpers/number_setting.vue @@ -3,17 +3,22 @@ v-if="matchesExpertLevel" class="NumberSetting" > - <label :for="path"> - <slot /> + <label :for="path" :class="{ 'faint': shouldBeDisabled }"> + <template v-if="backendDescription"> + {{ backendDescriptionLabel + ' ' }} + </template> + <template v-else> + <slot /> + </template> </label> <input :id="path" class="number-input" type="number" :step="step || 1" - :disabled="disabled" + :disabled="shouldBeDisabled" :min="min || 0" - :value="state" + :value="realDraftMode ? draft :state" @change="update" > {{ ' ' }} @@ -21,6 +26,15 @@ :changed="isChanged" :onclick="reset" /> + <ProfileSettingIndicator :is-profile="isProfileSetting" /> + <DraftButtons /> + <p + v-if="backendDescriptionDescription" + class="setting-description" + :class="{ 'faint': shouldBeDisabled }" + > + {{ backendDescriptionDescription + ' ' }} + </p> </span> </template> diff --git a/src/components/settings_modal/helpers/server_side_indicator.vue b/src/components/settings_modal/helpers/profile_setting_indicator.vue index bf181959..d160781b 100644 --- a/src/components/settings_modal/helpers/server_side_indicator.vue +++ b/src/components/settings_modal/helpers/profile_setting_indicator.vue @@ -1,7 +1,7 @@ <template> <span - v-if="serverSide" - class="ServerSideIndicator" + v-if="isProfile" + class="ProfileSettingIndicator" > <Popover trigger="hover" @@ -14,7 +14,7 @@ /> </template> <template #content> - <div class="serverside-tooltip"> + <div class="profilesetting-tooltip"> {{ $t('settings.setting_server_side') }} </div> </template> @@ -33,17 +33,17 @@ library.add( export default { components: { Popover }, - props: ['serverSide'] + props: ['isProfile'] } </script> <style lang="scss"> -.ServerSideIndicator { +.ProfileSettingIndicator { display: inline-block; position: relative; } -.serverside-tooltip { +.profilesetting-tooltip { margin: 0.5em 1em; min-width: 10em; text-align: center; diff --git a/src/components/settings_modal/helpers/setting.js b/src/components/settings_modal/helpers/setting.js new file mode 100644 index 00000000..4f0be448 --- /dev/null +++ b/src/components/settings_modal/helpers/setting.js @@ -0,0 +1,203 @@ +import ModifiedIndicator from './modified_indicator.vue' +import ProfileSettingIndicator from './profile_setting_indicator.vue' +import DraftButtons from './draft_buttons.vue' +import { get, set } from 'lodash' + +export default { + components: { + ModifiedIndicator, + DraftButtons, + ProfileSettingIndicator + }, + props: { + path: { + type: [String, Array], + required: true + }, + disabled: { + type: Boolean, + default: false + }, + parentPath: { + type: [String, Array] + }, + parentInvert: { + type: Boolean, + default: false + }, + expert: { + type: [Number, String], + default: 0 + }, + source: { + type: String, + default: undefined + }, + draftMode: { + type: Boolean, + default: undefined + } + }, + inject: { + defaultSource: { + default: 'default' + }, + defaultDraftMode: { + default: false + } + }, + data () { + return { + localDraft: null + } + }, + created () { + if (this.realDraftMode && this.realSource !== 'admin') { + this.draft = this.state + } + }, + computed: { + draft: { + // TODO allow passing shared draft object? + get () { + if (this.realSource === 'admin') { + return get(this.$store.state.adminSettings.draft, this.canonPath) + } else { + return this.localDraft + } + }, + set (value) { + if (this.realSource === 'admin') { + this.$store.commit('updateAdminDraft', { path: this.canonPath, value }) + } else { + this.localDraft = value + } + } + }, + state () { + const value = get(this.configSource, this.canonPath) + if (value === undefined) { + return this.defaultState + } else { + return value + } + }, + visibleState () { + return this.realDraftMode ? this.draft : this.state + }, + realSource () { + return this.source || this.defaultSource + }, + realDraftMode () { + return typeof this.draftMode === 'undefined' ? this.defaultDraftMode : this.draftMode + }, + backendDescription () { + return get(this.$store.state.adminSettings.descriptions, this.path) + }, + backendDescriptionLabel () { + return this.backendDescription?.label + }, + backendDescriptionDescription () { + return this.backendDescription?.description + }, + backendDescriptionSuggestions () { + return this.backendDescription?.suggestions + }, + shouldBeDisabled () { + const parentValue = this.parentPath !== undefined ? get(this.configSource, this.parentPath) : null + return this.disabled || (parentValue !== null ? (this.parentInvert ? parentValue : !parentValue) : false) + }, + configSource () { + switch (this.realSource) { + case 'profile': + return this.$store.state.profileConfig + case 'admin': + return this.$store.state.adminSettings.config + default: + return this.$store.getters.mergedConfig + } + }, + configSink () { + switch (this.realSource) { + case 'profile': + return (k, v) => this.$store.dispatch('setProfileOption', { name: k, value: v }) + case 'admin': + return (k, v) => this.$store.dispatch('pushAdminSetting', { path: k, value: v }) + default: + return (k, v) => this.$store.dispatch('setOption', { name: k, value: v }) + } + }, + defaultState () { + switch (this.realSource) { + case 'profile': + return {} + default: + return get(this.$store.getters.defaultConfig, this.path) + } + }, + isProfileSetting () { + return this.realSource === 'profile' + }, + isChanged () { + switch (this.realSource) { + case 'profile': + case 'admin': + return false + default: + return this.state !== this.defaultState + } + }, + canonPath () { + return Array.isArray(this.path) ? this.path : this.path.split('.') + }, + isDirty () { + if (this.realSource === 'admin' && this.canonPath.length > 3) { + return false // should not show draft buttons for "grouped" values + } else { + return this.realDraftMode && this.draft !== this.state + } + }, + canHardReset () { + return this.realSource === 'admin' && this.$store.state.adminSettings.modifiedPaths.has(this.canonPath.join(' -> ')) + }, + matchesExpertLevel () { + return (this.expert || 0) <= this.$store.state.config.expertLevel > 0 + } + }, + methods: { + getValue (e) { + return e.target.value + }, + update (e) { + if (this.realDraftMode) { + this.draft = this.getValue(e) + } else { + this.configSink(this.path, this.getValue(e)) + } + }, + commitDraft () { + if (this.realDraftMode) { + this.configSink(this.path, this.draft) + } + }, + reset () { + console.log('reset') + if (this.realDraftMode) { + console.log(this.draft) + console.log(this.state) + this.draft = this.state + } else { + set(this.$store.getters.mergedConfig, this.path, this.defaultState) + } + }, + hardReset () { + switch (this.realSource) { + case 'admin': + return this.$store.dispatch('resetAdminSetting', { path: this.path }) + .then(() => { this.draft = this.state }) + default: + console.warn('Hard reset not implemented yet!') + } + } + } +} diff --git a/src/components/settings_modal/helpers/shared_computed_object.js b/src/components/settings_modal/helpers/shared_computed_object.js index 12431dca..bb3d36ac 100644 --- a/src/components/settings_modal/helpers/shared_computed_object.js +++ b/src/components/settings_modal/helpers/shared_computed_object.js @@ -1,52 +1,18 @@ -import { defaultState as configDefaultState } from 'src/modules/config.js' -import { defaultState as serverSideConfigDefaultState } from 'src/modules/serverSideConfig.js' - const SharedComputedObject = () => ({ user () { return this.$store.state.users.currentUser }, - // Getting values for default properties - ...Object.keys(configDefaultState) - .map(key => [ - key + 'DefaultValue', - function () { - return this.$store.getters.defaultConfig[key] - } - ]) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), - // Generating computed values for vuex properties - ...Object.keys(configDefaultState) - .map(key => [key, { - get () { return this.$store.getters.mergedConfig[key] }, - set (value) { - this.$store.dispatch('setOption', { name: key, value }) - } - }]) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), - ...Object.keys(serverSideConfigDefaultState) - .map(key => ['serverSide_' + key, { - get () { return this.$store.state.serverSideConfig[key] }, - set (value) { - this.$store.dispatch('setServerSideOption', { name: key, value }) - } - }]) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), - // Special cases (need to transform values or perform actions first) - useStreamingApi: { - get () { return this.$store.getters.mergedConfig.useStreamingApi }, - set (value) { - const promise = value - ? this.$store.dispatch('enableMastoSockets') - : this.$store.dispatch('disableMastoSockets') - - promise.then(() => { - this.$store.dispatch('setOption', { name: 'useStreamingApi', value }) - }).catch((e) => { - console.error('Failed starting MastoAPI Streaming socket', e) - this.$store.dispatch('disableMastoSockets') - this.$store.dispatch('setOption', { name: 'useStreamingApi', value: false }) - }) - } + expertLevel () { + return this.$store.getters.mergedConfig.expertLevel > 0 + }, + mergedConfig () { + return this.$store.getters.mergedConfig + }, + adminConfig () { + return this.$store.state.adminSettings.config + }, + adminDraft () { + return this.$store.state.adminSettings.draft } }) diff --git a/src/components/settings_modal/helpers/size_setting.js b/src/components/settings_modal/helpers/size_setting.js index 58697412..12cef705 100644 --- a/src/components/settings_modal/helpers/size_setting.js +++ b/src/components/settings_modal/helpers/size_setting.js @@ -1,67 +1,40 @@ -import { get, set } from 'lodash' -import ModifiedIndicator from './modified_indicator.vue' import Select from 'src/components/select/select.vue' +import Setting from './setting.js' export const allCssUnits = ['cm', 'mm', 'in', 'px', 'pt', 'pc', 'em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', '%'] export const defaultHorizontalUnits = ['px', 'rem', 'vw'] export const defaultVerticalUnits = ['px', 'rem', 'vh'] export default { + ...Setting, components: { - ModifiedIndicator, + ...Setting.components, Select }, props: { - path: String, - disabled: Boolean, + ...Setting.props, min: Number, units: { - type: [String], + type: Array, default: () => allCssUnits - }, - expert: [Number, String] + } }, computed: { - pathDefault () { - const [firstSegment, ...rest] = this.path.split('.') - return [firstSegment + 'DefaultValue', ...rest].join('.') - }, + ...Setting.computed, stateUnit () { - return (this.state || '').replace(/\d+/, '') + return this.state.replace(/\d+/, '') }, stateValue () { - return (this.state || '').replace(/\D+/, '') - }, - state () { - const value = get(this.$parent, this.path) - if (value === undefined) { - return this.defaultState - } else { - return value - } - }, - defaultState () { - return get(this.$parent, this.pathDefault) - }, - isChanged () { - return this.state !== this.defaultState - }, - matchesExpertLevel () { - return (this.expert || 0) <= this.$parent.expertLevel + return this.state.replace(/\D+/, '') } }, methods: { - update (e) { - set(this.$parent, this.path, e) - }, - reset () { - set(this.$parent, this.path, this.defaultState) - }, + ...Setting.methods, updateValue (e) { - set(this.$parent, this.path, parseInt(e.target.value) + this.stateUnit) + this.configSink(this.path, parseInt(e.target.value) + this.stateUnit) }, updateUnit (e) { - set(this.$parent, this.path, this.stateValue + e.target.value) + this.configSink(this.path, this.stateValue + e.target.value) } } } diff --git a/src/components/settings_modal/helpers/string_setting.js b/src/components/settings_modal/helpers/string_setting.js new file mode 100644 index 00000000..b368cfc8 --- /dev/null +++ b/src/components/settings_modal/helpers/string_setting.js @@ -0,0 +1,5 @@ +import Setting from './setting.js' + +export default { + ...Setting +} diff --git a/src/components/settings_modal/helpers/string_setting.vue b/src/components/settings_modal/helpers/string_setting.vue new file mode 100644 index 00000000..5ee75a84 --- /dev/null +++ b/src/components/settings_modal/helpers/string_setting.vue @@ -0,0 +1,39 @@ +<template> + <label + v-if="matchesExpertLevel" + class="StringSetting" + > + <label :for="path" :class="{ 'faint': shouldBeDisabled }"> + <template v-if="backendDescription"> + {{ backendDescriptionLabel + ' ' }} + </template> + <template v-else> + <slot /> + </template> + </label> + <input + :id="path" + class="string-input" + step="1" + :disabled="shouldBeDisabled" + :value="realDraftMode ? draft : state" + @change="update" + > + {{ ' ' }} + <ModifiedIndicator + :changed="isChanged" + :onclick="reset" + /> + <ProfileSettingIndicator :is-profile="isProfileSetting" /> + <DraftButtons /> + <p + v-if="backendDescriptionDescription" + class="setting-description" + :class="{ 'faint': shouldBeDisabled }" + > + {{ backendDescriptionDescription + ' ' }} + </p> + </label> +</template> + +<script src="./string_setting.js"></script> diff --git a/src/components/settings_modal/settings_modal.js b/src/components/settings_modal/settings_modal.js index 0a72dca1..ff58f2c3 100644 --- a/src/components/settings_modal/settings_modal.js +++ b/src/components/settings_modal/settings_modal.js @@ -5,7 +5,7 @@ import getResettableAsyncComponent from 'src/services/resettable_async_component import Popover from '../popover/popover.vue' import Checkbox from 'src/components/checkbox/checkbox.vue' import { library } from '@fortawesome/fontawesome-svg-core' -import { cloneDeep } from 'lodash' +import { cloneDeep, isEqual } from 'lodash' import { newImporter, newExporter @@ -53,8 +53,16 @@ const SettingsModal = { Modal, Popover, Checkbox, - SettingsModalContent: getResettableAsyncComponent( - () => import('./settings_modal_content.vue'), + SettingsModalUserContent: getResettableAsyncComponent( + () => import('./settings_modal_user_content.vue'), + { + loadingComponent: PanelLoading, + errorComponent: AsyncComponentError, + delay: 0 + } + ), + SettingsModalAdminContent: getResettableAsyncComponent( + () => import('./settings_modal_admin_content.vue'), { loadingComponent: PanelLoading, errorComponent: AsyncComponentError, @@ -147,6 +155,12 @@ const SettingsModal = { PLEROMAFE_SETTINGS_MINOR_VERSION ] return clone + }, + resetAdminDraft () { + this.$store.commit('resetAdminDraft') + }, + pushAdminDraft () { + this.$store.dispatch('pushAdminDraft') } }, computed: { @@ -156,8 +170,14 @@ const SettingsModal = { modalActivated () { return this.$store.state.interface.settingsModalState !== 'hidden' }, - modalOpenedOnce () { - return this.$store.state.interface.settingsModalLoaded + modalMode () { + return this.$store.state.interface.settingsModalMode + }, + modalOpenedOnceUser () { + return this.$store.state.interface.settingsModalLoadedUser + }, + modalOpenedOnceAdmin () { + return this.$store.state.interface.settingsModalLoadedAdmin }, modalPeeked () { return this.$store.state.interface.settingsModalState === 'minimized' @@ -167,9 +187,14 @@ const SettingsModal = { return this.$store.state.config.expertLevel > 0 }, set (value) { - console.log(value) this.$store.dispatch('setOption', { name: 'expertLevel', value: value ? 1 : 0 }) } + }, + adminDraftAny () { + return !isEqual( + this.$store.state.adminSettings.config, + this.$store.state.adminSettings.draft + ) } } } diff --git a/src/components/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss index f5861229..49ef83e0 100644 --- a/src/components/settings_modal/settings_modal.scss +++ b/src/components/settings_modal/settings_modal.scss @@ -17,6 +17,12 @@ } } + .setting-description { + margin-top: 0.2em; + margin-bottom: 2em; + font-size: 70%; + } + .settings-modal-panel { overflow: hidden; transition: transform; @@ -37,7 +43,9 @@ .btn { min-height: 2em; - min-width: 10em; + } + + .btn:not(.dropdown-button) { padding: 0 2em; } } @@ -45,6 +53,8 @@ .settings-footer { display: flex; + flex-wrap: wrap; + line-height: 2; >* { margin-right: 0.5em; diff --git a/src/components/settings_modal/settings_modal.vue b/src/components/settings_modal/settings_modal.vue index 7b457371..57ec5535 100644 --- a/src/components/settings_modal/settings_modal.vue +++ b/src/components/settings_modal/settings_modal.vue @@ -8,7 +8,7 @@ <div class="settings-modal-panel panel"> <div class="panel-heading"> <span class="title"> - {{ $t('settings.settings') }} + {{ modalMode === 'user' ? $t('settings.settings') : $t('admin_dash.window_title') }} </span> <transition name="fade"> <div @@ -42,10 +42,12 @@ </button> </div> <div class="panel-body"> - <SettingsModalContent v-if="modalOpenedOnce" /> + <SettingsModalUserContent v-if="modalMode === 'user' && modalOpenedOnceUser" /> + <SettingsModalAdminContent v-if="modalMode === 'admin' && modalOpenedOnceAdmin" /> </div> - <div class="panel-footer settings-footer"> + <div class="panel-footer settings-footer -flexible-height"> <Popover + v-if="modalMode === 'user'" class="export" trigger="click" placement="top" @@ -107,10 +109,39 @@ > {{ $t("settings.expert_mode") }} </Checkbox> + <span v-if="modalMode === 'admin'"> + <i18n-t keypath="admin_dash.wip_notice"> + <template #adminFeLink> + <a + href="/pleroma/admin/#/login-pleroma" + target="_blank" + > + {{ $t("admin_dash.old_ui_link") }} + </a> + </template> + </i18n-t> + </span> <span id="unscrolled-content" class="extra-content" /> + <span class="admin-buttons" v-if="modalMode === 'admin'"> + <button + class="button-default btn" + @click="resetAdminDraft" + :disabled="!adminDraftAny" + > + {{ $t("admin_dash.reset_all") }} + </button> + {{ ' ' }} + <button + class="button-default btn" + @click="pushAdminDraft" + :disabled="!adminDraftAny" + > + {{ $t("admin_dash.commit_all") }} + </button> + </span> </div> </div> </Modal> diff --git a/src/components/settings_modal/settings_modal_admin_content.js b/src/components/settings_modal/settings_modal_admin_content.js new file mode 100644 index 00000000..f94721ec --- /dev/null +++ b/src/components/settings_modal/settings_modal_admin_content.js @@ -0,0 +1,93 @@ +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' + +import InstanceTab from './admin_tabs/instance_tab.vue' +import LimitsTab from './admin_tabs/limits_tab.vue' +import FrontendsTab from './admin_tabs/frontends_tab.vue' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faWrench, + faHand, + faLaptopCode, + faPaintBrush, + faBell, + faDownload, + faEyeSlash, + faInfo +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faWrench, + faHand, + faLaptopCode, + faPaintBrush, + faBell, + faDownload, + faEyeSlash, + faInfo +) + +const SettingsModalAdminContent = { + components: { + TabSwitcher, + + InstanceTab, + LimitsTab, + FrontendsTab + }, + computed: { + user () { + return this.$store.state.users.currentUser + }, + isLoggedIn () { + return !!this.$store.state.users.currentUser + }, + open () { + return this.$store.state.interface.settingsModalState !== 'hidden' + }, + bodyLock () { + return this.$store.state.interface.settingsModalState === 'visible' + }, + adminDbLoaded () { + return this.$store.state.adminSettings.loaded + }, + adminDescriptionsLoaded () { + return this.$store.state.adminSettings.descriptions !== null + }, + noDb () { + return this.$store.state.adminSettings.dbConfigEnabled === false + } + }, + created () { + if (this.user.rights.admin) { + this.$store.dispatch('loadAdminStuff') + } + }, + methods: { + onOpen () { + const targetTab = this.$store.state.interface.settingsModalTargetTab + // We're being told to open in specific tab + if (targetTab) { + const tabIndex = this.$refs.tabSwitcher.$slots.default().findIndex(elm => { + return elm.props && elm.props['data-tab-name'] === targetTab + }) + if (tabIndex >= 0) { + this.$refs.tabSwitcher.setTab(tabIndex) + } + } + // Clear the state of target tab, so that next time settings is opened + // it doesn't force it. + this.$store.dispatch('clearSettingsModalTargetTab') + } + }, + mounted () { + this.onOpen() + }, + watch: { + open: function (value) { + if (value) this.onOpen() + } + } +} + +export default SettingsModalAdminContent diff --git a/src/components/settings_modal/settings_modal_content.scss b/src/components/settings_modal/settings_modal_admin_content.scss index 87df7982..87df7982 100644 --- a/src/components/settings_modal/settings_modal_content.scss +++ b/src/components/settings_modal/settings_modal_admin_content.scss diff --git a/src/components/settings_modal/settings_modal_admin_content.vue b/src/components/settings_modal/settings_modal_admin_content.vue new file mode 100644 index 00000000..a7a2ac9a --- /dev/null +++ b/src/components/settings_modal/settings_modal_admin_content.vue @@ -0,0 +1,68 @@ +<template> + <tab-switcher + v-if="adminDescriptionsLoaded && (noDb || adminDbLoaded)" + ref="tabSwitcher" + class="settings_tab-switcher" + :side-tab-bar="true" + :scrollable-tabs="true" + :render-only-focused="true" + :body-scroll-lock="bodyLock" + > + <div + v-if="noDb" + :label="$t('admin_dash.tabs.nodb')" + icon="exclamation-triangle" + data-tab-name="nodb-notice" + > + <div :label="$t('admin_dash.tabs.nodb')"> + <div class="setting-item"> + <h2>{{ $t('admin_dash.nodb.heading') }}</h2> + <i18n-t keypath="admin_dash.nodb.text"> + <template #documentation> + <a + href="https://docs-develop.pleroma.social/backend/configuration/howto_database_config/" + target="_blank" + > + {{ $t("admin_dash.nodb.documentation") }} + </a> + </template> + <template #property> + <code>config :pleroma, configurable_from_database</code> + </template> + <template #value> + <code>true</code> + </template> + </i18n-t> + <p>{{ $t('admin_dash.nodb.text2') }}</p> + </div> + </div> + </div> + <div + v-if="adminDbLoaded" + :label="$t('admin_dash.tabs.instance')" + icon="wrench" + data-tab-name="general" + > + <InstanceTab /> + </div> + <div + v-if="adminDbLoaded" + :label="$t('admin_dash.tabs.limits')" + icon="hand" + data-tab-name="limits" + > + <LimitsTab /> + </div> + <div + :label="$t('admin_dash.tabs.frontends')" + icon="laptop-code" + data-tab-name="frontends" + > + <FrontendsTab /> + </div> + </tab-switcher> +</template> + +<script src="./settings_modal_admin_content.js"></script> + +<style src="./settings_modal_admin_content.scss" lang="scss"></style> diff --git a/src/components/settings_modal/settings_modal_content.js b/src/components/settings_modal/settings_modal_user_content.js index 9ac0301f..9ac0301f 100644 --- a/src/components/settings_modal/settings_modal_content.js +++ b/src/components/settings_modal/settings_modal_user_content.js diff --git a/src/components/settings_modal/settings_modal_user_content.scss b/src/components/settings_modal/settings_modal_user_content.scss new file mode 100644 index 00000000..87df7982 --- /dev/null +++ b/src/components/settings_modal/settings_modal_user_content.scss @@ -0,0 +1,56 @@ +@import "src/variables"; + +.settings_tab-switcher { + height: 100%; + + .setting-item { + border-bottom: 2px solid var(--fg, $fallback--fg); + margin: 1em 1em 1.4em; + padding-bottom: 1.4em; + + > div, + > label { + display: block; + margin-bottom: 0.5em; + + &:last-child { + margin-bottom: 0; + } + } + + .select-multiple { + display: flex; + + .option-list { + margin: 0; + padding-left: 0.5em; + } + } + + &:last-child { + border-bottom: none; + padding-bottom: 0; + margin-bottom: 1em; + } + + select { + min-width: 10em; + } + + textarea { + width: 100%; + max-width: 100%; + height: 100px; + } + + .unavailable, + .unavailable svg { + color: var(--cRed, $fallback--cRed); + color: $fallback--cRed; + } + + .number-input { + max-width: 6em; + } + } +} diff --git a/src/components/settings_modal/settings_modal_content.vue b/src/components/settings_modal/settings_modal_user_content.vue index 0be76d22..0221cccb 100644 --- a/src/components/settings_modal/settings_modal_content.vue +++ b/src/components/settings_modal/settings_modal_user_content.vue @@ -78,6 +78,6 @@ </tab-switcher> </template> -<script src="./settings_modal_content.js"></script> +<script src="./settings_modal_user_content.js"></script> -<style src="./settings_modal_content.scss" lang="scss"></style> +<style src="./settings_modal_user_content.scss" lang="scss"></style> diff --git a/src/components/settings_modal/tabs/filtering_tab.vue b/src/components/settings_modal/tabs/filtering_tab.vue index 97046ff0..41d1b54f 100644 --- a/src/components/settings_modal/tabs/filtering_tab.vue +++ b/src/components/settings_modal/tabs/filtering_tab.vue @@ -7,13 +7,11 @@ <BooleanSetting path="hideFilteredStatuses"> {{ $t('settings.hide_filtered_statuses') }} </BooleanSetting> - <ul - class="setting-list suboptions" - :class="[{disabled: !streaming}]" - > + <ul class="setting-list suboptions"> <li> <BooleanSetting - :disabled="hideFilteredStatuses" + parent-path="hideFilteredStatuses" + :parent-invert="true" path="hideWordFilteredPosts" > {{ $t('settings.hide_wordfiltered_statuses') }} @@ -22,7 +20,8 @@ <li> <BooleanSetting v-if="user" - :disabled="hideFilteredStatuses" + parent-path="hideFilteredStatuses" + :parent-invert="true" path="hideMutedThreads" > {{ $t('settings.hide_muted_threads') }} @@ -31,7 +30,8 @@ <li> <BooleanSetting v-if="user" - :disabled="hideFilteredStatuses" + parent-path="hideFilteredStatuses" + :parent-invert="true" path="hideMutedPosts" > {{ $t('settings.hide_muted_posts') }} diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js index be97710f..3f2bcb13 100644 --- a/src/components/settings_modal/tabs/general_tab.js +++ b/src/components/settings_modal/tabs/general_tab.js @@ -7,7 +7,7 @@ import SizeSetting, { defaultHorizontalUnits } from '../helpers/size_setting.vue import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue' import SharedComputedObject from '../helpers/shared_computed_object.js' -import ServerSideIndicator from '../helpers/server_side_indicator.vue' +import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { faGlobe @@ -67,7 +67,7 @@ const GeneralTab = { SizeSetting, InterfaceLanguageSwitcher, ScopeSelector, - ServerSideIndicator + ProfileSettingIndicator }, computed: { horizontalUnits () { @@ -110,7 +110,7 @@ const GeneralTab = { }, methods: { changeDefaultScope (value) { - this.$store.dispatch('setServerSideOption', { name: 'defaultScope', value }) + this.$store.dispatch('setProfileOption', { name: 'defaultScope', value }) } } } diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue index 21e2d855..f56fa8e0 100644 --- a/src/components/settings_modal/tabs/general_tab.vue +++ b/src/components/settings_modal/tabs/general_tab.vue @@ -29,14 +29,11 @@ <BooleanSetting path="streaming"> {{ $t('settings.streaming') }} </BooleanSetting> - <ul - class="setting-list suboptions" - :class="[{disabled: !streaming}]" - > + <ul class="setting-list suboptions"> <li> <BooleanSetting path="pauseOnUnfocused" - :disabled="!streaming" + parent-path="streaming" > {{ $t('settings.pause_on_unfocused') }} </BooleanSetting> @@ -213,7 +210,7 @@ </ChoiceSetting> </li> <ul - v-if="conversationDisplay !== 'linear'" + v-if="mergedConfig.conversationDisplay !== 'linear'" class="setting-list suboptions" > <li> @@ -265,7 +262,8 @@ <li> <BooleanSetting v-if="user" - path="serverSide_stripRichContent" + source="profile" + path="stripRichContent" expert="1" > {{ $t('settings.no_rich_text_description') }} @@ -299,7 +297,7 @@ <BooleanSetting path="preloadImage" expert="1" - :disabled="!hideNsfw" + parent-path="hideNsfw" > {{ $t('settings.preload_images') }} </BooleanSetting> @@ -308,7 +306,7 @@ <BooleanSetting path="useOneClickNsfw" expert="1" - :disabled="!hideNsfw" + parent-path="hideNsfw" > {{ $t('settings.use_one_click_nsfw') }} </BooleanSetting> @@ -321,15 +319,13 @@ > {{ $t('settings.loop_video') }} </BooleanSetting> - <ul - class="setting-list suboptions" - :class="[{disabled: !streaming}]" - > + <ul class="setting-list suboptions"> <li> <BooleanSetting path="loopVideoSilentOnly" expert="1" - :disabled="!loopVideo || !loopSilentAvailable" + parent-path="loopVideo" + :disabled="!loopSilentAvailable" > {{ $t('settings.loop_video_silent_only') }} </BooleanSetting> @@ -427,18 +423,18 @@ <ul class="setting-list"> <li> <label for="default-vis"> - {{ $t('settings.default_vis') }} <ServerSideIndicator :server-side="true" /> + {{ $t('settings.default_vis') }} <ProfileSettingIndicator :is-profile="true" /> <ScopeSelector class="scope-selector" :show-all="true" - :user-default="serverSide_defaultScope" - :initial-scope="serverSide_defaultScope" + :user-default="$store.state.profileConfig.defaultScope" + :initial-scope="$store.state.profileConfig.defaultScope" :on-scope-change="changeDefaultScope" /> </label> </li> <li> - <!-- <BooleanSetting path="serverSide_defaultNSFW"> --> + <!-- <BooleanSetting source="profile" path="defaultNSFW"> --> <BooleanSetting path="sensitiveByDefault"> {{ $t('settings.sensitive_by_default') }} </BooleanSetting> diff --git a/src/components/settings_modal/tabs/notifications_tab.vue b/src/components/settings_modal/tabs/notifications_tab.vue index dd3806ed..fcb92135 100644 --- a/src/components/settings_modal/tabs/notifications_tab.vue +++ b/src/components/settings_modal/tabs/notifications_tab.vue @@ -4,7 +4,10 @@ <h2>{{ $t('settings.notification_setting_filters') }}</h2> <ul class="setting-list"> <li> - <BooleanSetting path="serverSide_blockNotificationsFromStrangers"> + <BooleanSetting + source="profile" + path="blockNotificationsFromStrangers" + > {{ $t('settings.notification_setting_block_from_strangers') }} </BooleanSetting> </li> @@ -67,7 +70,8 @@ </li> <li> <BooleanSetting - path="serverSide_webPushHideContents" + source="profile" + path="webPushHideContents" expert="1" > {{ $t('settings.notification_setting_hide_notification_contents') }} diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue index 6a5b478a..1cc850cb 100644 --- a/src/components/settings_modal/tabs/profile_tab.vue +++ b/src/components/settings_modal/tabs/profile_tab.vue @@ -254,37 +254,50 @@ <h2>{{ $t('settings.account_privacy') }}</h2> <ul class="setting-list"> <li> - <BooleanSetting path="serverSide_locked"> + <BooleanSetting + source="profile" + path="locked" + > {{ $t('settings.lock_account_description') }} </BooleanSetting> </li> <li> - <BooleanSetting path="serverSide_discoverable"> + <BooleanSetting + source="profile" + path="discoverable" + > {{ $t('settings.discoverable') }} </BooleanSetting> </li> <li> - <BooleanSetting path="serverSide_allowFollowingMove"> + <BooleanSetting + source="profile" + path="allowFollowingMove" + > {{ $t('settings.allow_following_move') }} </BooleanSetting> </li> <li> - <BooleanSetting path="serverSide_hideFavorites"> + <BooleanSetting + source="profile" + path="hideFavorites" + > {{ $t('settings.hide_favorites_description') }} </BooleanSetting> </li> <li> - <BooleanSetting path="serverSide_hideFollowers"> + <BooleanSetting + source="profile" + path="hideFollowers" + > {{ $t('settings.hide_followers_description') }} </BooleanSetting> - <ul - class="setting-list suboptions" - :class="[{disabled: !serverSide_hideFollowers}]" - > + <ul class="setting-list suboptions"> <li> <BooleanSetting - path="serverSide_hideFollowersCount" - :disabled="!serverSide_hideFollowers" + source="profile" + path="hideFollowersCount" + parent-path="hideFollowers" > {{ $t('settings.hide_followers_count_description') }} </BooleanSetting> @@ -292,17 +305,18 @@ </ul> </li> <li> - <BooleanSetting path="serverSide_hideFollows"> + <BooleanSetting + source="profile" + path="hideFollows" + > {{ $t('settings.hide_follows_description') }} </BooleanSetting> - <ul - class="setting-list suboptions" - :class="[{disabled: !serverSide_hideFollows}]" - > + <ul class="setting-list suboptions"> <li> <BooleanSetting - path="serverSide_hideFollowsCount" - :disabled="!serverSide_hideFollows" + source="profile" + path="hideFollowsCount" + parent-path="hideFollows" > {{ $t('settings.hide_follows_count_description') }} </BooleanSetting> diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.vue b/src/components/settings_modal/tabs/security_tab/security_tab.vue index 6e03bef4..d36d478f 100644 --- a/src/components/settings_modal/tabs/security_tab/security_tab.vue +++ b/src/components/settings_modal/tabs/security_tab/security_tab.vue @@ -143,8 +143,8 @@ /> </div> <div> - <i18n - path="settings.new_alias_target" + <i18n-t + keypath="settings.new_alias_target" tag="p" > <code @@ -152,7 +152,7 @@ > foo@example.org </code> - </i18n> + </i18n-t> <input v-model="addAliasTarget" > @@ -175,16 +175,16 @@ <h2>{{ $t('settings.move_account') }}</h2> <p>{{ $t('settings.move_account_notes') }}</p> <div> - <i18n - path="settings.move_account_target" + <i18n-t + keypath="settings.move_account_target" tag="p" > - <code - place="example" - > - foo@example.org - </code> - </i18n> + <template #example> + <code> + foo@example.org + </code> + </template> + </i18n-t> <input v-model="moveAccountTarget" > diff --git a/src/components/tab_switcher/tab_switcher.jsx b/src/components/tab_switcher/tab_switcher.jsx index a7ef8560..b444da43 100644 --- a/src/components/tab_switcher/tab_switcher.jsx +++ b/src/components/tab_switcher/tab_switcher.jsx @@ -60,13 +60,7 @@ export default { const isWanted = slot => slot.props && slot.props['data-tab-name'] === tabName return this.$slots.default().findIndex(isWanted) === this.activeIndex } - }, - settingsModalVisible () { - return this.settingsModalState === 'visible' - }, - ...mapState({ - settingsModalState: state => state.interface.settingsModalState - }) + } }, beforeUpdate () { const currentSlot = this.slots()[this.active] diff --git a/src/i18n/en.json b/src/i18n/en.json index b051f088..f4c2df3e 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -830,6 +830,66 @@ "title": "Version", "backend_version": "Backend version", "frontend_version": "Frontend version" + }, + "commit_value": "Save", + "commit_value_tooltip": "Value is not saved, press this button to commit your changes", + "reset_value": "Reset", + "reset_value_tooltip": "Reset draft", + "hard_reset_value": "Hard reset", + "hard_reset_value_tooltip": "Remove setting from storage, forcing use of default value" + }, + "admin_dash": { + "window_title": "Administration", + "wip_notice": "This admin dashboard is experimental and WIP, {adminFeLink}.", + "old_ui_link": "old admin UI available here", + "reset_all": "Reset all", + "commit_all": "Save all", + "tabs": { + "nodb": "No DB Config", + "instance": "Instance", + "limits": "Limits", + "frontends": "Front-ends" + }, + "nodb": { + "heading": "Database config is disabled", + "text": "You need to change backend config files so that {property} is set to {value}, see more in {documentation}.", + "documentation": "documentation", + "text2": "Most configuration options will be unavailable." + }, + "captcha": { + "native": "Native", + "kocaptcha": "KoCaptcha" + }, + "instance": { + "instance": "Instance information", + "registrations": "User sign-ups", + "captcha_header": "CAPTCHA", + "kocaptcha": "KoCaptcha settings" + }, + "limits": { + "arbitrary_limits": "Arbitrary limits", + "posts": "Post limits", + "uploads": "Attachments limits", + "users": "User profile limits", + "profile_fields": "Profile fields limits", + "user_uploads": "Profile media limits" + }, + "frontend": { + "repository": "Repository link", + "versions": "Available versions", + "build_url": "Build URL", + "reinstall": "Reinstall", + "is_default": "(Default)", + "is_default_custom": "(Default, version: {version})", + "install": "Install", + "install_version": "Install version {version}", + "set_default": "Set default", + "set_default_version": "Set version {version} as default", + "wip_notice": "Please note that this section is a WIP and lacks certain features as backend implementation of front-end management is incomplete.", + "default_frontend": "Default front-end", + "default_frontend_tip": "Default front-end will be shown to all users. Currently there's no way to for a user to select personal front-end. If you switch away from PleromaFE you'll most likely have to use old and buggy AdminFE to do instance configuration until we replace it.", + "default_frontend_tip2": "WIP: Since Pleroma backend doesn't properly list all installed frontends you'll have to enter name and reference manually. List below provides shortcuts to fill the values.", + "available_frontends": "Available for install" } }, "time": { diff --git a/src/main.js b/src/main.js index d3e60a0f..71f19a82 100644 --- a/src/main.js +++ b/src/main.js @@ -10,8 +10,9 @@ import listsModule from './modules/lists.js' import usersModule from './modules/users.js' import apiModule from './modules/api.js' import configModule from './modules/config.js' -import serverSideConfigModule from './modules/serverSideConfig.js' +import profileConfigModule from './modules/profileConfig.js' import serverSideStorageModule from './modules/serverSideStorage.js' +import adminSettingsModule from './modules/adminSettings.js' import shoutModule from './modules/shout.js' import oauthModule from './modules/oauth.js' import authFlowModule from './modules/auth_flow.js' @@ -80,8 +81,9 @@ const persistedStateOptions = { lists: listsModule, api: apiModule, config: configModule, - serverSideConfig: serverSideConfigModule, + profileConfig: profileConfigModule, serverSideStorage: serverSideStorageModule, + adminSettings: adminSettingsModule, shout: shoutModule, oauth: oauthModule, authFlow: authFlowModule, diff --git a/src/modules/adminSettings.js b/src/modules/adminSettings.js new file mode 100644 index 00000000..cad9c0ca --- /dev/null +++ b/src/modules/adminSettings.js @@ -0,0 +1,230 @@ +import { set, get, cloneDeep, differenceWith, isEqual, flatten } from 'lodash' + +export const defaultState = { + frontends: [], + loaded: false, + needsReboot: null, + config: null, + modifiedPaths: null, + descriptions: null, + draft: null, + dbConfigEnabled: null +} + +export const newUserFlags = { + ...defaultState.flagStorage +} + +const adminSettingsStorage = { + state: { + ...cloneDeep(defaultState) + }, + mutations: { + setInstanceAdminNoDbConfig (state) { + state.loaded = false + state.dbConfigEnabled = false + }, + setAvailableFrontends (state, { frontends }) { + state.frontends = frontends.map(f => { + if (f.name === 'pleroma-fe') { + f.refs = ['master', 'develop'] + } else { + f.refs = [f.ref] + } + return f + }) + }, + updateAdminSettings (state, { config, modifiedPaths }) { + state.loaded = true + state.dbConfigEnabled = true + state.config = config + state.modifiedPaths = modifiedPaths + }, + updateAdminDescriptions (state, { descriptions }) { + state.descriptions = descriptions + }, + updateAdminDraft (state, { path, value }) { + const [group, key, subkey] = path + const parent = [group, key, subkey] + + set(state.draft, path, value) + + // force-updating grouped draft to trigger refresh of group settings + if (path.length > parent.length) { + set(state.draft, parent, cloneDeep(get(state.draft, parent))) + } + }, + resetAdminDraft (state) { + state.draft = cloneDeep(state.config) + } + }, + actions: { + loadFrontendsStuff ({ state, rootState, dispatch, commit }) { + rootState.api.backendInteractor.fetchAvailableFrontends() + .then(frontends => commit('setAvailableFrontends', { frontends })) + }, + loadAdminStuff ({ state, rootState, dispatch, commit }) { + rootState.api.backendInteractor.fetchInstanceDBConfig() + .then(backendDbConfig => { + if (backendDbConfig.error) { + if (backendDbConfig.error.status === 400) { + backendDbConfig.error.json().then(errorJson => { + if (/configurable_from_database/.test(errorJson.error)) { + commit('setInstanceAdminNoDbConfig') + } + }) + } + } else { + dispatch('setInstanceAdminSettings', { backendDbConfig }) + } + }) + if (state.descriptions === null) { + rootState.api.backendInteractor.fetchInstanceConfigDescriptions() + .then(backendDescriptions => dispatch('setInstanceAdminDescriptions', { backendDescriptions })) + } + }, + setInstanceAdminSettings ({ state, commit, dispatch }, { backendDbConfig }) { + const config = state.config || {} + const modifiedPaths = new Set() + backendDbConfig.configs.forEach(c => { + const path = [c.group, c.key] + if (c.db) { + // Path elements can contain dot, therefore we use ' -> ' as a separator instead + // Using strings for modified paths for easier searching + c.db.forEach(x => modifiedPaths.add([...path, x].join(' -> '))) + } + const convert = (value) => { + if (Array.isArray(value) && value.length > 0 && value[0].tuple) { + return value.reduce((acc, c) => { + return { ...acc, [c.tuple[0]]: convert(c.tuple[1]) } + }, {}) + } else { + return value + } + } + set(config, path, convert(c.value)) + }) + console.log(config[':pleroma']) + commit('updateAdminSettings', { config, modifiedPaths }) + commit('resetAdminDraft') + }, + setInstanceAdminDescriptions ({ state, commit, dispatch }, { backendDescriptions }) { + const convert = ({ children, description, label, key = '<ROOT>', group, suggestions }, path, acc) => { + const newPath = group ? [group, key] : [key] + const obj = { description, label, suggestions } + if (Array.isArray(children)) { + children.forEach(c => { + convert(c, newPath, obj) + }) + } + set(acc, newPath, obj) + } + + const descriptions = {} + backendDescriptions.forEach(d => convert(d, '', descriptions)) + console.log(descriptions[':pleroma']['Pleroma.Captcha']) + commit('updateAdminDescriptions', { descriptions }) + }, + + // This action takes draft state, diffs it with live config state and then pushes + // only differences between the two. Difference detection only work up to subkey (third) level. + pushAdminDraft ({ rootState, state, commit, dispatch }) { + // TODO cleanup paths in modifiedPaths + const convert = (value) => { + if (typeof value !== 'object') { + return value + } else if (Array.isArray(value)) { + return value.map(convert) + } else { + return Object.entries(value).map(([k, v]) => ({ tuple: [k, v] })) + } + } + + // Getting all group-keys used in config + const allGroupKeys = flatten( + Object + .entries(state.config) + .map( + ([group, lv1data]) => Object + .keys(lv1data) + .map((key) => ({ group, key })) + ) + ) + + // Only using group-keys where there are changes detected + const changedGroupKeys = allGroupKeys.filter(({ group, key }) => { + return !isEqual(state.config[group][key], state.draft[group][key]) + }) + + // Here we take all changed group-keys and get all changed subkeys + const changed = changedGroupKeys.map(({ group, key }) => { + const config = state.config[group][key] + const draft = state.draft[group][key] + + // We convert group-key value into entries arrays + const eConfig = Object.entries(config) + const eDraft = Object.entries(draft) + + // Then those entries array we diff so only changed subkey entries remain + // We use the diffed array to reconstruct the object and then shove it into convert() + return ({ group, key, value: convert(Object.fromEntries(differenceWith(eDraft, eConfig, isEqual))) }) + }) + + rootState.api.backendInteractor.pushInstanceDBConfig({ + payload: { + configs: changed + } + }) + .then(() => rootState.api.backendInteractor.fetchInstanceDBConfig()) + .then(backendDbConfig => dispatch('setInstanceAdminSettings', { backendDbConfig })) + }, + pushAdminSetting ({ rootState, state, commit, dispatch }, { path, value }) { + const [group, key, ...rest] = Array.isArray(path) ? path : path.split(/\./g) + const clone = {} // not actually cloning the entire thing to avoid excessive writes + set(clone, rest, value) + + // TODO cleanup paths in modifiedPaths + const convert = (value) => { + if (typeof value !== 'object') { + return value + } else if (Array.isArray(value)) { + return value.map(convert) + } else { + return Object.entries(value).map(([k, v]) => ({ tuple: [k, v] })) + } + } + + rootState.api.backendInteractor.pushInstanceDBConfig({ + payload: { + configs: [{ + group, + key, + value: convert(clone) + }] + } + }) + .then(() => rootState.api.backendInteractor.fetchInstanceDBConfig()) + .then(backendDbConfig => dispatch('setInstanceAdminSettings', { backendDbConfig })) + }, + resetAdminSetting ({ rootState, state, commit, dispatch }, { path }) { + const [group, key, subkey] = path.split(/\./g) + + state.modifiedPaths.delete(path) + + return rootState.api.backendInteractor.pushInstanceDBConfig({ + payload: { + configs: [{ + group, + key, + delete: true, + subkeys: [subkey] + }] + } + }) + .then(() => rootState.api.backendInteractor.fetchInstanceDBConfig()) + .then(backendDbConfig => dispatch('setInstanceAdminSettings', { backendDbConfig })) + } + } +} + +export default adminSettingsStorage diff --git a/src/modules/config.js b/src/modules/config.js index 7597886e..56f8cba5 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -1,6 +1,7 @@ import Cookies from 'js-cookie' import { setPreset, applyTheme, applyConfig } from '../services/style_setter/style_setter.js' import messages from '../i18n/messages' +import { set } from 'lodash' import localeService from '../services/locale/locale.service.js' const BACKEND_LANGUAGE_COOKIE_NAME = 'userLanguage' @@ -148,7 +149,7 @@ const config = { }, mutations: { setOption (state, { name, value }) { - state[name] = value + set(state, name, value) }, setHighlight (state, { user, color, type }) { const data = this.state.config.highlight[user] @@ -178,32 +179,52 @@ const config = { commit('setHighlight', { user, color, type }) }, setOption ({ commit, dispatch, state }, { name, value }) { - commit('setOption', { name, value }) - switch (name) { - case 'theme': - setPreset(value) - break - case 'sidebarColumnWidth': - case 'contentColumnWidth': - case 'notifsColumnWidth': - case 'emojiReactionsScale': - applyConfig(state) - break - case 'customTheme': - case 'customThemeSource': - applyTheme(value) - break - case 'interfaceLanguage': - messages.setLanguage(this.getters.i18n, value) - dispatch('loadUnicodeEmojiData', value) - Cookies.set( - BACKEND_LANGUAGE_COOKIE_NAME, - localeService.internalToBackendLocaleMulti(value) - ) - break - case 'thirdColumnMode': - dispatch('setLayoutWidth', undefined) - break + const exceptions = new Set([ + 'useStreamingApi' + ]) + + if (exceptions.has(name)) { + switch (name) { + case 'useStreamingApi': { + const action = value ? 'enableMastoSockets' : 'disableMastoSockets' + + dispatch(action).then(() => { + commit('setOption', { name: 'useStreamingApi', value }) + }).catch((e) => { + console.error('Failed starting MastoAPI Streaming socket', e) + dispatch('disableMastoSockets') + dispatch('setOption', { name: 'useStreamingApi', value: false }) + }) + } + } + } else { + commit('setOption', { name, value }) + switch (name) { + case 'theme': + setPreset(value) + break + case 'sidebarColumnWidth': + case 'contentColumnWidth': + case 'notifsColumnWidth': + case 'emojiReactionsScale': + applyConfig(state) + break + case 'customTheme': + case 'customThemeSource': + applyTheme(value) + break + case 'interfaceLanguage': + messages.setLanguage(this.getters.i18n, value) + dispatch('loadUnicodeEmojiData', value) + Cookies.set( + BACKEND_LANGUAGE_COOKIE_NAME, + localeService.internalToBackendLocaleMulti(value) + ) + break + case 'thirdColumnMode': + dispatch('setLayoutWidth', undefined) + break + } } } } diff --git a/src/modules/interface.js b/src/modules/interface.js index a86193ea..2d012f1b 100644 --- a/src/modules/interface.js +++ b/src/modules/interface.js @@ -1,7 +1,9 @@ const defaultState = { settingsModalState: 'hidden', - settingsModalLoaded: false, + settingsModalLoadedUser: false, + settingsModalLoadedAdmin: false, settingsModalTargetTab: null, + settingsModalMode: 'user', settings: { currentSaveStateNotice: null, noticeClearTimeout: null, @@ -54,10 +56,17 @@ const interfaceMod = { throw new Error('Illegal minimization state of settings modal') } }, - openSettingsModal (state) { + openSettingsModal (state, value) { + state.settingsModalMode = value state.settingsModalState = 'visible' - if (!state.settingsModalLoaded) { - state.settingsModalLoaded = true + if (value === 'user') { + if (!state.settingsModalLoadedUser) { + state.settingsModalLoadedUser = true + } + } else if (value === 'admin') { + if (!state.settingsModalLoadedAdmin) { + state.settingsModalLoadedAdmin = true + } } }, setSettingsModalTargetTab (state, value) { @@ -92,8 +101,8 @@ const interfaceMod = { closeSettingsModal ({ commit }) { commit('closeSettingsModal') }, - openSettingsModal ({ commit }) { - commit('openSettingsModal') + openSettingsModal ({ commit }, value = 'user') { + commit('openSettingsModal', value) }, togglePeekSettingsModal ({ commit }) { commit('togglePeekSettingsModal') diff --git a/src/modules/serverSideConfig.js b/src/modules/profileConfig.js index 476263bc..2cb2014a 100644 --- a/src/modules/serverSideConfig.js +++ b/src/modules/profileConfig.js @@ -22,9 +22,9 @@ const notificationsApi = ({ rootState, commit }, { path, value, oldValue }) => { .updateNotificationSettings({ settings }) .then(result => { if (result.status === 'success') { - commit('confirmServerSideOption', { name, value }) + commit('confirmProfileOption', { name, value }) } else { - commit('confirmServerSideOption', { name, value: oldValue }) + commit('confirmProfileOption', { name, value: oldValue }) } }) } @@ -94,16 +94,16 @@ export const settingsMap = { export const defaultState = Object.fromEntries(Object.keys(settingsMap).map(key => [key, null])) -const serverSideConfig = { +const profileConfig = { state: { ...defaultState }, mutations: { - confirmServerSideOption (state, { name, value }) { + confirmProfileOption (state, { name, value }) { set(state, name, value) }, - wipeServerSideOption (state, { name }) { + wipeProfileOption (state, { name }) { set(state, name, null) }, - wipeAllServerSideOptions (state) { + wipeAllProfileOptions (state) { Object.keys(settingsMap).forEach(key => { set(state, key, null) }) @@ -118,23 +118,23 @@ const serverSideConfig = { } }, actions: { - setServerSideOption ({ rootState, state, commit, dispatch }, { name, value }) { + setProfileOption ({ rootState, state, commit, dispatch }, { name, value }) { const oldValue = get(state, name) const map = settingsMap[name] if (!map) throw new Error('Invalid server-side setting') const { set: path = map, api = defaultApi } = map - commit('wipeServerSideOption', { name }) + commit('wipeProfileOption', { name }) api({ rootState, commit }, { path, value, oldValue }) .catch((e) => { console.warn('Error setting server-side option:', e) - commit('confirmServerSideOption', { name, value: oldValue }) + commit('confirmProfileOption', { name, value: oldValue }) }) }, logout ({ commit }) { - commit('wipeAllServerSideOptions') + commit('wipeAllProfileOptions') } } } -export default serverSideConfig +export default profileConfig diff --git a/src/modules/users.js b/src/modules/users.js index 7b41fab6..e976d875 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -577,6 +577,7 @@ const users = { loginUser (store, accessToken) { return new Promise((resolve, reject) => { const commit = store.commit + const dispatch = store.dispatch commit('beginLogin') store.rootState.api.backendInteractor.verifyCredentials(accessToken) .then((data) => { @@ -591,57 +592,57 @@ const users = { commit('setServerSideStorage', user) commit('addNewUsers', [user]) - store.dispatch('fetchEmoji') + dispatch('fetchEmoji') getNotificationPermission() .then(permission => commit('setNotificationPermission', permission)) // Set our new backend interactor commit('setBackendInteractor', backendInteractorService(accessToken)) - store.dispatch('pushServerSideStorage') + dispatch('pushServerSideStorage') if (user.token) { - store.dispatch('setWsToken', user.token) + dispatch('setWsToken', user.token) // Initialize the shout socket. - store.dispatch('initializeSocket') + dispatch('initializeSocket') } const startPolling = () => { // Start getting fresh posts. - store.dispatch('startFetchingTimeline', { timeline: 'friends' }) + dispatch('startFetchingTimeline', { timeline: 'friends' }) // Start fetching notifications - store.dispatch('startFetchingNotifications') + dispatch('startFetchingNotifications') // Start fetching chats - store.dispatch('startFetchingChats') + dispatch('startFetchingChats') } - store.dispatch('startFetchingLists') + dispatch('startFetchingLists') if (user.locked) { - store.dispatch('startFetchingFollowRequests') + dispatch('startFetchingFollowRequests') } if (store.getters.mergedConfig.useStreamingApi) { - store.dispatch('fetchTimeline', { timeline: 'friends', since: null }) - store.dispatch('fetchNotifications', { since: null }) - store.dispatch('enableMastoSockets', true).catch((error) => { + dispatch('fetchTimeline', { timeline: 'friends', since: null }) + dispatch('fetchNotifications', { since: null }) + dispatch('enableMastoSockets', true).catch((error) => { console.error('Failed initializing MastoAPI Streaming socket', error) }).then(() => { - store.dispatch('fetchChats', { latest: true }) - setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000) + dispatch('fetchChats', { latest: true }) + setTimeout(() => dispatch('setNotificationsSilence', false), 10000) }) } else { startPolling() } // Get user mutes - store.dispatch('fetchMutes') + dispatch('fetchMutes') - store.dispatch('setLayoutWidth', windowWidth()) - store.dispatch('setLayoutHeight', windowHeight()) + dispatch('setLayoutWidth', windowWidth()) + dispatch('setLayoutHeight', windowHeight()) // Fetch our friends store.rootState.api.backendInteractor.fetchFriends({ id: user.id }) diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index e90723a1..ac715678 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -108,6 +108,11 @@ const PLEROMA_POST_ANNOUNCEMENT_URL = '/api/v1/pleroma/admin/announcements' const PLEROMA_EDIT_ANNOUNCEMENT_URL = id => `/api/v1/pleroma/admin/announcements/${id}` const PLEROMA_DELETE_ANNOUNCEMENT_URL = id => `/api/v1/pleroma/admin/announcements/${id}` +const PLEROMA_ADMIN_CONFIG_URL = '/api/pleroma/admin/config' +const PLEROMA_ADMIN_DESCRIPTIONS_URL = '/api/pleroma/admin/config/descriptions' +const PLEROMA_ADMIN_FRONTENDS_URL = '/api/pleroma/admin/frontends' +const PLEROMA_ADMIN_FRONTENDS_INSTALL_URL = '/api/pleroma/admin/frontends/install' + const oldfetch = window.fetch const fetch = (url, options) => { @@ -1668,6 +1673,94 @@ const setReportState = ({ id, state, credentials }) => { }) } +// ADMIN STUFF // EXPERIMENTAL +const fetchInstanceDBConfig = ({ credentials }) => { + return fetch(PLEROMA_ADMIN_CONFIG_URL, { + headers: authHeaders(credentials) + }) + .then((response) => { + if (response.ok) { + return response.json() + } else { + return { + error: response + } + } + }) +} + +const fetchInstanceConfigDescriptions = ({ credentials }) => { + return fetch(PLEROMA_ADMIN_DESCRIPTIONS_URL, { + headers: authHeaders(credentials) + }) + .then((response) => { + if (response.ok) { + return response.json() + } else { + return { + error: response + } + } + }) +} + +const fetchAvailableFrontends = ({ credentials }) => { + return fetch(PLEROMA_ADMIN_FRONTENDS_URL, { + headers: authHeaders(credentials) + }) + .then((response) => { + if (response.ok) { + return response.json() + } else { + return { + error: response + } + } + }) +} + +const pushInstanceDBConfig = ({ credentials, payload }) => { + return fetch(PLEROMA_ADMIN_CONFIG_URL, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...authHeaders(credentials) + }, + method: 'POST', + body: JSON.stringify(payload) + }) + .then((response) => { + if (response.ok) { + return response.json() + } else { + return { + error: response + } + } + }) +} + +const installFrontend = ({ credentials, payload }) => { + return fetch(PLEROMA_ADMIN_FRONTENDS_INSTALL_URL, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...authHeaders(credentials) + }, + method: 'POST', + body: JSON.stringify(payload) + }) + .then((response) => { + if (response.ok) { + return response.json() + } else { + return { + error: response + } + } + }) +} + const apiService = { verifyCredentials, fetchTimeline, @@ -1781,7 +1874,12 @@ const apiService = { postAnnouncement, editAnnouncement, deleteAnnouncement, - adminFetchAnnouncements + adminFetchAnnouncements, + fetchInstanceDBConfig, + fetchInstanceConfigDescriptions, + fetchAvailableFrontends, + pushInstanceDBConfig, + installFrontend } export default apiService |
