logo

pleroma-fe

My custom branche(s) on git.pleroma.social/pleroma/pleroma-fe git clone https://hacktivis.me/git/pleroma-fe.git
commit: c730c9b6d0fac2b79e0fa5900117c66d073e4a9d
parent 4bf085b8feb81ad52bad6cb8979786df22baba9e
Author: HJ <30-hj@users.noreply.git.pleroma.social>
Date:   Wed, 24 May 2023 18:55:20 +0000

Merge branch 'improve_settings_reusability' into 'develop'

AdminFE functionality in PleromaFE

See merge request pleroma/pleroma-fe!1800

Diffstat:

Achangelog.d/adminfe.add1+
Msrc/App.scss23++++++++++++++++++++---
Msrc/components/checkbox/checkbox.vue28++++++++++++++++++++++++++--
Msrc/components/desktop_nav/desktop_nav.js5++++-
Msrc/components/desktop_nav/desktop_nav.vue9++++-----
Msrc/components/interface_language_switcher/interface_language_switcher.vue4+++-
Msrc/components/media_upload/media_upload.js18++++++++++++++----
Msrc/components/media_upload/media_upload.vue21+++++++++++++++------
Msrc/components/notification/notification.vue2+-
Msrc/components/popover/popover.js3+++
Msrc/components/popover/popover.vue3++-
Asrc/components/settings_modal/admin_tabs/frontends_tab.js64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/settings_modal/admin_tabs/frontends_tab.scss13+++++++++++++
Asrc/components/settings_modal/admin_tabs/frontends_tab.vue184+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/settings_modal/admin_tabs/instance_tab.js38++++++++++++++++++++++++++++++++++++++
Asrc/components/settings_modal/admin_tabs/instance_tab.vue196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/settings_modal/admin_tabs/limits_tab.js29+++++++++++++++++++++++++++++
Asrc/components/settings_modal/admin_tabs/limits_tab.vue136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/settings_modal/helpers/attachment_setting.js43+++++++++++++++++++++++++++++++++++++++++++
Asrc/components/settings_modal/helpers/attachment_setting.vue96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/settings_modal/helpers/boolean_setting.js63+++++++++++++++++++--------------------------------------------
Msrc/components/settings_modal/helpers/boolean_setting.vue34++++++++++++++++++++++++----------
Msrc/components/settings_modal/helpers/choice_setting.js68+++++++++++++++++++++++++++++---------------------------------------
Msrc/components/settings_modal/helpers/choice_setting.vue20++++++++++++++++----
Asrc/components/settings_modal/helpers/draft_buttons.vue88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/settings_modal/helpers/group_setting.js13+++++++++++++
Asrc/components/settings_modal/helpers/group_setting.vue15+++++++++++++++
Msrc/components/settings_modal/helpers/number_setting.js62+++++++++++++++-----------------------------------------------
Msrc/components/settings_modal/helpers/number_setting.vue26++++++++++++++++++++++----
Asrc/components/settings_modal/helpers/profile_setting_indicator.vue51+++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/components/settings_modal/helpers/server_side_indicator.vue51---------------------------------------------------
Asrc/components/settings_modal/helpers/setting.js237+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/settings_modal/helpers/shared_computed_object.js56+++++++++++---------------------------------------------
Msrc/components/settings_modal/helpers/size_setting.js51++++++++++++---------------------------------------
Msrc/components/settings_modal/helpers/size_setting.vue19+++++++++++++------
Asrc/components/settings_modal/helpers/string_setting.js5+++++
Asrc/components/settings_modal/helpers/string_setting.vue42++++++++++++++++++++++++++++++++++++++++++
Msrc/components/settings_modal/settings_modal.js37+++++++++++++++++++++++++++++++------
Msrc/components/settings_modal/settings_modal.scss12+++++++++++-
Msrc/components/settings_modal/settings_modal.vue40+++++++++++++++++++++++++++++++++++++---
Asrc/components/settings_modal/settings_modal_admin_content.js93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/settings_modal/settings_modal_admin_content.scss52++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/settings_modal/settings_modal_admin_content.vue68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/components/settings_modal/settings_modal_content.scss56--------------------------------------------------------
Dsrc/components/settings_modal/settings_modal_content.vue83-------------------------------------------------------------------------------
Rsrc/components/settings_modal/settings_modal_content.js -> src/components/settings_modal/settings_modal_user_content.js0
Asrc/components/settings_modal/settings_modal_user_content.scss52++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/settings_modal/settings_modal_user_content.vue83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/settings_modal/tabs/filtering_tab.vue14+++++++-------
Msrc/components/settings_modal/tabs/general_tab.js6+++---
Msrc/components/settings_modal/tabs/general_tab.vue32++++++++++++++------------------
Msrc/components/settings_modal/tabs/notifications_tab.vue8++++++--
Msrc/components/settings_modal/tabs/profile_tab.vue50++++++++++++++++++++++++++++++++------------------
Msrc/components/settings_modal/tabs/security_tab/security_tab.vue22+++++++++++-----------
Msrc/components/side_drawer/side_drawer.js5++++-
Msrc/components/side_drawer/side_drawer.vue8++++----
Msrc/components/tab_switcher/tab_switcher.jsx8+-------
Msrc/i18n/en.json94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main.js6++++--
Asrc/modules/adminSettings.js230+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/modules/config.js75++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/modules/interface.js21+++++++++++++++------
Asrc/modules/profileConfig.js140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/modules/serverSideConfig.js140-------------------------------------------------------------------------------
Msrc/modules/users.js35++++++++++++++++++-----------------
Msrc/services/api/api.service.js100++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/services/file_type/file_type.service.js18++++++++++++++++--
67 files changed, 2677 insertions(+), 728 deletions(-)

