Merge branch 'improve_settings_reusability' into 'develop'

AdminFE functionality in PleromaFE

See merge request pleroma/pleroma-fe!1800
このコミットが含まれているのは:
HJ 2023-05-24 18:55:20 +00:00
コミット c730c9b6d0
63個のファイルの変更2371行の追加422行の削除

1
changelog.d/adminfe.add ノーマルファイル
ファイルの表示

@ -0,0 +1 @@
Implemented a very basic instance administration screen

ファイルの表示

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

ファイルの表示

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

ファイルの表示

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

ファイルの表示

@ -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"

ファイルの表示

@ -36,7 +36,9 @@
<button
class="button-default btn"
@click="addLanguage"
>{{ $t('settings.add_language') }}</button>
>
{{ $t('settings.add_language') }}
</button>
</li>
</ul>
</div>

ファイルの表示

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

ファイルの表示

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

ファイルの表示

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

ファイルの表示

@ -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: {}

ファイルの表示

@ -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"

ファイルの表示

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

ファイルの表示

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

ファイルの表示

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

ファイルの表示

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

ファイルの表示

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

ファイルの表示

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

ファイルの表示

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

ファイルの表示

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

ファイルの表示

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

ファイルの表示

@ -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 {
components: {
Checkbox,
ModifiedIndicator,
ServerSideIndicator
...Setting,
props: {
...Setting.props,
indeterminateState: [String, Object]
},
components: {
...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
}
}
}

ファイルの表示

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

ファイルの表示

@ -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: {
...Setting.props,
options: {
type: Array,
required: false
},
optionLabelMap: {
type: Object,
required: false,
default: {}
}
},
props: [
'path',
'disabled',
'options',
'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
...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
}
}
}

ファイルの表示

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

ファイルの表示

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

ファイルの表示

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

ファイルの表示

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

ファイルの表示

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

ファイルの表示

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

ファイルの表示

@ -1,7 +1,7 @@
<template>
<span
v-if="serverSide"
class="ServerSideIndicator"
v-if="isProfile"
class="ProfileSettingIndicator"
>
<Popover
trigger="hover"
@ -14,7 +14,7 @@
/>
</template>
<template #content>
<div class="serverside-tooltip">
<div class="profilesetting-tooltip">
{{ $t('settings.setting_server_side') }}
</div>
</template>
@ -33,17 +33,17 @@ library.add(
export default {
components: { Popover },
props: ['serverSide']
props: ['isProfile']
}
</script>
<style lang="scss">
.ServerSideIndicator {
.ProfileSettingIndicator {
display: inline-block;
position: relative;
}
.serverside-tooltip {
.profilesetting-tooltip {
margin: 0.5em 1em;
min-width: 10em;
text-align: center;

ファイルの表示

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

ファイルの表示

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

ファイルの表示

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

ファイルの表示

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

ファイルの表示

@ -0,0 +1,5 @@
import Setting from './setting.js'
export default {
...Setting
}

ファイルの表示

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

ファイルの表示

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

ファイルの表示

@ -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;

ファイルの表示

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

ファイルの表示

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

ファイルの表示

@ -48,9 +48,5 @@
color: var(--cRed, $fallback--cRed);
color: $fallback--cRed;
}
.number-input {
max-width: 6em;
}
}
}

ファイルの表示

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

ファイルの表示

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

ファイルの表示

@ -78,6 +78,6 @@
</tab-switcher>
</template>
<script src="./settings_modal_content.js"></script>
<script src="./settings_modal_user_content.js"></script>
<style src="./settings_modal_content.scss" lang="scss"></style>
<style src="./settings_modal_user_content.scss" lang="scss"></style>

ファイルの表示

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

ファイルの表示

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

ファイルの表示

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

ファイルの表示

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

ファイルの表示

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

ファイルの表示

@ -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"
>

ファイルの表示

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

ファイルの表示

@ -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"

ファイルの表示

@ -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]

ファイルの表示

@ -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": {

ファイルの表示

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

230
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

ファイルの表示

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

ファイルの表示

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

ファイルの表示

@ -22,9 +22,9 @@ const notificationsApi = ({ rootState, commit }, { path, value, oldValue }) => {
.updateNotificationSettings({ settings })
.then(result => {
if (result.status === 'success') {
commit('confirmServerSideOption', { name, value })
commit('confirmProfileOption', { name, value })
} else {
commit('confirmServerSideOption', { name, value: oldValue })
commit('confirmProfileOption', { name, value: oldValue })
}
})
}
@ -94,16 +94,16 @@ export const settingsMap = {
export const defaultState = Object.fromEntries(Object.keys(settingsMap).map(key => [key, null]))
const serverSideConfig = {
const profileConfig = {
state: { ...defaultState },
mutations: {
confirmServerSideOption (state, { name, value }) {
confirmProfileOption (state, { name, value }) {
set(state, name, value)
},
wipeServerSideOption (state, { name }) {
wipeProfileOption (state, { name }) {
set(state, name, null)
},
wipeAllServerSideOptions (state) {
wipeAllProfileOptions (state) {
Object.keys(settingsMap).forEach(key => {
set(state, key, null)
})
@ -118,23 +118,23 @@ const serverSideConfig = {
}
},
actions: {
setServerSideOption ({ rootState, state, commit, dispatch }, { name, value }) {
setProfileOption ({ rootState, state, commit, dispatch }, { name, value }) {
const oldValue = get(state, name)
const map = settingsMap[name]
if (!map) throw new Error('Invalid server-side setting')
const { set: path = map, api = defaultApi } = map
commit('wipeServerSideOption', { name })
commit('wipeProfileOption', { name })
api({ rootState, commit }, { path, value, oldValue })
.catch((e) => {
console.warn('Error setting server-side option:', e)
commit('confirmServerSideOption', { name, value: oldValue })
commit('confirmProfileOption', { name, value: oldValue })
})
},
logout ({ commit }) {
commit('wipeAllServerSideOptions')
commit('wipeAllProfileOptions')
}
}
}
export default serverSideConfig
export default profileConfig

ファイルの表示

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

ファイルの表示

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

ファイルの表示

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