diff --git a/changelog.d/adminfe.add b/changelog.d/adminfe.add @@ -0,0 +1 @@ +Implemented a very basic instance administration screen diff --git 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/checkbox/checkbox.vue b/src/components/checkbox/checkbox.vue @@ -1,7 +1,7 @@ <template> <label class="checkbox" - :class="{ disabled, indeterminate }" + :class="{ disabled, indeterminate, 'indeterminate-fix': indeterminateTransitionFix }" > <input type="checkbox" @@ -14,6 +14,7 @@ <i class="checkbox-indicator" :aria-hidden="true" + @transitionend.capture="onTransitionEnd" /> <span v-if="!!$slots.default" @@ -31,7 +32,24 @@ export default { 'indeterminate', 'disabled' ], - emits: ['update:modelValue'] + emits: ['update:modelValue'], + data: (vm) => ({ + indeterminateTransitionFix: vm.indeterminate + }), + watch: { + indeterminate (e) { + if (e) { + this.indeterminateTransitionFix = true + } + } + }, + methods: { + onTransitionEnd (e) { + if (!this.indeterminate) { + this.indeterminateTransitionFix = false + } + } + } } </script> @@ -98,6 +116,12 @@ export default { } } + &.indeterminate-fix { + input[type="checkbox"] + .checkbox-indicator::before { + content: "–"; + } + } + & > span { margin-left: 0.5em; } diff --git 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 @@ -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 @@ -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/media_upload/media_upload.js b/src/components/media_upload/media_upload.js @@ -23,6 +23,11 @@ const mediaUpload = { } }, methods: { + onClick () { + if (this.uploadReady) { + this.$refs.input.click() + } + }, uploadFile (file) { const self = this const store = this.$store @@ -69,10 +74,15 @@ const mediaUpload = { this.multiUpload(target.files) } }, - props: [ - 'dropFiles', - 'disabled' - ], + props: { + dropFiles: Object, + disabled: Boolean, + normalButton: Boolean, + acceptTypes: { + type: String, + default: '*/*' + } + }, watch: { dropFiles: function (fileInfos) { if (!this.uploading) { diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue @@ -1,8 +1,9 @@ <template> - <label + <button class="media-upload" - :class="{ disabled: disabled }" + :class="[normalButton ? 'button-default btn' : 'button-unstyled', { disabled }]" :title="$t('tool_tip.media_upload')" + @click="onClick" > <FAIcon v-if="uploading" @@ -15,15 +16,21 @@ class="new-icon" icon="upload" /> + <template v-if="normalButton"> + {{ ' ' }} + {{ uploading ? $t('general.loading') : $t('tool_tip.media_upload') }} + </template> <input v-if="uploadReady" + ref="input" class="hidden-input-file" :disabled="disabled" type="file" multiple="true" + :accept="acceptTypes" @change="change" > - </label> + </button> </template> <script src="./media_upload.js"></script> @@ -32,10 +39,12 @@ @import "../../variables"; .media-upload { - cursor: pointer; // We use <label> for interactivity... i wonder if it's fine - .hidden-input-file { display: none; } } - </style> + +label.media-upload { + cursor: pointer; // We use <label> for interactivity... i wonder if it's fine +} +</style> diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue @@ -163,8 +163,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/popover/popover.js b/src/components/popover/popover.js @@ -45,6 +45,9 @@ const Popover = { // Lets hover popover stay when clicking inside of it stayOnClick: Boolean, + // Use styled button (to avoid nested buttons) + normalButton: Boolean, + triggerAttrs: { type: Object, default: {} diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue @@ -5,7 +5,8 @@ > <button ref="trigger" - class="button-unstyled popover-trigger-button" + class="popover-trigger-button" + :class="normalButton ? 'button-default btn' : 'button-unstyled'" type="button" v-bind="triggerAttrs" @click="onClick" diff --git a/src/components/settings_modal/admin_tabs/frontends_tab.js 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 @@ -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 @@ -0,0 +1,184 @@ +<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> + <ul class="setting-list"> + <li> + <StringSetting path=":pleroma.:frontends.:primary.name" /> + </li> + <li> + <StringSetting path=":pleroma.:frontends.:primary.ref" /> + </li> + <li> + <GroupSetting path=":pleroma.:frontends.:primary" /> + </li> + </ul> + </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 + v-if="adminDraft[':pleroma'][':frontends'][':primary'].ref === frontend.refs[0]" + keypath="admin_dash.frontend.is_default" + /> + <i18n-t + v-else + keypath="admin_dash.frontend.is_default_custom" + > + <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" + @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.more_install_options')" + > + <FAIcon icon="chevron-down" /> + </button> + </template> + </Popover> + </span> + <span + v-if="frontend.installed && frontend.name !== 'admin-fe'" + class="btn-group" + > + <button + class="button button-default btn" + type="button" + :disabled=" + adminDraft[':pleroma'][':frontends'][':primary'].name === frontend.name && + adminDraft[':pleroma'][':frontends'][':primary'].ref === frontend.refs[0] + " + @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.more_default_options')" + > + <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 @@ -0,0 +1,38 @@ +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 AttachmentSetting from '../helpers/attachment_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, + AttachmentSetting, + 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 @@ -0,0 +1,196 @@ +<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" /> + </li> + <li> + <StringSetting path=":pleroma.:instance.:email" /> + </li> + <li> + <StringSetting path=":pleroma.:instance.:description" /> + </li> + <li> + <StringSetting path=":pleroma.:instance.:short_description" /> + </li> + <li> + <AttachmentSetting path=":pleroma.:instance.:instance_thumbnail" /> + </li> + <li> + <AttachmentSetting path=":pleroma.:instance.:background_image" /> + </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" /> + <ul class="setting-list suboptions"> + <li> + <BooleanSetting + path=":pleroma.:instance.:invites_enabled" + parent-path=":pleroma.:instance.:registrations_open" + parent-invert + /> + </li> + </ul> + </li> + <li> + <BooleanSetting path=":pleroma.:instance.:birthday_required" /> + <ul class="setting-list suboptions"> + <li> + <IntegerSetting + path=":pleroma.:instance.:birthday_min_age" + parent-path=":pleroma.:instance.:birthday_required" + /> + </li> + </ul> + </li> + <li> + <BooleanSetting path=":pleroma.:instance.:account_activation_required" /> + </li> + <li> + <BooleanSetting path=":pleroma.:instance.:account_approval_required" /> + </li> + <li> + <h3>{{ $t('admin_dash.instance.captcha_header') }}</h3> + <ul class="setting-list"> + <li> + <BooleanSetting :path="[':pleroma', 'Pleroma.Captcha', ':enabled']" /> + <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') + }" + /> + <IntegerSetting + :path="[':pleroma', 'Pleroma.Captcha', ':seconds_valid']" + :parent-path="[':pleroma', 'Pleroma.Captcha', ':enabled']" + /> + </li> + <li + v-if="adminDraft[':pleroma']['Pleroma.Captcha'][':enabled'] && adminDraft[':pleroma']['Pleroma.Captcha'][':method'] === 'Pleroma.Captcha.Kocaptcha'" + > + <h4>{{ $t('admin_dash.instance.kocaptcha') }}</h4> + <ul class="setting-list"> + <li> + <StringSetting :path="[':pleroma', 'Pleroma.Captcha.Kocaptcha', ':endpoint']" /> + </li> + </ul> + </li> + </ul> + </li> + </ul> + </li> + </ul> + </div> + <div class="setting-item"> + <h2>{{ $t('admin_dash.instance.access') }}</h2> + <ul class="setting-list"> + <li> + <BooleanSetting + override-backend-description + override-backend-description-label + path=":pleroma.:instance.:public" + /> + </li> + <li> + <ChoiceSetting + override-backend-description + override-backend-description-label + path=":pleroma.:instance.:limit_to_local_content" + /> + </li> + <li v-if="expertLevel"> + <h3>{{ $t('admin_dash.instance.restrict.header') }}</h3> + <p> + {{ $t('admin_dash.instance.restrict.description') }} + </p> + <ul class="setting-list"> + <li> + <h4>{{ $t('admin_dash.instance.restrict.timelines') }}</h4> + <ul class="setting-list"> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:timelines.:local" + indeterminate-state=":if_instance_is_private" + swap-description-and-label + hide-description + /> + </li> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:timelines.:federated" + indeterminate-state=":if_instance_is_private" + swap-description-and-label + hide-description + /> + </li> + <li> + <GroupSetting path=":pleroma.:restrict_unauthenticated.:timelines" /> + </li> + </ul> + </li> + <li> + <h4>{{ $t('admin_dash.instance.restrict.profiles') }}</h4> + <ul class="setting-list"> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:profiles.:local" + indeterminate-state=":if_instance_is_private" + swap-description-and-label + hide-description + /> + </li> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:profiles.:remote" + indeterminate-state=":if_instance_is_private" + swap-description-and-label + hide-description + /> + </li> + <li> + <GroupSetting path=":pleroma.:restrict_unauthenticated.:profiles" /> + </li> + </ul> + </li> + <li> + <h4>{{ $t('admin_dash.instance.restrict.activities') }}</h4> + <ul class="setting-list"> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:activities.:local" + indeterminate-state=":if_instance_is_private" + swap-description-and-label + hide-description + /> + </li> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:activities.:remote" + indeterminate-state=":if_instance_is_private" + swap-description-and-label + hide-description + /> + </li> + <li> + <GroupSetting path=":pleroma.:restrict_unauthenticated.:activities" /> + </li> + </ul> + </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 @@ -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 @@ -0,0 +1,136 @@ +<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> + <ul class="setting-list"> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:limit" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:remote_limit" + expert="1" + draft-mode + /> + </li> + </ul> + </li> + <li> + <h3>{{ $t('admin_dash.limits.uploads') }}</h3> + <ul class="setting-list"> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:description_limit" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:upload_limit" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:max_media_attachments" + draft-mode + /> + </li> + </ul> + </li> + <li> + <h3>{{ $t('admin_dash.limits.users') }}</h3> + <ul class="setting-list"> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:max_pinned_statuses" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:user_bio_length" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:user_name_length" + draft-mode + /> + </li> + <li> + <h4>{{ $t('admin_dash.limits.profile_fields') }}</h4> + <ul class="setting-list"> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:max_account_fields" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:max_remote_account_fields" + draft-mode + expert="1" + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:account_field_name_length" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:account_field_value_length" + draft-mode + /> + </li> + </ul> + </li> + <li> + <h4>{{ $t('admin_dash.limits.user_uploads') }}</h4> + <ul class="setting-list"> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:avatar_upload_limit" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:banner_upload_limit" + draft-mode + /> + </li> + </ul> + </li> + </ul> + </li> + </ul> + </div> + </div> +</template> + +<script src="./limits_tab.js"></script> diff --git a/src/components/settings_modal/helpers/attachment_setting.js b/src/components/settings_modal/helpers/attachment_setting.js @@ -0,0 +1,43 @@ +import Setting from './setting.js' +import { fileTypeExt } from 'src/services/file_type/file_type.service.js' +import MediaUpload from 'src/components/media_upload/media_upload.vue' +import Attachment from 'src/components/attachment/attachment.vue' + +export default { + ...Setting, + props: { + ...Setting.props, + acceptTypes: { + type: String, + required: false, + default: 'image/*' + } + }, + components: { + ...Setting.components, + MediaUpload, + Attachment + }, + computed: { + ...Setting.computed, + attachment () { + const path = this.realDraftMode ? this.draft : this.state + // The "server" part is primarily for local dev, but could be useful for alt-domain or multiuser usage. + const url = path.includes('://') ? path : this.$store.state.instance.server + path + return { + mimetype: fileTypeExt(url), + url + } + } + }, + methods: { + ...Setting.methods, + setMediaFile (fileInfo) { + if (this.realDraftMode) { + this.draft = fileInfo.url + } else { + this.configSink(this.path, fileInfo.url) + } + } + } +} diff --git a/src/components/settings_modal/helpers/attachment_setting.vue b/src/components/settings_modal/helpers/attachment_setting.vue @@ -0,0 +1,96 @@ +<template> + <span + v-if="matchesExpertLevel" + class="AttachmentSetting" + > + <label + :for="path" + :class="{ 'faint': shouldBeDisabled }" + > + <template v-if="backendDescriptionLabel"> + {{ backendDescriptionLabel + ' ' }} + </template> + <template v-else-if="source === 'admin'"> + MISSING LABEL FOR {{ path }} + </template> + <slot v-else /> + + </label> + <p + v-if="backendDescriptionDescription" + class="setting-description" + :class="{ 'faint': shouldBeDisabled }" + > + {{ backendDescriptionDescription + ' ' }} + </p> + <div class="attachment-input"> + <div>{{ $t('settings.url') }}</div> + <div class="controls"> + <input + :id="path" + class="string-input" + :disabled="shouldBeDisabled" + :value="realDraftMode ? draft : state" + @change="update" + > + {{ ' ' }} + <ModifiedIndicator + :changed="isChanged" + :onclick="reset" + /> + <ProfileSettingIndicator :is-profile="isProfileSetting" /> + </div> + <div>{{ $t('settings.preview') }}</div> + <Attachment + class="attachment" + :compact="compact" + :attachment="attachment" + size="small" + hide-description + @setMedia="onMedia" + @naturalSizeLoad="onNaturalSizeLoad" + /> + <div class="controls"> + <MediaUpload + ref="mediaUpload" + class="media-upload-icon" + :drop-files="dropFiles" + normal-button + :accept-types="acceptTypes" + @uploaded="setMediaFile" + @upload-failed="uploadFailed" + /> + </div> + </div> + <DraftButtons /> + </span> +</template> + +<script src="./attachment_setting.js"></script> + +<style lang="scss"> +.AttachmentSetting { + .attachment { + display: block; + width: 100%; + height: 15em; + margin-bottom: 0.5em; + } + + .attachment-input { + margin-left: 1em; + display: flex; + flex-direction: column; + width: 20em; + } + + .controls { + margin-bottom: 0.5em; + + input, + button { + width: 100%; + } + } +} +</style> diff --git 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 @@ -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="backendDescriptionLabel"> + {{ backendDescriptionLabel }} + </template> + <template v-else-if="source === 'admin'"> + MISSING LABEL FOR {{ path }} + </template> + <slot v-else /> </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 @@ -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 @@ -3,15 +3,20 @@ v-if="matchesExpertLevel" class="ChoiceSetting" > - <slot /> + <template v-if="backendDescriptionLabel"> + {{ 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 @@ -0,0 +1,88 @@ +<!-- this is a helper exclusive to Setting components --> +<!-- TODO make it reusable --> +<template> + <span + class="DraftButtons" + > + <Popover + v-if="$parent.isDirty" + trigger="hover" + normal-button + :trigger-attrs="{ 'aria-label': $t('settings.commit_value_tooltip') }" + @click="$parent.commitDraft" + > + <template #trigger> + {{ $t('settings.commit_value') }} + </template> + <template #content> + <div class="modified-tooltip"> + {{ $t('settings.commit_value_tooltip') }} + </div> + </template> + </Popover> + <Popover + v-if="$parent.isDirty" + trigger="hover" + normal-button + :trigger-attrs="{ 'aria-label': $t('settings.reset_value_tooltip') }" + @click="$parent.reset" + > + <template #trigger> + {{ $t('settings.reset_value') }} + </template> + <template #content> + <div class="modified-tooltip"> + {{ $t('settings.reset_value_tooltip') }} + </div> + </template> + </Popover> + <Popover + v-if="$parent.canHardReset" + trigger="hover" + normal-button + :trigger-attrs="{ 'aria-label': $t('settings.hard_reset_value_tooltip') }" + @click="$parent.hardReset" + > + <template #trigger> + {{ $t('settings.hard_reset_value') }} + </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; + + .button-default { + margin-left: 0.5em; + } +} + +.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 @@ -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 @@ -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 @@ -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 @@ -3,17 +3,26 @@ v-if="matchesExpertLevel" class="NumberSetting" > - <label :for="path"> - <slot /> + <label + :for="path" + :class="{ 'faint': shouldBeDisabled }" + > + <template v-if="backendDescriptionLabel"> + {{ backendDescriptionLabel + ' ' }} + </template> + <template v-else-if="source === 'admin'"> + MISSING LABEL FOR {{ path }} + </template> + <slot v-else /> </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 +30,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/profile_setting_indicator.vue b/src/components/settings_modal/helpers/profile_setting_indicator.vue @@ -0,0 +1,51 @@ +<template> + <span + v-if="isProfile" + class="ProfileSettingIndicator" + > + <Popover + trigger="hover" + > + <template #trigger> + &nbsp; + <FAIcon + icon="server" + :aria-label="$t('settings.setting_server_side')" + /> + </template> + <template #content> + <div class="profilesetting-tooltip"> + {{ $t('settings.setting_server_side') }} + </div> + </template> + </Popover> + </span> +</template> + +<script> +import Popover from 'src/components/popover/popover.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { faServer } from '@fortawesome/free-solid-svg-icons' + +library.add( + faServer +) + +export default { + components: { Popover }, + props: ['isProfile'] +} +</script> + +<style lang="scss"> +.ProfileSettingIndicator { + display: inline-block; + position: relative; +} + +.profilesetting-tooltip { + margin: 0.5em 1em; + min-width: 10em; + text-align: center; +} +</style> diff --git a/src/components/settings_modal/helpers/server_side_indicator.vue b/src/components/settings_modal/helpers/server_side_indicator.vue @@ -1,51 +0,0 @@ -<template> - <span - v-if="serverSide" - class="ServerSideIndicator" - > - <Popover - trigger="hover" - > - <template #trigger> - &nbsp; - <FAIcon - icon="server" - :aria-label="$t('settings.setting_server_side')" - /> - </template> - <template #content> - <div class="serverside-tooltip"> - {{ $t('settings.setting_server_side') }} - </div> - </template> - </Popover> - </span> -</template> - -<script> -import Popover from 'src/components/popover/popover.vue' -import { library } from '@fortawesome/fontawesome-svg-core' -import { faServer } from '@fortawesome/free-solid-svg-icons' - -library.add( - faServer -) - -export default { - components: { Popover }, - props: ['serverSide'] -} -</script> - -<style lang="scss"> -.ServerSideIndicator { - display: inline-block; - position: relative; -} - -.serverside-tooltip { - margin: 0.5em 1em; - min-width: 10em; - text-align: center; -} -</style> diff --git a/src/components/settings_modal/helpers/setting.js b/src/components/settings_modal/helpers/setting.js @@ -0,0 +1,237 @@ +import ModifiedIndicator from './modified_indicator.vue' +import ProfileSettingIndicator from './profile_setting_indicator.vue' +import DraftButtons from './draft_buttons.vue' +import { get, set, cloneDeep } 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 + }, + hideDescription: { + type: Boolean + }, + swapDescriptionAndLabel: { + type: Boolean + }, + overrideBackendDescription: { + type: Boolean + }, + overrideBackendDescriptionLabel: { + type: Boolean + }, + 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 () { + if (this.realSource !== 'admin') return '' + if (!this.backendDescription || this.overrideBackendDescriptionLabel) { + return this.$t([ + 'admin_dash', + 'temp_overrides', + ...this.canonPath.map(p => p.replace(/\./g, '_DOT_')), + 'label' + ].join('.')) + } else { + return this.swapDescriptionAndLabel + ? this.backendDescription?.description + : this.backendDescription?.label + } + }, + backendDescriptionDescription () { + if (this.realSource !== 'admin') return '' + if (this.hideDescription) return null + if (!this.backendDescription || this.overrideBackendDescription) { + return this.$t([ + 'admin_dash', + 'temp_overrides', + ...this.canonPath.map(p => p.replace(/\./g, '_DOT_')), + 'description' + ].join('.')) + } else { + return this.swapDescriptionAndLabel + ? this.backendDescription?.label + : 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 () { + if (this.realDraftMode) { + this.draft = cloneDeep(this.state) + } else { + set(this.$store.getters.mergedConfig, this.path, cloneDeep(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 @@ -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 @@ -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/size_setting.vue b/src/components/settings_modal/helpers/size_setting.vue @@ -45,11 +45,18 @@ <script src="./size_setting.js"></script> <style lang="scss"> -.css-unit-input, -.css-unit-input select { - margin-left: 0.5em; - width: 4em; - max-width: 4em; - min-width: 4em; +.SizeSetting { + .number-input { + max-width: 6.5em; + } + + .css-unit-input, + .css-unit-input select { + margin-left: 0.5em; + width: 4em; + max-width: 4em; + min-width: 4em; + } } + </style> diff --git a/src/components/settings_modal/helpers/string_setting.js 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 @@ -0,0 +1,42 @@ +<template> + <label + v-if="matchesExpertLevel" + class="StringSetting" + > + <label + :for="path" + :class="{ 'faint': shouldBeDisabled }" + > + <template v-if="backendDescriptionLabel"> + {{ backendDescriptionLabel + ' ' }} + </template> + <template v-else-if="source === 'admin'"> + MISSING LABEL FOR {{ path }} + </template> + <slot v-else /> + </label> + <input + :id="path" + class="string-input" + :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 @@ -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 @@ -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 @@ -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,42 @@ > {{ $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 + v-if="modalMode === 'admin'" + class="admin-buttons" + > + <button + class="button-default btn" + :disabled="!adminDraftAny" + @click="resetAdminDraft" + > + {{ $t("admin_dash.reset_all") }} + </button> + {{ ' ' }} + <button + class="button-default btn" + :disabled="!adminDraftAny" + @click="pushAdminDraft" + > + {{ $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 @@ -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_admin_content.scss b/src/components/settings_modal/settings_modal_admin_content.scss @@ -0,0 +1,52 @@ +@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; + } + } +} diff --git a/src/components/settings_modal/settings_modal_admin_content.vue 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.scss b/src/components/settings_modal/settings_modal_content.scss @@ -1,56 +0,0 @@ -@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_content.vue @@ -1,83 +0,0 @@ -<template> - <tab-switcher - ref="tabSwitcher" - class="settings_tab-switcher" - :side-tab-bar="true" - :scrollable-tabs="true" - :body-scroll-lock="bodyLock" - > - <div - :label="$t('settings.general')" - icon="wrench" - data-tab-name="general" - > - <GeneralTab /> - </div> - <div - v-if="isLoggedIn" - :label="$t('settings.profile_tab')" - icon="user" - data-tab-name="profile" - > - <ProfileTab /> - </div> - <div - v-if="isLoggedIn" - :label="$t('settings.security_tab')" - icon="lock" - data-tab-name="security" - > - <SecurityTab /> - </div> - <div - :label="$t('settings.filtering')" - icon="filter" - data-tab-name="filtering" - > - <FilteringTab /> - </div> - <div - :label="$t('settings.theme')" - icon="paint-brush" - data-tab-name="theme" - > - <ThemeTab /> - </div> - <div - v-if="isLoggedIn" - :label="$t('settings.notifications')" - icon="bell" - data-tab-name="notifications" - > - <NotificationsTab /> - </div> - <div - v-if="isLoggedIn" - :label="$t('settings.data_import_export_tab')" - icon="download" - data-tab-name="dataImportExport" - > - <DataImportExportTab /> - </div> - <div - v-if="isLoggedIn" - :label="$t('settings.mutes_and_blocks')" - :fullHeight="true" - icon="eye-slash" - data-tab-name="mutesAndBlocks" - > - <MutesAndBlocksTab /> - </div> - <div - :label="$t('settings.version.title')" - icon="info" - data-tab-name="version" - > - <VersionTab /> - </div> - </tab-switcher> -</template> - -<script src="./settings_modal_content.js"></script> - -<style src="./settings_modal_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 diff --git a/src/components/settings_modal/settings_modal_user_content.scss b/src/components/settings_modal/settings_modal_user_content.scss @@ -0,0 +1,52 @@ +@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; + } + } +} diff --git a/src/components/settings_modal/settings_modal_user_content.vue b/src/components/settings_modal/settings_modal_user_content.vue @@ -0,0 +1,83 @@ +<template> + <tab-switcher + ref="tabSwitcher" + class="settings_tab-switcher" + :side-tab-bar="true" + :scrollable-tabs="true" + :body-scroll-lock="bodyLock" + > + <div + :label="$t('settings.general')" + icon="wrench" + data-tab-name="general" + > + <GeneralTab /> + </div> + <div + v-if="isLoggedIn" + :label="$t('settings.profile_tab')" + icon="user" + data-tab-name="profile" + > + <ProfileTab /> + </div> + <div + v-if="isLoggedIn" + :label="$t('settings.security_tab')" + icon="lock" + data-tab-name="security" + > + <SecurityTab /> + </div> + <div + :label="$t('settings.filtering')" + icon="filter" + data-tab-name="filtering" + > + <FilteringTab /> + </div> + <div + :label="$t('settings.theme')" + icon="paint-brush" + data-tab-name="theme" + > + <ThemeTab /> + </div> + <div + v-if="isLoggedIn" + :label="$t('settings.notifications')" + icon="bell" + data-tab-name="notifications" + > + <NotificationsTab /> + </div> + <div + v-if="isLoggedIn" + :label="$t('settings.data_import_export_tab')" + icon="download" + data-tab-name="dataImportExport" + > + <DataImportExportTab /> + </div> + <div + v-if="isLoggedIn" + :label="$t('settings.mutes_and_blocks')" + :fullHeight="true" + icon="eye-slash" + data-tab-name="mutesAndBlocks" + > + <MutesAndBlocksTab /> + </div> + <div + :label="$t('settings.version.title')" + icon="info" + data-tab-name="version" + > + <VersionTab /> + </div> + </tab-switcher> +</template> + +<script src="./settings_modal_user_content.js"></script> + +<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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js @@ -115,7 +115,10 @@ const SideDrawer = { GestureService.updateSwipe(e, this.closeGesture) }, openSettingsModal () { - this.$store.dispatch('openSettingsModal') + this.$store.dispatch('openSettingsModal', 'user') + }, + openAdminModal () { + this.$store.dispatch('openSettingsModal', 'admin') } } } diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue @@ -180,16 +180,16 @@ v-if="currentUser && currentUser.role === 'admin'" @click="toggleDrawer" > - <a - href="/pleroma/admin/#/login-pleroma" - target="_blank" + <button + class="button-unstyled -link -fullwidth" + @click.stop="openAdminModal" > <FAIcon fixed-width class="fa-scale-110 fa-old-padding" icon="tachometer-alt" /> {{ $t("nav.administration") }} - </a> + </button> </li> <li v-if="currentUser && supportsAnnouncements" diff --git 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 @@ -519,6 +519,8 @@ "loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")", "mutes_tab": "Mutes", "play_videos_in_modal": "Play videos in a popup frame", + "url": "URL", + "preview": "Preview", "file_export_import": { "backup_restore": "Settings backup", "backup_settings": "Backup settings to file", @@ -830,6 +832,98 @@ "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", + "access": "Instance access", + "restrict": { + "header": "Restrict access for anonymous visitors", + "description": "Detailed setting for allowing/disallowing access to certain aspects of API. By default (indeterminate state) it will disallow if instance is not public, ticked checkbox means disallow access even if instance is public, unticked means allow access even if instance is private. Please note that unexpected behavior might happen if some settings are set, i.e. if profile access is disabled posts will show without profile information.", + "timelines": "Timelines access", + "profiles": "User profiles access", + "activities": "Statues/activities access" + } + }, + "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}", + "more_install_options": "More install options", + "more_default_options": "More default setting options", + "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" + }, + "temp_overrides": { + ":pleroma": { + ":instance": { + ":public": { + "label": "Instance is public", + "description": "Disabling this will make all API accessible only for logged-in users, this will make Public and Federated timelines inaccessible to anonymous visitors." + }, + ":limit_to_local_content": { + "label": "Limit search to local content", + "description": "Disables global network search for unauthenticated (default), all users or none" + }, + ":description_limit": { + "label": "Limit", + "description": "Character limit for attachment descriptions" + }, + ":background_image": { + "label": "Background image", + "description": "Background image (primarily used by PleromaFE)" + } + } + } } }, "time": { diff --git 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 @@ -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 @@ -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 @@ -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/profileConfig.js b/src/modules/profileConfig.js @@ -0,0 +1,140 @@ +import { get, set } from 'lodash' + +const defaultApi = ({ rootState, commit }, { path, value }) => { + const params = {} + set(params, path, value) + return rootState + .api + .backendInteractor + .updateProfile({ params }) + .then(result => { + commit('addNewUsers', [result]) + commit('setCurrentUser', result) + }) +} + +const notificationsApi = ({ rootState, commit }, { path, value, oldValue }) => { + const settings = {} + set(settings, path, value) + return rootState + .api + .backendInteractor + .updateNotificationSettings({ settings }) + .then(result => { + if (result.status === 'success') { + commit('confirmProfileOption', { name, value }) + } else { + commit('confirmProfileOption', { name, value: oldValue }) + } + }) +} + +/** + * Map that stores relation between path for reading (from user profile), + * for writing (into API) an what API to use. + * + * Shorthand - instead of { get, set, api? } object it's possible to use string + * in case default api is used and get = set + * + * If no api is specified, defaultApi is used (see above) + */ +export const settingsMap = { + defaultScope: 'source.privacy', + defaultNSFW: 'source.sensitive', // BROKEN: pleroma/pleroma#2837 + stripRichContent: { + get: 'source.pleroma.no_rich_text', + set: 'no_rich_text' + }, + // Privacy + locked: 'locked', + acceptChatMessages: { + get: 'pleroma.accepts_chat_messages', + set: 'accepts_chat_messages' + }, + allowFollowingMove: { + get: 'pleroma.allow_following_move', + set: 'allow_following_move' + }, + discoverable: { + get: 'source.pleroma.discoverable', + set: 'discoverable' + }, + hideFavorites: { + get: 'pleroma.hide_favorites', + set: 'hide_favorites' + }, + hideFollowers: { + get: 'pleroma.hide_followers', + set: 'hide_followers' + }, + hideFollows: { + get: 'pleroma.hide_follows', + set: 'hide_follows' + }, + hideFollowersCount: { + get: 'pleroma.hide_followers_count', + set: 'hide_followers_count' + }, + hideFollowsCount: { + get: 'pleroma.hide_follows_count', + set: 'hide_follows_count' + }, + // NotificationSettingsAPIs + webPushHideContents: { + get: 'pleroma.notification_settings.hide_notification_contents', + set: 'hide_notification_contents', + api: notificationsApi + }, + blockNotificationsFromStrangers: { + get: 'pleroma.notification_settings.block_from_strangers', + set: 'block_from_strangers', + api: notificationsApi + } +} + +export const defaultState = Object.fromEntries(Object.keys(settingsMap).map(key => [key, null])) + +const profileConfig = { + state: { ...defaultState }, + mutations: { + confirmProfileOption (state, { name, value }) { + set(state, name, value) + }, + wipeProfileOption (state, { name }) { + set(state, name, null) + }, + wipeAllProfileOptions (state) { + Object.keys(settingsMap).forEach(key => { + set(state, key, null) + }) + }, + // Set the settings based on their path location + setCurrentUser (state, user) { + Object.entries(settingsMap).forEach((map) => { + const [name, value] = map + const { get: path = value } = value + set(state, name, get(user._original, path)) + }) + } + }, + actions: { + 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('wipeProfileOption', { name }) + + api({ rootState, commit }, { path, value, oldValue }) + .catch((e) => { + console.warn('Error setting server-side option:', e) + commit('confirmProfileOption', { name, value: oldValue }) + }) + }, + logout ({ commit }) { + commit('wipeAllProfileOptions') + } + } +} + +export default profileConfig diff --git a/src/modules/serverSideConfig.js b/src/modules/serverSideConfig.js @@ -1,140 +0,0 @@ -import { get, set } from 'lodash' - -const defaultApi = ({ rootState, commit }, { path, value }) => { - const params = {} - set(params, path, value) - return rootState - .api - .backendInteractor - .updateProfile({ params }) - .then(result => { - commit('addNewUsers', [result]) - commit('setCurrentUser', result) - }) -} - -const notificationsApi = ({ rootState, commit }, { path, value, oldValue }) => { - const settings = {} - set(settings, path, value) - return rootState - .api - .backendInteractor - .updateNotificationSettings({ settings }) - .then(result => { - if (result.status === 'success') { - commit('confirmServerSideOption', { name, value }) - } else { - commit('confirmServerSideOption', { name, value: oldValue }) - } - }) -} - -/** - * Map that stores relation between path for reading (from user profile), - * for writing (into API) an what API to use. - * - * Shorthand - instead of { get, set, api? } object it's possible to use string - * in case default api is used and get = set - * - * If no api is specified, defaultApi is used (see above) - */ -export const settingsMap = { - defaultScope: 'source.privacy', - defaultNSFW: 'source.sensitive', // BROKEN: pleroma/pleroma#2837 - stripRichContent: { - get: 'source.pleroma.no_rich_text', - set: 'no_rich_text' - }, - // Privacy - locked: 'locked', - acceptChatMessages: { - get: 'pleroma.accepts_chat_messages', - set: 'accepts_chat_messages' - }, - allowFollowingMove: { - get: 'pleroma.allow_following_move', - set: 'allow_following_move' - }, - discoverable: { - get: 'source.pleroma.discoverable', - set: 'discoverable' - }, - hideFavorites: { - get: 'pleroma.hide_favorites', - set: 'hide_favorites' - }, - hideFollowers: { - get: 'pleroma.hide_followers', - set: 'hide_followers' - }, - hideFollows: { - get: 'pleroma.hide_follows', - set: 'hide_follows' - }, - hideFollowersCount: { - get: 'pleroma.hide_followers_count', - set: 'hide_followers_count' - }, - hideFollowsCount: { - get: 'pleroma.hide_follows_count', - set: 'hide_follows_count' - }, - // NotificationSettingsAPIs - webPushHideContents: { - get: 'pleroma.notification_settings.hide_notification_contents', - set: 'hide_notification_contents', - api: notificationsApi - }, - blockNotificationsFromStrangers: { - get: 'pleroma.notification_settings.block_from_strangers', - set: 'block_from_strangers', - api: notificationsApi - } -} - -export const defaultState = Object.fromEntries(Object.keys(settingsMap).map(key => [key, null])) - -const serverSideConfig = { - state: { ...defaultState }, - mutations: { - confirmServerSideOption (state, { name, value }) { - set(state, name, value) - }, - wipeServerSideOption (state, { name }) { - set(state, name, null) - }, - wipeAllServerSideOptions (state) { - Object.keys(settingsMap).forEach(key => { - set(state, key, null) - }) - }, - // Set the settings based on their path location - setCurrentUser (state, user) { - Object.entries(settingsMap).forEach((map) => { - const [name, value] = map - const { get: path = value } = value - set(state, name, get(user._original, path)) - }) - } - }, - actions: { - setServerSideOption ({ 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 }) - - api({ rootState, commit }, { path, value, oldValue }) - .catch((e) => { - console.warn('Error setting server-side option:', e) - commit('confirmServerSideOption', { name, value: oldValue }) - }) - }, - logout ({ commit }) { - commit('wipeAllServerSideOptions') - } - } -} - -export default serverSideConfig diff --git 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 @@ -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 diff --git a/src/services/file_type/file_type.service.js b/src/services/file_type/file_type.service.js @@ -1,7 +1,7 @@ // TODO this func might as well take the entire file and use its mimetype // or the entire service could be just mimetype service that only operates // on mimetypes and not files. Currently the naming is confusing. -const fileType = mimetype => { +export const fileType = mimetype => { if (mimetype.match(/flash/)) { return 'flash' } @@ -25,11 +25,25 @@ const fileType = mimetype => { return 'unknown' } -const fileMatchesSomeType = (types, file) => +export const fileTypeExt = url => { + if (url.match(/\.(png|jpe?g|gif|webp|avif)$/)) { + return 'image' + } + if (url.match(/\.(ogv|mp4|webm|mov)$/)) { + return 'video' + } + if (url.match(/\.(it|s3m|mod|umx|mp3|aac|m4a|flac|alac|ogg|oga|opus|wav|ape|midi?)$/)) { + return 'audio' + } + return 'unknown' +} + +export const fileMatchesSomeType = (types, file) => types.some(type => fileType(file.mimetype) === type) const fileTypeService = { fileType, + fileTypeExt, fileMatchesSomeType }