Merge remote-tracking branch 'origin/develop' into admin-users

This commit is contained in:
Henry Jameson 2026-06-08 00:57:42 +03:00
commit 43936a8725
628 changed files with 72639 additions and 24537 deletions

View file

@ -1,12 +1,12 @@
import BasicUserCard from '../../basic_user_card/basic_user_card.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import PageList from 'src/components/page_list/page_list.vue'
import AdminStatusCard from 'src/components/settings_modal/admin_tabs/admin_status_card.vue'
import Modal from 'src/components/modal/modal.vue'
import Popover from 'src/components/popover/popover.vue'
import GenericConfirm from 'src/components/confirm_modal/generic_confirm.vue'
import Select from 'src/components/select/select.vue'
import TextConfirm from 'src/components/confirm_modal/text_confirm.vue'
import Modal from 'src/components/modal/modal.vue'
import PageList from 'src/components/page_list/page_list.vue'
import Popover from 'src/components/popover/popover.vue'
import Select from 'src/components/select/select.vue'
import AdminStatusCard from 'src/components/settings_modal/admin_tabs/admin_status_card.vue'
import BasicUserCard from '../../basic_user_card/basic_user_card.vue'
const AdminCard = {
props: {
@ -27,17 +27,17 @@ const AdminCard = {
* @param {any} u
* @returns {u is { id: string; _original: { is_approved; is_confirmed: boolean; } } }
*/
validator (u) {
validator(u) {
return (
typeof(u.id) === 'string' &&
typeof(u._original) === 'object' &&
typeof(u._original.is_approved) === 'boolean' &&
typeof(u._original.is_confirmed) === 'boolean'
typeof u.id === 'string' &&
typeof u._original === 'object' &&
typeof u._original.is_approved === 'boolean' &&
typeof u._original.is_confirmed === 'boolean'
)
}
}
},
},
},
data () {
data() {
return {
progress: false,
detailsExpanded: false,
@ -49,7 +49,7 @@ const AdminCard = {
justDeleted: false,
showDirect: false,
showReblogs: false,
timelineSorting: "des"
timelineSorting: 'des',
}
},
computed: {
@ -57,27 +57,27 @@ const AdminCard = {
* checks if the user is defined
* @returns {boolean}
*/
isLoaded () {
return typeof(this.user) !== 'undefined'
isLoaded() {
return typeof this.user !== 'undefined'
},
/**
* @returns {object} user info
*/
user () {
user() {
return this.$store.getters.findUser(this.userDetails.id)
},
/**
* @returns {object} user relationship
*/
relationship () {
relationship() {
return this.$store.getters.relationship(this.userDetails.id)
},
/**
* @returns {boolean} is user local
*/
isLocal () {
isLocal() {
const u = this.$store.getters.findUser(this.userDetails.id)
if (typeof(u) !== 'undefined') {
if (typeof u !== 'undefined') {
return u.is_local === true
}
return false
@ -85,9 +85,9 @@ const AdminCard = {
/**
* @returns {boolean} is user admin
*/
isAdmin () {
isAdmin() {
const u = this.$store.getters.findUser(this.userDetails.id)
if (typeof(u) !== 'undefined') {
if (typeof u !== 'undefined') {
return u.rights.admin === true
}
return false
@ -95,9 +95,9 @@ const AdminCard = {
/**
* @returns {boolean} is user moderator
*/
isModerator () {
isModerator() {
const u = this.$store.getters.findUser(this.userDetails.id)
if (typeof(u) !== 'undefined') {
if (typeof u !== 'undefined') {
return u.rights.moderator === true
}
return false
@ -105,9 +105,9 @@ const AdminCard = {
/**
* @returns {boolean} is user active
*/
isActivated () {
isActivated() {
const u = this.$store.getters.findUser(this.userDetails.id)
if (typeof(u) !== 'undefined') {
if (typeof u !== 'undefined') {
return u.deactivated === false
}
return false
@ -115,16 +115,19 @@ const AdminCard = {
/**
* @returns {boolean} has this user been confirmed
*/
isConfirmed () {
isConfirmed() {
const u = this.$store.getters.findUser(this.userDetails.id)
return (u._original.is_confirmed === true) || (this.justConfirmed === true)
return u._original.is_confirmed === true || this.justConfirmed === true
},
/**
* @returns {boolean} has this user been approved
*/
isApproved () {
return (this.userDetails._original.is_approved === true) || (this.justApproved === true)
}
isApproved() {
return (
this.userDetails._original.is_approved === true ||
this.justApproved === true
)
},
},
components: {
BasicUserCard,
@ -135,13 +138,13 @@ const AdminCard = {
Popover,
GenericConfirm,
Select,
TextConfirm
TextConfirm,
},
methods: {
/**
* @param {boolean} v set admin status
*/
setAdmin (v) {
setAdmin(v) {
const u = this.$store.getters.findUser(this.userDetails.id)
if (v === true) {
this.$store.dispatch('adminAddUserToAdminGroup', u)
@ -152,7 +155,7 @@ const AdminCard = {
/**
* @param {boolean} v set moderator status
*/
setModerator (v) {
setModerator(v) {
const u = this.$store.getters.findUser(this.userDetails.id)
if (v === true) {
this.$store.dispatch('adminAddUserToModeratorGroup', u)
@ -163,7 +166,7 @@ const AdminCard = {
/**
* @param {boolean} v set activation status
*/
setActivation (v) {
setActivation(v) {
const u = this.$store.getters.findUser(this.userDetails.id)
if (v === true) {
this.$store.dispatch('adminActivateUser', u)
@ -174,7 +177,7 @@ const AdminCard = {
/**
* confirm this user
*/
confirmUser () {
confirmUser() {
const u = this.$store.getters.findUser(this.userDetails.id)
this.$store.dispatch('adminConfirmUser', u)
this.just_confirmed = true
@ -182,37 +185,37 @@ const AdminCard = {
/**
* try resending the confirmation email
*/
resendConfirmationEmail () {
resendConfirmationEmail() {
const u = this.$store.getters.findUser(this.userDetails.id)
this.$store.dispatch('adminResendConfirmationEmail', u)
},
/**
* approve this user
*/
approveUser () {
approveUser() {
const u = this.$store.getters.findUser(this.userDetails.id)
this.$store.dispatch('adminApproveUser', u)
},
/**
* update user info from server
*/
forceUpdateUser () {
forceUpdateUser() {
this.$store.dispatch('fetchUser', this.userDetails.id)
},
/**
* delete selected statuses
*/
deleteSelection () {
deleteSelection() {
const l = this.$refs.timelineList
const s = l.getSelected()
s.forEach(p => this.$store.dispatch('deleteStatus', p))
s.forEach((p) => this.$store.dispatch('deleteStatus', p))
l.reset()
},
/**
* delete this user. keep in mind that user deletion is not intuitive in pleroma backend.
* it actually deletes all content of a user. the user itself will keep showing up in search results.
*/
deleteUser () {
deleteUser() {
if (!this.justDeleted) {
const u = this.$store.getters.findUser(this.userDetails.id)
this.$store.dispatch('adminDeleteUser', u)
@ -222,17 +225,19 @@ const AdminCard = {
/**
* @param {string} text name of tag to be added to user
*/
addUserTag (text) {
addUserTag(text) {
const u = this.$store.getters.findUser(this.userDetails.id)
this.$store.dispatch('adminTagUser', { user: u, tag: text })
this.$store
.dispatch('adminTagUser', { user: u, tag: text })
.then(() => this.$store.dispatch('fetchUser', this.userDetails.id))
},
/**
* @param {string} text name of tag to be removed from user
*/
removeUserTag (text) {
removeUserTag(text) {
const u = this.$store.getters.findUser(this.userDetails.id)
this.$store.dispatch('adminUntagUser', { user: u, tag: text })
this.$store
.dispatch('adminUntagUser', { user: u, tag: text })
.then(() => this.$store.dispatch('fetchUser', this.userDetails.id))
},
/**
@ -240,34 +245,48 @@ const AdminCard = {
* @param {object} opts
* @returns {Promise<Array<object>>} statuses
*/
async fetchStatuses (store, opts) {
async fetchStatuses(store, opts) {
const u = this.$store.getters.findUser(this.userDetails.id)
const res = store.dispatch('adminListStatuses', { user: u, opts: { pageSize: opts.pageSize, godmode: this.showDirect, withReblogs: this.showReblogs}})
return res.then(r => {
const res = store.dispatch('adminListStatuses', {
user: u,
opts: {
pageSize: opts.pageSize,
godmode: this.showDirect,
withReblogs: this.showReblogs,
},
})
return res.then((r) => {
const a = r.activities
console.log(this.timelineSorting)
if (this.timelineSorting === 'des') {
return [...a].sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
return [...a].sort(
(a, b) => new Date(b.created_at) - new Date(a.created_at),
)
} else if (this.timelineSorting === 'asc') {
return [...a].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
return [...a].sort(
(a, b) => new Date(a.created_at) - new Date(b.created_at),
)
} else return []
})
},
confirmAction (box) {
confirmAction(box) {
this.$refs[box].show()
this.$refs.dropdownuser.hidePopover()
},
userActionConfirmed (action) {
this.$store.dispatch(action, this.$store.getters.findUser(this.userDetails.id))
userActionConfirmed(action) {
this.$store.dispatch(
action,
this.$store.getters.findUser(this.userDetails.id),
)
},
statusActionConfirmed (action, opts) {
statusActionConfirmed(action, opts) {
const s = this.$refs.statusList.getSelected()
s.forEach(p => {
this.$store.dispatch(action, { id: p.id, ...(opts || {})})
s.forEach((p) => {
this.$store.dispatch(action, { id: p.id, ...(opts || {}) })
})
this.reset()
}
}
},
},
}
export default AdminCard

View file

@ -1,6 +1,7 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Select from 'src/components/select/select.vue'
import Status from 'src/components/status/status.vue'
import { parseStatus } from 'src/services/entity_normalizer/entity_normalizer.service.js'
const AdminStatusCard = {
@ -18,12 +19,12 @@ const AdminStatusCard = {
* @param {any} u
* @returns {u is { id: string }}
*/
validator (u) {
return typeof(u.id) === 'string'
}
}
validator(u) {
return typeof u.id === 'string'
},
},
},
data () {
data() {
return {
jsonExpanded: false,
statusCache: undefined,
@ -33,28 +34,38 @@ const AdminStatusCard = {
/**
* @returns {boolean} is this status sensitive?
*/
isSensitive () {
isSensitive() {
return this.statusDetails.sensitive === true
},
/**
* @returns {'public' | 'unlisted' | 'private' | 'direct'} status visibility
*/
visibility () {
visibility() {
return this.statusDetails.visibility
}
},
},
methods: {
/**
* @param {boolean} v set sensitive
*/
changeSensitivity (v) {
this.$store.dispatch('adminChangeStatusScope', { opts: { id: this.statusDetails.id, sensitive: v }}).then(res => parseStatus(res)).then(s => this.statusCache = s)
changeSensitivity(v) {
this.$store
.dispatch('adminChangeStatusScope', {
opts: { id: this.statusDetails.id, sensitive: v },
})
.then((res) => parseStatus(res))
.then((s) => (this.statusCache = s))
},
/**
* @param {boolean} v set visible
*/
changeVisibility (v) {
this.$store.dispatch('adminChangeStatusScope', { opts: { id: this.statusDetails.id, visibility: v }}).then(res => parseStatus(res)).then(s => this.statusCache = s)
changeVisibility(v) {
this.$store
.dispatch('adminChangeStatusScope', {
opts: { id: this.statusDetails.id, visibility: v },
})
.then((res) => parseStatus(res))
.then((s) => (this.statusCache = s))
},
/**
* show the confirmation box for bulk actions.
@ -71,13 +82,19 @@ const AdminStatusCard = {
selectionConfirmed(action, opts) {
const restricted = []
const s = this.$refs.userList.getSelected()
s.forEach(u => {
if (restricted.includes(action) !== false || u.id !== this.$store.state.users.currentUser.id) {
this.$store.dispatch(action, { id: this.statusDetails.id, ...(opts || {}) })
s.forEach((u) => {
if (
restricted.includes(action) !== false ||
u.id !== this.$store.state.users.currentUser.id
) {
this.$store.dispatch(action, {
id: this.statusDetails.id,
...(opts || {}),
})
}
})
this.reset()
}
},
},
components: {
Checkbox,
@ -87,9 +104,14 @@ const AdminStatusCard = {
/**
* fetch and cache status info
*/
mounted () {
this.$store.dispatch('adminChangeStatusScope', { opts: { id: this.statusDetails.id }}).then(res => parseStatus(res)).then(s => this.statusCache = s)
}
mounted() {
this.$store
.dispatch('adminChangeStatusScope', {
opts: { id: this.statusDetails.id },
})
.then((res) => parseStatus(res))
.then((s) => (this.statusCache = s))
},
}
export default AdminStatusCard

View file

@ -0,0 +1,40 @@
import AttachmentSetting from '../helpers/attachment_setting.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import GroupSetting from '../helpers/group_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import ListSetting from '../helpers/list_setting.vue'
import MapSetting from '../helpers/map_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
import TupleSetting from '../helpers/tuple_setting.vue'
const AuthTab = {
provide() {
return {
defaultDraftMode: true,
defaultSource: 'admin',
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
StringSetting,
TupleSetting,
AttachmentSetting,
GroupSetting,
ListSetting,
MapSetting,
},
computed: {
...SharedComputedObject(),
LDAPEnabled() {
return this.$store.state.adminSettings.draft[':pleroma'][':ldap'][
':enabled'
]
},
},
}
export default AuthTab

View file

@ -0,0 +1,105 @@
<template>
<div :label="$t('admin_dash.tabs.job_queues')">
<div class="setting-section">
<h3>{{ $t('admin_dash.auth.MFA') }}</h3>
<ul class="setting-list">
<li>
<h4>{{ $t('admin_dash.auth.TOTP') }}</h4>
<ul class="setting-list suboptions">
<li>
<IntegerSetting path=":pleroma.:instance.:multi_factor_authentication.:totp.:digits" />
</li>
<li>
<IntegerSetting path=":pleroma.:instance.:multi_factor_authentication.:totp.:period" />
</li>
</ul>
</li>
<li>
<h4>{{ $t('admin_dash.auth.backup_codes') }}</h4>
<ul class="setting-list suboptions">
<li>
<IntegerSetting path=":pleroma.:instance.:multi_factor_authentication.:backup_codes.:number" />
</li>
<li>
<IntegerSetting path=":pleroma.:instance.:multi_factor_authentication.:backup_codes.:length" />
</li>
</ul>
</li>
<GroupSetting path=":pleroma.:instance.:multi_factor_authentication" />
</ul>
<h3>{{ $t('admin_dash.auth.OAuth') }}</h3>
<ul class="setting-list">
<li>
<StringSetting path=":pleroma.:auth.:auth_template" />
</li>
<li>
<BooleanSetting path=":pleroma.:auth.:enforce_oauth_admin_scope_usage" />
</li>
<li>
<StringSetting path=":pleroma.:auth.:oauth_consumer_template" />
</li>
<li>
<ListSetting path=":pleroma.:auth.:oauth_consumer_strategies" />
</li>
<li>
<IntegerSetting path=":pleroma.:oauth2.:token_expires_in" />
</li>
<li>
<BooleanSetting path=":pleroma.:oauth2.:issue_new_refresh_token" />
</li>
<li>
<BooleanSetting path=":pleroma.:oauth2.:clean_expired_tokens" />
</li>
</ul>
<h3>{{ $t('admin_dash.auth.LDAP') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting path=":pleroma.:ldap.:enabled" />
</li>
<template v-if="LDAPEnabled">
<li>
<StringSetting path=":pleroma.:ldap.:host" />
</li>
<li>
<IntegerSetting path=":pleroma.:ldap.:port" />
</li>
<li>
<BooleanSetting path=":pleroma.:ldap.:tls" />
</li>
<li>
<!-- CONFIRM old admin FE only supports ONE setting which is Verify, is that correct or should we allow more than one? -->
<MapSetting
:allow-new="false"
path=":pleroma.:ldap.:tlsopts"
/>
</li>
<li>
<BooleanSetting path=":pleroma.:ldap.:ssl" />
</li>
<li>
<!-- CONFIRM old admin FE only supports ONE setting which is Verify, is that correct or should we allow more than one? -->
<MapSetting
:allow-new="false"
path=":pleroma.:ldap.:sslopts"
/>
</li>
<li>
<StringSetting path=":pleroma.:ldap.:base" />
</li>
<li>
<StringSetting path=":pleroma.:ldap.:uid" />
</li>
<li>
<StringSetting path=":pleroma.:ldap.:cacertfile" />
</li>
<li>
<StringSetting path=":pleroma.:ldap.:mail" />
</li>
</template>
</ul>
</div>
<!-- CONFIRM admin token is present in AdminFE but missing in both data and descriptions?? -->
</div>
</template>
<script src="./auth_tab.js"></script>

View file

@ -1,14 +1,29 @@
import { clone, assign } from 'lodash'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import StringSetting from '../helpers/string_setting.vue'
import Checkbox from 'components/checkbox/checkbox.vue'
import StillImage from 'components/still-image/still-image.vue'
import Select from 'components/select/select.vue'
import Popover from 'components/popover/popover.vue'
import ConfirmModal from 'components/confirm_modal/confirm_modal.vue'
import ModifiedIndicator from '../helpers/modified_indicator.vue'
import Select from 'components/select/select.vue'
import StillImage from 'components/still-image/still-image.vue'
import { assign, clone } from 'lodash'
import { defineAsyncComponent } from 'vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import EmojiEditingPopover from '../helpers/emoji_editing_popover.vue'
import { useInterfaceStore } from 'src/stores/interface'
import ModifiedIndicator from '../helpers/modified_indicator.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowsRotate,
faDownload,
faFolderOpen,
faServer,
} from '@fortawesome/free-solid-svg-icons'
library.add(faArrowsRotate, faFolderOpen, faDownload, faServer)
const EmojiTab = {
components: {
@ -18,16 +33,19 @@ const EmojiTab = {
StillImage,
Select,
Popover,
ConfirmModal,
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
ModifiedIndicator,
EmojiEditingPopover
EmojiEditingPopover,
},
data () {
data() {
return {
knownLocalPacks: { },
knownRemotePacks: { },
editedMetadata: { },
knownLocalPacks: {},
knownRemotePacks: {},
editedMetadata: {},
packName: '',
newPackName: '',
deleteModalVisible: false,
@ -35,169 +53,162 @@ const EmojiTab = {
remotePackDownloadAs: '',
remotePackURL: '',
remotePackFile: null
remotePackFile: null,
}
},
provide () {
provide() {
return { emojiAddr: this.emojiAddr }
},
computed: {
pack () {
...SharedComputedObject(),
pack() {
return this.packName !== '' ? this.knownPacks[this.packName] : undefined
},
packMeta () {
packMeta() {
if (this.packName === '') return {}
if (this.editedMetadata[this.packName] === undefined) {
this.editedMetadata[this.packName] = clone(this.pack.pack)
}
return this.editedMetadata[this.packName]
},
knownPacks () {
knownPacks() {
// Copy the object itself but not the children, so they are still passed by reference and modified
const result = clone(this.knownLocalPacks)
for (const instName in this.knownRemotePacks) {
for (const instPack in this.knownRemotePacks[instName]) {
result[`${instPack}@${instName}`] = this.knownRemotePacks[instName][instPack]
result[`${instPack}@${instName}`] =
this.knownRemotePacks[instName][instPack]
}
}
return result
},
downloadWillReplaceLocal () {
return (this.remotePackDownloadAs.trim() === '' && this.pack.remote && this.pack.remote.baseName in this.knownLocalPacks) ||
(this.remotePackDownloadAs in this.knownLocalPacks)
}
downloadWillReplaceLocal() {
return (
(this.remotePackDownloadAs.trim() === '' &&
this.pack.remote &&
this.pack.remote.baseName in this.knownLocalPacks) ||
this.remotePackDownloadAs in this.knownLocalPacks
)
},
},
methods: {
reloadEmoji () {
reloadEmoji() {
this.$store.state.api.backendInteractor.reloadEmoji()
},
importFromFS () {
importFromFS() {
this.$store.state.api.backendInteractor.importEmojiFromFS()
},
emojiAddr (name) {
emojiAddr(name) {
if (this.pack.remote !== undefined) {
// Remote pack
return `${this.pack.remote.instance}/emoji/${encodeURIComponent(this.pack.remote.baseName)}/${name}`
} else {
return `${this.$store.state.instance.server}/emoji/${encodeURIComponent(this.packName)}/${name}`
return `${useInstanceStore().server}/emoji/${encodeURIComponent(this.packName)}/${name}`
}
},
createEmojiPack () {
this.$store.state.api.backendInteractor.createEmojiPack(
{ name: this.newPackName }
).then(resp => resp.json()).then(resp => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
return Promise.reject(resp)
}
}).then(() => {
this.$refs.createPackPopover.hidePopover()
this.packName = this.newPackName
this.newPackName = ''
})
createEmojiPack() {
this.$store.state.api.backendInteractor
.createEmojiPack({ name: this.newPackName })
.then((resp) => resp.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
return Promise.reject(resp)
}
})
.then(() => {
this.packName = this.newPackName
this.newPackName = ''
})
},
deleteEmojiPack () {
this.$store.state.api.backendInteractor.deleteEmojiPack(
{ name: this.packName }
).then(resp => resp.json()).then(resp => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
return Promise.reject(resp)
}
}).then(() => {
delete this.editedMetadata[this.packName]
deleteEmojiPack() {
this.$store.state.api.backendInteractor
.deleteEmojiPack({ name: this.packName })
.then((resp) => resp.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
return Promise.reject(resp)
}
})
.then(() => {
delete this.editedMetadata[this.packName]
this.deleteModalVisible = false
this.packName = ''
})
this.deleteModalVisible = false
this.packName = ''
})
},
metaEdited (prop) {
metaEdited(prop) {
if (!this.pack) return
const def = this.pack.pack[prop] || ''
const edited = this.packMeta[prop] || ''
return edited !== def
},
savePackMetadata () {
this.$store.state.api.backendInteractor.saveEmojiPackMetadata({ name: this.packName, newData: this.packMeta }).then(
resp => resp.json()
).then(resp => {
if (resp.error !== undefined) {
this.displayError(resp.error)
return
}
savePackMetadata() {
this.$store.state.api.backendInteractor
.saveEmojiPackMetadata({ name: this.packName, newData: this.packMeta })
.then((resp) => resp.json())
.then((resp) => {
if (resp.error !== undefined) {
this.displayError(resp.error)
return
}
// Update actual pack data
this.pack.pack = resp
// Delete edited pack data, should auto-update itself
delete this.editedMetadata[this.packName]
})
// Update actual pack data
this.pack.pack = resp
// Delete edited pack data, should auto-update itself
delete this.editedMetadata[this.packName]
})
},
updatePackFiles (newFiles, packName) {
updatePackFiles(newFiles, packName) {
this.knownPacks[packName].files = newFiles
this.sortPackFiles(packName)
},
loadPacksPaginated (listFunction) {
const pageSize = 25
const allPacks = {}
return listFunction({ instance: this.remotePackInstance, page: 1, pageSize: 0 })
.then(data => data.json())
.then(data => {
if (data.error !== undefined) { return Promise.reject(data.error) }
let resultingPromise = Promise.resolve({})
for (let i = 0; i < Math.ceil(data.count / pageSize); i++) {
resultingPromise = resultingPromise.then(() => listFunction({ instance: this.remotePackInstance, page: i, pageSize })
).then(data => data.json()).then(pageData => {
if (pageData.error !== undefined) { return Promise.reject(pageData.error) }
assign(allPacks, pageData.packs)
})
}
return resultingPromise
})
.then(() => allPacks)
.catch(data => {
this.displayError(data)
})
},
refreshPackList () {
this.loadPacksPaginated(this.$store.state.api.backendInteractor.listEmojiPacks)
.then(allPacks => {
refreshPackList() {
useEmojiStore()
.getAdminPacks(
this.remotePackInstance,
this.$store.state.api.backendInteractor.listEmojiPacks,
)
.then((allPacks) => {
this.knownLocalPacks = allPacks
for (const name of Object.keys(this.knownLocalPacks)) {
this.sortPackFiles(name)
}
})
},
listRemotePacks () {
this.loadPacksPaginated(this.$store.state.api.backendInteractor.listRemoteEmojiPacks)
.then(allPacks => {
listRemotePacks() {
useEmojiStore()
.getAdminPacks(
this.remotePackInstance,
this.$store.state.api.backendInteractor.listRemoteEmojiPacks,
)
.then((allPacks) => {
let inst = this.remotePackInstance
if (!inst.startsWith('http')) { inst = 'https://' + inst }
if (!inst.startsWith('http')) {
inst = 'https://' + inst
}
const instUrl = new URL(inst)
inst = instUrl.host
for (const packName in allPacks) {
allPacks[packName].remote = {
baseName: packName,
instance: instUrl.origin
instance: instUrl.origin,
}
}
@ -205,98 +216,102 @@ const EmojiTab = {
for (const pack in this.knownRemotePacks[inst]) {
this.sortPackFiles(`${pack}@${inst}`)
}
this.$refs.remotePackPopover.hidePopover()
})
.catch(data => {
.catch((data) => {
this.displayError(data)
})
},
downloadRemotePack () {
downloadRemotePack() {
if (this.remotePackDownloadAs.trim() === '') {
this.remotePackDownloadAs = this.pack.remote.baseName
}
this.$store.state.api.backendInteractor.downloadRemoteEmojiPack({
instance: this.pack.remote.instance, packName: this.pack.remote.baseName, as: this.remotePackDownloadAs
})
.then(data => data.json())
.then(resp => {
this.$store.state.api.backendInteractor
.downloadRemoteEmojiPack({
instance: this.pack.remote.instance,
packName: this.pack.remote.baseName,
as: this.remotePackDownloadAs,
})
.then((data) => data.json())
.then((resp) => {
if (resp === 'ok') {
this.$refs.downloadPackPopover.hidePopover()
return this.refreshPackList()
} else {
this.displayError(resp.error)
return Promise.reject(resp)
}
}).then(() => {
})
.then(() => {
this.packName = this.remotePackDownloadAs
this.remotePackDownloadAs = ''
})
},
downloadRemoteURLPack () {
this.$store.state.api.backendInteractor.downloadRemoteEmojiPackZIP({
url: this.remotePackURL, packName: this.newPackName
})
.then(data => data.json())
.then(resp => {
downloadRemoteURLPack() {
this.$store.state.api.backendInteractor
.downloadRemoteEmojiPackZIP({
url: this.remotePackURL,
packName: this.newPackName,
})
.then((data) => data.json())
.then((resp) => {
if (resp === 'ok') {
this.$refs.additionalRemotePopover.hidePopover()
return this.refreshPackList()
} else {
this.displayError(resp.error)
return Promise.reject(resp)
}
}).then(() => {
})
.then(() => {
this.packName = this.newPackName
this.newPackName = ''
this.remotePackURL = ''
})
},
downloadRemoteFilePack () {
this.$store.state.api.backendInteractor.downloadRemoteEmojiPackZIP({
file: this.remotePackFile[0], packName: this.newPackName
})
.then(data => data.json())
.then(resp => {
downloadRemoteFilePack() {
this.$store.state.api.backendInteractor
.downloadRemoteEmojiPackZIP({
file: this.remotePackFile[0],
packName: this.newPackName,
})
.then((data) => data.json())
.then((resp) => {
if (resp === 'ok') {
this.$refs.additionalRemotePopover.hidePopover()
return this.refreshPackList()
} else {
this.displayError(resp.error)
return Promise.reject(resp)
}
}).then(() => {
})
.then(() => {
this.packName = this.newPackName
this.newPackName = ''
this.remotePackURL = ''
})
},
displayError (msg) {
displayError(msg) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'admin_dash.emoji.error',
messageArgs: [msg],
level: 'error'
level: 'error',
})
},
sortPackFiles (nameOfPack) {
sortPackFiles(nameOfPack) {
// Sort by key
const sorted = Object.keys(this.knownPacks[nameOfPack].files).sort().reduce((acc, key) => {
if (key.length === 0) return acc
acc[key] = this.knownPacks[nameOfPack].files[key]
return acc
}, {})
const sorted = Object.keys(this.knownPacks[nameOfPack].files)
.sort()
.reduce((acc, key) => {
if (key.length === 0) return acc
acc[key] = this.knownPacks[nameOfPack].files[key]
return acc
}, {})
this.knownPacks[nameOfPack].files = sorted
}
},
},
mounted () {
mounted() {
this.refreshPackList()
}
},
}
export default EmojiTab

View file

@ -1,10 +1,54 @@
.emoji-tab {
.btn-group .btn:not(:first-child) {
margin-left: 0.5em;
.EmojiTab {
.setting-list {
margin: 0.5em 2em;
}
.pack-info-wrapper {
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5em;
.header-buttons {
display: flex;
flex: 1 0 auto;
justify-content: end;
align-items: end;
&:not(.btn-group) {
gap: 0.5em;
}
.header-text {
flex: 1 0 auto;
}
}
.button-default {
flex: 0 0 auto;
padding: 0.5em;
font-size: 1rem;
}
.popover-wrapper {
display: flex;
}
}
.selector-buttons,
.meta-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5em;
margin-left: 1em;
}
h5 {
margin-top: 1em;
margin-bottom: 0.25em;
}
h3.toolbar {
align-items: end;
}
.emoji-info-input {
@ -18,8 +62,8 @@
}
.emoji {
width: 32px;
height: 32px;
width: 2em;
height: 2em;
}
.emoji-unsaved {
@ -30,6 +74,22 @@
display: flex;
flex-wrap: wrap;
gap: 1em;
.button-unstyled {
display: flex;
}
.emoji-item,
.placeholder {
width: 2em;
height: 2em;
opacity: 0.5;
}
.placeholder {
background: var(--textFaint);
border-radius: 0.5em;
}
}
}

View file

@ -1,168 +1,75 @@
<template>
<div
class="emoji-tab"
class="EmojiTab"
:label="$t('admin_dash.tabs.emoji')"
>
<div class="setting-item">
<h2>{{ $t('admin_dash.tabs.emoji') }}</h2>
<div class="setting-section">
<h3 class="toolbar">
<span class="header-text">
{{ $t('admin_dash.emoji.emoji_packs') }}
</span>
<ul class="setting-list">
<h3>{{ $t('admin_dash.emoji.global_actions') }}</h3>
<li class="btn-group setting-item">
<span class="header-buttons btn-group">
<button
class="button button-default btn"
class="button button-default"
type="button"
:title="$t('admin_dash.emoji.reload')"
@click="reloadEmoji"
>
{{ $t('admin_dash.emoji.reload') }}
</button>
<button
class="button button-default btn"
type="button"
@click="importFromFS"
>
{{ $t('admin_dash.emoji.importFS') }}
</button>
</li>
<li class="btn-group setting-item">
<button
class="button button-default btn"
type="button"
@click="$refs.remotePackPopover.showPopover"
>
{{ $t('admin_dash.emoji.remote_packs') }}
<Popover
ref="remotePackPopover"
popover-class="emoji-tab-edit-popover popover-default"
trigger="click"
placement="bottom"
bound-to-selector=".emoji-tab"
:bound-to="{ x: 'container' }"
:offset="{ y: 5 }"
>
<template #content>
<div class="emoji-tab-popover-input">
<h3>{{ $t('admin_dash.emoji.remote_pack_instance') }}</h3>
<input
v-model="remotePackInstance"
class="input"
:placeholder="$t('admin_dash.emoji.remote_pack_instance')"
>
<button
class="button button-default btn emoji-tab-popover-button"
type="button"
@click="listRemotePacks"
>
{{ $t('admin_dash.emoji.do_list') }}
</button>
</div>
</template>
</Popover>
</button>
<button
class="button button-default emoji-panel-additional-actions"
@click="$refs.additionalRemotePopover.showPopover"
>
<FAIcon
icon="chevron-down"
/>
<Popover
ref="additionalRemotePopover"
popover-class="emoji-tab-edit-popover popover-default"
trigger="click"
placement="bottom"
bound-to-selector=".emoji-tab"
:bound-to="{ x: 'container' }"
:offset="{ y: 5 }"
>
<template #content>
<div class="emoji-tab-popover-input">
<h3>{{ $t('admin_dash.emoji.new_pack_name') }}</h3>
<input
v-model="newPackName"
:placeholder="$t('admin_dash.emoji.new_pack_name')"
class="input"
>
<h3>Import pack from URL</h3>
<input
v-model="remotePackURL"
class="input"
placeholder="Pack .zip URL"
>
<button
class="button button-default btn emoji-tab-popover-button"
type="button"
:disabled="newPackName.trim() === '' || remotePackURL.trim() === ''"
@click="downloadRemoteURLPack"
>
Import
</button>
<h3>Import pack from a file</h3>
<input
type="file"
accept="application/zip"
class="emoji-tab-popover-file input"
@change="remotePackFile = $event.target.files"
>
<button
class="button button-default btn emoji-tab-popover-button"
type="button"
:disabled="newPackName.trim() === '' || remotePackFile === null || remotePackFile.length === 0"
@click="downloadRemoteFilePack"
>
Import
</button>
</div>
</template>
</Popover>
</button>
</li>
<h3>{{ $t('admin_dash.emoji.emoji_packs') }}</h3>
<li>
<h4>{{ $t('admin_dash.emoji.edit_pack') }}</h4>
<Select
v-model="packName"
class="form-control"
>
<option
value=""
disabled
hidden
>
{{ $t('admin_dash.emoji.emoji_pack') }}
</option>
<option
v-for="(pack, listPackName) in knownPacks"
:key="listPackName"
:label="listPackName"
>
{{ listPackName }}
</option>
</Select>
<button
class="button button-default btn emoji-tab-popover-button"
type="button"
@click="$refs.createPackPopover.showPopover"
>
{{ $t('admin_dash.emoji.create_pack') }}
<FAIcon icon="arrows-rotate" />
{{ $t('admin_dash.emoji.reload_short') }}
</button>
<Popover
ref="createPackPopover"
popover-class="emoji-tab-edit-popover popover-default"
trigger="click"
placement="bottom"
bound-to-selector=".emoji-tab"
:bound-to="{ x: 'container' }"
:offset="{ y: 5 }"
>
<template #trigger>
<button
class="button button-default"
type="button"
:title="$t('admin_dash.emoji.remote_packs')"
>
<FAIcon icon="download" />
{{ $t('admin_dash.emoji.remote_packs_short') }}
</button>
</template>
<template #content>
<div class="emoji-tab-popover-input">
<h3>{{ $t('admin_dash.emoji.remote_pack_instance') }}</h3>
<input
v-model="remotePackInstance"
class="input"
:placeholder="$t('admin_dash.emoji.remote_pack_instance')"
>
<button
class="button button-default emoji-tab-popover-button"
type="button"
@click="listRemotePacks"
>
{{ $t('admin_dash.emoji.do_list') }}
</button>
</div>
</template>
</Popover>
<Popover
ref="additionalRemotePopover"
popover-class="emoji-tab-edit-popover popover-default"
trigger="click"
placement="bottom"
>
<template #trigger>
<button
class="button button-default emoji-panel-additional-actions"
:title="$t('admin_dash.emoji.import_pack')"
@click="$refs.additionalRemotePopover.showPopover"
>
<FAIcon icon="folder-open" />
{{ $t('admin_dash.emoji.import_pack_short') }}
</button>
</template>
<template #content>
<div class="emoji-tab-popover-input">
<h3>{{ $t('admin_dash.emoji.new_pack_name') }}</h3>
@ -171,94 +78,276 @@
:placeholder="$t('admin_dash.emoji.new_pack_name')"
class="input"
>
<h3>Import pack from URL</h3>
<input
v-model="remotePackURL"
class="input"
placeholder="Pack .zip URL"
>
<button
class="button button-default btn emoji-tab-popover-button"
type="button"
@click="createEmojiPack"
:disabled="newPackName.trim() === '' || remotePackURL.trim() === ''"
@click="downloadRemoteURLPack"
>
{{ $t('admin_dash.emoji.create') }}
Import
</button>
<h3>Import pack from a file</h3>
<input
type="file"
accept="application/zip"
class="emoji-tab-popover-file input"
@change="remotePackFile = $event.target.files"
>
<button
class="button button-default btn emoji-tab-popover-button"
type="button"
:disabled="newPackName.trim() === '' || remotePackFile === null || remotePackFile.length === 0"
@click="downloadRemoteFilePack"
>
Import
</button>
</div>
</template>
</Popover>
</li>
</ul>
</span>
</h3>
<div class="setting-section">
<h4 class="toolbar">
{{ $t('admin_dash.emoji.edit_pack') }}
</h4>
<div class="setting-item selector-buttons">
<button
:disabled="!pack || pack.remote !== undefined"
class="button button-default btn"
type="button"
@click="deleteModalVisible = true"
>
{{ $t('admin_dash.emoji.delete_pack') }}
<div v-if="pack">
<div class="pack-info-wrapper">
<ul class="setting-list">
<li>
<label>
{{ $t('admin_dash.emoji.description') }}
<ConfirmModal
v-if="deleteModalVisible"
:title="$t('admin_dash.emoji.delete_title')"
:cancel-text="$t('status.delete_confirm_cancel_button')"
:confirm-text="$t('status.delete_confirm_accept_button')"
@cancelled="deleteModalVisible = false"
@accepted="deleteEmojiPack"
>
{{ $t('admin_dash.emoji.delete_confirm', [packName]) }}
</ConfirmModal>
</button>
<button
:disabled="!pack || pack.remote === undefined"
class="button button-default btn"
type="button"
@click="$refs.downloadPackPopover.showPopover"
>
{{ $t('admin_dash.emoji.download_pack') }}
<Popover
ref="downloadPackPopover"
trigger="click"
placement="bottom"
bound-to-selector=".emoji-tab"
popover-class="emoji-tab-edit-popover popover-default"
:bound-to="{ x: 'container' }"
:offset="{ y: 5 }"
>
<template #content>
<h3>{{ $t('admin_dash.emoji.downloading_pack', [packName]) }}</h3>
<div>
<div>
<div class="emoji-tab-popover-input">
<label>
{{ $t('admin_dash.emoji.download_as_name') }}
<input
v-model="remotePackDownloadAs"
class="emoji-data-input input"
:placeholder="$t('admin_dash.emoji.download_as_name_full')"
>
</label>
<div
v-if="downloadWillReplaceLocal"
class="warning"
>
<em>{{ $t('admin_dash.emoji.replace_warning') }}</em>
</div>
</div>
<button
class="button button-default btn"
type="button"
@click="downloadRemotePack"
>
{{ $t('admin_dash.emoji.download') }}
</button>
</div>
</div>
</template>
</Popover>
</button>
<span class="btn-group">
<Select
v-model="packName"
class="form-control"
>
<option
value=""
disabled
hidden
>
{{ $t('admin_dash.emoji.emoji_pack') }}
</option>
<option
v-for="(pack, listPackName) in knownPacks"
:key="listPackName"
:label="listPackName"
>
{{ listPackName }}
</option>
</Select>
<Popover
ref="createPackPopover"
popover-class="emoji-tab-edit-popover popover-default"
trigger="click"
placement="bottom"
>
<template #trigger>
<button
class="button button-default btn emoji-tab-popover-button"
type="button"
>
{{ $t('admin_dash.emoji.create_pack') }}
</button>
</template>
<template #content>
<div class="emoji-tab-popover-input">
<h3>{{ $t('admin_dash.emoji.new_pack_name') }}</h3>
<input
v-model="newPackName"
:placeholder="$t('admin_dash.emoji.new_pack_name')"
class="input"
>
<button
class="button button-default btn emoji-tab-popover-button"
type="button"
@click="createEmojiPack"
>
{{ $t('admin_dash.emoji.create') }}
</button>
</div>
</template>
</Popover>
</span>
</div>
<h5>
{{ $t('admin_dash.emoji.metadata') }}
<ModifiedIndicator
:changed="$refs.emojiPopovers && $refs.emojiPopovers.some(p => p.isEdited)"
message-key="admin_dash.emoji.emoji_changed"
/>
</h5>
<ul class="setting-list">
<li>
<label
class="setting-item"
:class="{ ['-disabled']: !pack || pack.remote !== undefined }"
>
<span class="setting-label">
<ModifiedIndicator
:changed="metaEdited('description')"
message-key="admin_dash.emoji.metadata_changed"
/>
<textarea
v-model="packMeta.description"
:disabled="pack.remote !== undefined"
class="bio resize-height input"
/>
</label>
</li>
<li>
<label>
{{ $t('admin_dash.emoji.homepage') }}
{{ $t('admin_dash.emoji.description') }}
</span>
<textarea
v-model="packMeta.description"
:disabled="!pack || pack.remote !== undefined"
height="4"
class="bio resize-height input textarea setting-control"
/>
</label>
</li>
<li>
<label
class="setting-item"
:class="{ ['-disabled']: !pack || pack.remote !== undefined }"
>
<span class="setting-label">
<ModifiedIndicator
:changed="metaEdited('homepage')"
message-key="admin_dash.emoji.metadata_changed"
/>
{{ $t('admin_dash.emoji.homepage') }}
</span>
<input
v-model="packMeta.homepage"
class="emoji-info-input input"
:disabled="pack.remote !== undefined"
>
</label>
</li>
<li>
<label>
{{ $t('admin_dash.emoji.fallback_src') }}
<input
v-model="packMeta.homepage"
class="emoji-info-input input setting-control"
:disabled="!pack || pack.remote !== undefined"
>
</label>
</li>
<li>
<label
class="setting-item"
:class="{ ['-disabled']: !pack || pack.remote !== undefined }"
>
<span class="setting-label">
<ModifiedIndicator
:changed="metaEdited('fallback-src')"
message-key="admin_dash.emoji.metadata_changed"
/>
{{ $t('admin_dash.emoji.fallback_src') }}
</span>
<input
v-model="packMeta['fallback-src']"
class="emoji-info-input input"
:disabled="pack.remote !== undefined"
>
</label>
</li>
<li>
<label>
<input
v-model="packMeta['fallback-src']"
class="emoji-info-input input setting-control"
:disabled="!pack || pack.remote !== undefined"
>
</label>
</li>
<li>
<label
class="setting-item"
:class="{ ['-disabled']: !pack || pack.remote !== undefined }"
>
<span class="setting-label">
{{ $t('admin_dash.emoji.fallback_sha256') }}
</span>
<input
v-model="packMeta['fallback-src-sha256']"
:disabled="true"
class="emoji-info-input input"
>
</label>
</li>
<li>
<input
v-model="packMeta['fallback-src-sha256']"
:disabled="!pack || pack.remote !== undefined"
class="emoji-info-input input setting-control"
>
</label>
</li>
<li>
<div class="setting-item">
<Checkbox
v-model="packMeta['share-files']"
:disabled="pack.remote !== undefined"
:disabled="!pack || pack.remote !== undefined"
class="setting-label setting-control"
>
<ModifiedIndicator
:changed="metaEdited('share-files')"
message-key="admin_dash.emoji.metadata_changed"
/>
{{ $t('admin_dash.emoji.share') }}
</Checkbox>
<ModifiedIndicator
:changed="metaEdited('share-files')"
message-key="admin_dash.emoji.metadata_changed"
/>
</li>
<li class="btn-group">
</div>
</li>
<li>
<div class="meta-buttons">
<button
v-if="pack.remote === undefined"
v-if="pack && pack.remote === undefined"
class="button button-default btn"
type="button"
@click="savePackMetadata"
@ -266,148 +355,89 @@
{{ $t('admin_dash.emoji.save_meta') }}
</button>
<button
v-if="pack.remote === undefined"
v-if="pack && pack.remote === undefined"
class="button button-default btn"
type="button"
@click="savePackMetadata"
>
{{ $t('admin_dash.emoji.revert_meta') }}
</button>
<button
v-if="pack.remote === undefined"
class="button button-default btn"
type="button"
@click="deleteModalVisible = true"
>
{{ $t('admin_dash.emoji.delete_pack') }}
<ConfirmModal
v-if="deleteModalVisible"
:title="$t('admin_dash.emoji.delete_title')"
:cancel-text="$t('status.delete_confirm_cancel_button')"
:confirm-text="$t('status.delete_confirm_accept_button')"
@cancelled="deleteModalVisible = false"
@accepted="deleteEmojiPack"
>
{{ $t('admin_dash.emoji.delete_confirm', [packName]) }}
</ConfirmModal>
</button>
<button
v-if="pack.remote !== undefined"
class="button button-default btn"
type="button"
@click="$refs.downloadPackPopover.showPopover"
>
{{ $t('admin_dash.emoji.download_pack') }}
<Popover
ref="downloadPackPopover"
trigger="click"
placement="bottom"
bound-to-selector=".emoji-tab"
popover-class="emoji-tab-edit-popover popover-default"
:bound-to="{ x: 'container' }"
:offset="{ y: 5 }"
>
<template #content>
<h3>{{ $t('admin_dash.emoji.downloading_pack', [packName]) }}</h3>
<div>
<div>
<div class="emoji-tab-popover-input">
<label>
{{ $t('admin_dash.emoji.download_as_name') }}
<input
v-model="remotePackDownloadAs"
class="emoji-data-input input"
:placeholder="$t('admin_dash.emoji.download_as_name_full')"
>
</label>
<div
v-if="downloadWillReplaceLocal"
class="warning"
>
<em>{{ $t('admin_dash.emoji.replace_warning') }}</em>
</div>
</div>
<button
class="button button-default btn"
type="button"
@click="downloadRemotePack"
>
{{ $t('admin_dash.emoji.download') }}
</button>
</div>
</div>
</template>
</Popover>
</button>
</li>
</ul>
</div>
<ul class="setting-list">
<h4>
{{ $t('admin_dash.emoji.files') }}
<ModifiedIndicator
v-if="pack"
:changed="$refs.emojiPopovers && $refs.emojiPopovers.some(p => p.isEdited)"
message-key="admin_dash.emoji.emoji_changed"
/>
</h4>
<div
v-if="pack"
class="emoji-list"
>
<EmojiEditingPopover
v-if="pack.remote === undefined"
placement="bottom"
new-upload
:title="$t('admin_dash.emoji.adding_new')"
:pack-name="packName"
@update-pack-files="updatePackFiles"
@display-error="displayError"
>
<template #trigger>
<FAIcon
icon="plus"
size="2x"
:title="$t('admin_dash.emoji.add_file')"
/>
</template>
</EmojiEditingPopover>
<EmojiEditingPopover
v-for="(file, shortcode) in pack.files"
ref="emojiPopovers"
:key="shortcode"
placement="top"
:title="$t(`admin_dash.emoji.${pack.remote === undefined ? 'editing' : 'copying'}`, [shortcode])"
:shortcode="shortcode"
:file="file"
:pack-name="packName"
:remote="pack.remote"
:known-local-packs="knownLocalPacks"
@update-pack-files="updatePackFiles"
@display-error="displayError"
>
<template #trigger>
<StillImage
class="emoji"
:src="emojiAddr(file)"
:title="`:${shortcode}:`"
:alt="`:${shortcode}:`"
/>
</template>
</EmojiEditingPopover>
</div>
</div>
</li>
</ul>
<h5>
{{ $t('admin_dash.emoji.files') }}
<ModifiedIndicator
:changed="$refs.emojiPopovers && $refs.emojiPopovers.some(p => p.isEdited)"
message-key="admin_dash.emoji.emoji_changed"
/>
</h5>
<div
class="emoji-list setting-list"
>
<EmojiEditingPopover
v-if="pack && pack.remote === undefined"
class="emoji-item"
placement="bottom"
new-upload
:title="$t('admin_dash.emoji.adding_new')"
:pack-name="packName"
@update-pack-files="updatePackFiles"
@display-error="displayError"
>
<template #trigger>
<FAIcon
icon="plus"
size="2x"
:title="$t('admin_dash.emoji.add_file')"
/>
</template>
</EmojiEditingPopover>
<template v-if="!pack">
<div
v-for="(_, i) in new Array(20)"
:key="i"
class="placeholder"
/>
</template>
<EmojiEditingPopover
v-for="(file, shortcode) in (pack?.files || [])"
ref="emojiPopovers"
:key="shortcode"
placement="top"
:title="$t(`admin_dash.emoji.${pack?.remote === undefined ? 'editing' : 'copying'}`, [shortcode])"
:shortcode="shortcode"
:file="file"
:pack-name="packName"
:remote="pack?.remote"
:known-local-packs="knownLocalPacks"
@update-pack-files="updatePackFiles"
@display-error="displayError"
>
<template #trigger>
<StillImage
class="emoji"
:src="emojiAddr(file)"
:title="`:${shortcode}:`"
:alt="`:${shortcode}:`"
/>
</template>
</EmojiEditingPopover>
</div>
</div>
<h3>{{ $t('admin_dash.emoji.advanced') }}</h3>
<button
class="button button-default btn"
type="button"
@click="importFromFS"
>
<FAIcon icon="server" />
{{ $t('admin_dash.emoji.importFS') }}
</button>
</div>
</div>
</template>

View file

@ -0,0 +1,33 @@
import AttachmentSetting from '../helpers/attachment_setting.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import GroupSetting from '../helpers/group_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import ListSetting from '../helpers/list_setting.vue'
import ListTupleSetting from '../helpers/list_tuple_setting.vue'
import MapSetting from '../helpers/map_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
const FederationTab = {
provide() {
return {
defaultDraftMode: true,
defaultSource: 'admin',
}
},
components: {
BooleanSetting,
IntegerSetting,
StringSetting,
AttachmentSetting,
ListSetting,
ListTupleSetting,
GroupSetting,
MapSetting,
},
computed: {
...SharedComputedObject(),
},
}
export default FederationTab

View file

@ -0,0 +1,65 @@
<template>
<div :label="$t('admin_dash.tabs.federation')">
<div class="setting-section">
<h3>{{ $t('admin_dash.federation.global') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting path=":pleroma.:instance.:federating" />
</li>
<li>
<BooleanSetting path=":pleroma.:instance.:skip_thread_containment" />
</li>
<li>
<BooleanSetting path=":pleroma.:instance.:external_user_synchronization" />
</li>
</ul>
<h3>{{ $t('admin_dash.federation.restrictions') }}</h3>
<ul class="setting-list">
<li>
<MapSetting path=":pleroma.:instance.:quarantined_instances" />
</li>
<li>
<MapSetting path=":pleroma.:instance.:rejected_instances" />
</li>
</ul>
<h3>{{ $t('admin_dash.federation.activitypub') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting path=":pleroma.:activitypub.:unfollow_blocked" />
</li>
<li>
<BooleanSetting path=":pleroma.:activitypub.:outgoing_blocks" />
</li>
<li>
<BooleanSetting path=":pleroma.:activitypub.:blockers_visible" />
</li>
<li>
<IntegerSetting path=":pleroma.:activitypub.:follow_handshake_timeout" />
</li>
<li>
<IntegerSetting path=":pleroma.:activitypub.:note_replies_output_limit" />
</li>
<li>
<IntegerSetting path=":pleroma.:instance.:federation_incoming_replies_max_depth" />
</li>
<li>
<IntegerSetting path=":pleroma.:instance.:federation_reachability_timeout_days" />
</li>
<li>
<BooleanSetting path=":pleroma.:instance.:allow_relay" />
</li>
<li>
<BooleanSetting path=":pleroma.:activitypub.:sign_object_fetches" />
</li>
<li>
<BooleanSetting path=":pleroma.:activitypub.:authorized_fetch_mode" />
</li>
<li>
<BooleanSetting path=":pleroma.:activitypub.:client_api_enabled" />
</li>
</ul>
</div>
</div>
</template>
<script src="./federation_tab.js"></script>

View file

@ -1,32 +1,29 @@
import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
import Popover from 'src/components/popover/popover.vue'
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 PanelLoading from 'src/components/panel_loading/panel_loading.vue'
import { useInterfaceStore } from 'src/stores/interface'
import IntegerSetting from '../helpers/integer_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'
import StringSetting from '../helpers/string_setting.vue'
library.add(
faGlobe
)
import { useInterfaceStore } from 'src/stores/interface.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faGlobe } from '@fortawesome/free-solid-svg-icons'
library.add(faGlobe)
const FrontendsTab = {
provide () {
provide() {
return {
defaultDraftMode: true,
defaultSource: 'admin'
defaultSource: 'admin',
}
},
data () {
data() {
return {
working: false
working: false,
}
},
components: {
@ -36,26 +33,26 @@ const FrontendsTab = {
StringSetting,
GroupSetting,
PanelLoading,
Popover
Popover,
},
created () {
created() {
if (this.user.rights.admin) {
this.$store.dispatch('loadFrontendsStuff')
}
},
computed: {
frontends () {
...SharedComputedObject(),
frontends() {
return this.$store.state.adminSettings.frontends
},
...SharedComputedObject()
},
methods: {
canInstall (frontend) {
const fe = this.frontends.find(f => f.name === frontend.name)
canInstall(frontend) {
const fe = this.frontends.find((f) => f.name === frontend.name)
if (!fe) return false
return fe.refs.includes(frontend.ref)
},
getSuggestedRef (frontend) {
getSuggestedRef(frontend) {
if (this.adminDraft) {
const defaultFe = this.adminDraft[':pleroma'][':frontends'][':primary']
if (defaultFe?.name === frontend.name && this.canInstall(defaultFe)) {
@ -67,13 +64,14 @@ const FrontendsTab = {
return frontend.refs[0]
}
},
update (frontend, suggestRef) {
update(frontend, suggestRef) {
const ref = suggestRef || this.getSuggestedRef(frontend)
const { name } = frontend
const payload = { name, ref }
this.working = true
this.$store.state.api.backendInteractor.installFrontend({ payload })
this.$store.state.api.backendInteractor
.installFrontend({ payload })
.finally(() => {
this.working = false
})
@ -86,29 +84,32 @@ const FrontendsTab = {
messageKey: 'admin_dash.frontend.failure_installing_frontend',
messageArgs: {
version: name + '/' + ref,
reason: reason.error
reason: reason.error,
},
timeout: 5000
timeout: 5000,
})
} else {
useInterfaceStore().pushGlobalNotice({
level: 'success',
messageKey: 'admin_dash.frontend.success_installing_frontend',
messageArgs: {
version: name + '/' + ref
version: name + '/' + ref,
},
timeout: 2000
timeout: 2000,
})
}
})
},
setDefault (frontend, suggestRef) {
setDefault(frontend, suggestRef) {
const ref = suggestRef || this.getSuggestedRef(frontend)
const { name } = frontend
this.$store.commit('updateAdminDraft', { path: [':pleroma', ':frontends', ':primary'], value: { name, ref } })
}
}
this.$store.commit('updateAdminDraft', {
path: [':pleroma', ':frontends', ':primary'],
value: { name, ref },
})
},
},
}
export default FrontendsTab

View file

@ -1,8 +1,24 @@
.frontends-tab {
.FrontendsTab {
.cards-list {
padding: 0;
}
li.frontend-card {
display: flex;
margin: 0;
flex-direction: column;
}
.frontend-buttons {
margin-top: 0.5em;
display: flex;
justify-content: end;
gap: 0.5em;
flex-wrap: wrap;
flex: 1 0 auto;
align-items: end;
}
.relative {
position: relative;
}
@ -16,10 +32,26 @@
inset: 0;
}
h5 {
margin: 0;
font-size: 1.15em
}
dl {
margin-left: 1em;
}
dt {
margin-top: 0.5em;
text-overflow: ellipsis;
white-space: nowrap;
overflow-x: hidden;
}
dd {
text-overflow: ellipsis;
white-space: nowrap;
overflow-x: hidden;
max-width: 10em;
}
}

View file

@ -1,17 +1,17 @@
<template>
<div
class="frontends-tab"
class="FrontendsTab"
:label="$t('admin_dash.tabs.frontends')"
>
<div class="setting-item">
<h2>{{ $t('admin_dash.tabs.frontends') }}</h2>
<div class="setting-section">
<h3>{{ $t('admin_dash.frontend.title') }}</h3>
<p>{{ $t('admin_dash.frontend.wip_notice') }}</p>
<ul
v-if="adminDraft"
class="setting-list"
>
<li>
<h3>{{ $t('admin_dash.frontend.default_frontend') }}</h3>
<h4>{{ $t('admin_dash.frontend.default_frontend') }}</h4>
<p>{{ $t('admin_dash.frontend.default_frontend_tip') }}</p>
<ul class="setting-list">
<li>
@ -38,13 +38,14 @@
v-if="working"
class="overlay"
/>
<h3>{{ $t('admin_dash.frontend.available_frontends') }}</h3>
<h4>{{ $t('admin_dash.frontend.available_frontends') }}</h4>
<ul class="cards-list">
<li
v-for="frontend in frontends"
:key="frontend.name"
class="frontend-card"
>
<strong>{{ frontend.name }}</strong>
<h5>{{ frontend.name }}</h5>
{{ ' ' }}
<span v-if="adminDraft && adminDraft[':pleroma'][':frontends'][':primary']?.name === frontend.name">
<i18n-t
@ -89,7 +90,7 @@
>{{ frontend.build_url }}</a>
</dd>
</dl>
<div>
<div class="frontend-buttons">
<span class="btn-group">
<button
class="button button-default btn"

View file

@ -0,0 +1,51 @@
import { get } from 'lodash'
import AttachmentSetting from '../helpers/attachment_setting.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import GroupSetting from '../helpers/group_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import ListSetting from '../helpers/list_setting.vue'
import MapSetting from '../helpers/map_setting.vue'
import ProxySetting from '../helpers/proxy_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
import TupleSetting from '../helpers/tuple_setting.vue'
const HTTPTab = {
provide() {
return {
defaultDraftMode: true,
defaultSource: 'admin',
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
StringSetting,
AttachmentSetting,
MapSetting,
GroupSetting,
ListSetting,
TupleSetting,
ProxySetting,
},
computed: {
...SharedComputedObject(),
sslOptions() {
const desc = get(
this.$store.state.adminSettings.descriptions,
':pleroma.:http.:adapter.:ssl_options.:versions',
)
return new Set(
desc.suggestions.map((option) => ({
label: option.replace(':tlsv', 'TLS v'),
value: option,
})),
)
},
},
}
export default HTTPTab

View file

@ -0,0 +1,86 @@
<template>
<div
class="LinksTab"
:label="$t('admin_dash.tabs.http')"
>
<div class="setting-section">
<h3>{{ $t('admin_dash.http.outbound') }}</h3>
<ul class="setting-list">
<li>
<ProxySetting
hide-description
path=":pleroma.:http.:proxy_url"
/>
</li>
<li>
<BooleanSetting path=":pleroma.:http.:send_user_agent" />
</li>
<li>
<StringSetting path=":pleroma.:http.:user_agent" />
</li>
<li>
<ListSetting
override-available-options
:options="sslOptions"
path=":pleroma.:http.:adapter.:ssl_options.:versions"
/>
</li>
<li>
<GroupSetting path=":pleroma.:http.:adapter" />
</li>
</ul>
</div>
<div class="setting-section">
<h3>{{ $t('admin_dash.http.incoming') }}</h3>
<ul class="setting-list">
<h4>{{ $t('admin_dash.http.security') }}</h4>
<li>
<BooleanSetting path=":pleroma.:http_security.:enabled" />
</li>
<li>
<BooleanSetting path=":pleroma.:http_security.:sts" />
</li>
<li>
<IntegerSetting path=":pleroma.:http_security.:sts_max_age" />
</li>
<li>
<IntegerSetting path=":pleroma.:http_security.:ct_max_age" />
</li>
<li>
<StringSetting path=":pleroma.:http_security.:referrer_policy" />
</li>
<li>
<BooleanSetting path=":pleroma.:http_security.:allow_unsafe_eval" />
</li>
<li>
<StringSetting path=":pleroma.:http_security.:report_url" />
</li>
</ul>
<h3>{{ $t('admin_dash.http.web_push') }}</h3>
<p>{{ $t('admin_dash.http.web_push_description') }}</p>
<ul class="setting-list">
<li>
<StringSetting
path=":web_push_encryption.:vapid_details.:subject"
/>
</li>
<li>
<StringSetting
path=":web_push_encryption.:vapid_details.:public_key"
/>
</li>
<li>
<StringSetting
path=":web_push_encryption.:vapid_details.:private_key"
/>
</li>
</ul>
<!-- CONFIRM admin_token should go there but something is wrong with both data and description. -->
<!-- given the nature of the setting it's probably better to not expose it and deprecate it on backend side -->
</div>
</div>
</template>
<!--<style lang="scss" src="./http_tab.scss"></style>-->
<script src="./http_tab.js"></script>

View file

@ -1,25 +1,22 @@
import { get } from 'lodash'
import AttachmentSetting from '../helpers/attachment_setting.vue'
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 ColorSetting from '../helpers/color_setting.vue'
import GroupSetting from '../helpers/group_setting.vue'
import AttachmentSetting from '../helpers/attachment_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import ListSetting from '../helpers/list_setting.vue'
import MapSetting from '../helpers/map_setting.vue'
import PWAManifestIconsSetting from '../helpers/pwa_manifest_icons_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
)
import StringSetting from '../helpers/string_setting.vue'
const InstanceTab = {
provide () {
provide() {
return {
defaultDraftMode: true,
defaultSource: 'admin'
defaultSource: 'admin',
}
},
components: {
@ -27,12 +24,45 @@ const InstanceTab = {
ChoiceSetting,
IntegerSetting,
StringSetting,
ColorSetting,
AttachmentSetting,
GroupSetting
ListSetting,
PWAManifestIconsSetting,
MapSetting,
GroupSetting,
},
computed: {
...SharedComputedObject()
}
...SharedComputedObject(),
providersOptions() {
const desc = get(this.$store.state.adminSettings.descriptions, [
':pleroma',
'Pleroma.Web.Metadata',
':providers',
])
return new Set(
desc.suggestions.map((option) => ({
label: option.replace('Pleroma.Web.Metadata.Providers.', ''),
value: option,
})),
)
},
limitLocalContentOptions() {
const desc = get(this.$store.state.adminSettings.descriptions, [
':pleroma',
':instance',
':limit_to_local_content',
])
return new Set(
desc.suggestions.map((option) => ({
label:
option !== 'false'
? this.$t('admin_dash.instance.' + option)
: this.$t('general.no'),
value: option,
})),
)
},
},
}
export default InstanceTab

View file

@ -1,17 +1,13 @@
<template>
<div :label="$t('admin_dash.tabs.instance')">
<div class="setting-item">
<h2>{{ $t('admin_dash.instance.instance') }}</h2>
<div class="setting-section">
<h3>{{ $t('admin_dash.instance.instance') }}</h3>
<ul class="setting-list">
<li>
<StringSetting path=":pleroma.:instance.:name" />
</li>
<!-- See https://git.pleroma.social/pleroma/pleroma/-/merge_requests/3963 -->
<li v-if="adminDraft[':pleroma'][':instance'][':favicon'] !== undefined">
<AttachmentSetting
compact
path=":pleroma.:instance.:favicon"
/>
<li>
<StringSetting path=":pleroma.:instance.:contact_username" />
</li>
<li>
<StringSetting path=":pleroma.:instance.:email" />
@ -22,87 +18,75 @@
<li>
<StringSetting path=":pleroma.:instance.:short_description" />
</li>
<li>
<ListSetting
force-new
ignore-suggestions
path=":pleroma.:instance.:languages"
/>
</li>
<li>
<StringSetting path=":pleroma.:instance.:status_page" />
</li>
</ul>
<h3>{{ $t('admin_dash.instance.branding') }}</h3>
<ul class="setting-list">
<!-- See https://git.pleroma.social/pleroma/pleroma/-/merge_requests/3963 -->
<li v-if="adminDraft[':pleroma'][':instance'][':favicon'] !== undefined">
<AttachmentSetting
compact
path=":pleroma.:instance.:favicon"
/>
</li>
<li>
<AttachmentSetting
compact
path=":pleroma.:instance.:instance_thumbnail"
/>
</li>
<h4>{{ $t('admin_dash.instance.pwa.manifest') }}</H4>
<li>
<PWAManifestIconsSetting path=":pleroma.:manifest.:icons" />
</li>
<li>
<ColorSetting
hide-draft-buttons
label=""
path=":pleroma.:manifest.:theme_color"
/>
</li>
<li>
<ColorSetting
hide-draft-buttons
path=":pleroma.:manifest.:background_color"
/>
</li>
<li>
<GroupSetting path=":pleroma.:manifest" />
</li>
<h4>{{ $t('admin_dash.instance.misc_brand') }}</H4>
<li>
<AttachmentSetting path=":pleroma.:instance.:background_image" />
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('admin_dash.instance.registrations') }}</h2>
<h3>{{ $t('admin_dash.instance.rich_metadata') }}</h3>
<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>
<ListSetting
override-available-options
:options="providersOptions"
:path="[':pleroma','Pleroma.Web.Metadata', ':providers']"
/>
</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>
<BooleanSetting
:path="[':pleroma','Pleroma.Web.Metadata', ':unfurl_nsfw']"
/>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('admin_dash.instance.access') }}</h2>
<div class="setting-section">
<h3>{{ $t('admin_dash.instance.access') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting
@ -115,6 +99,8 @@
<ChoiceSetting
override-backend-description
override-backend-description-label
override-available-options
:options="[...limitLocalContentOptions]"
path=":pleroma.:instance.:limit_to_local_content"
/>
</li>

View file

@ -0,0 +1,33 @@
import AttachmentSetting from '../helpers/attachment_setting.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import GroupSetting from '../helpers/group_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import ListSetting from '../helpers/list_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
import TupleSetting from '../helpers/tuple_setting.vue'
const JobQueuesTab = {
provide() {
return {
defaultDraftMode: true,
defaultSource: 'admin',
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
StringSetting,
TupleSetting,
AttachmentSetting,
GroupSetting,
ListSetting,
},
computed: {
...SharedComputedObject(),
},
}
export default JobQueuesTab

View file

@ -0,0 +1,157 @@
<template>
<div :label="$t('admin_dash.tabs.job_queues')">
<div class="setting-section">
<h3>{{ $t('admin_dash.job_queues.Gun.title') }}</h3>
<ul class="setting-list">
<li>
<h4>{{ $t('admin_dash.job_queues.Gun.connections_pools') }}</h4>
<ul class="setting-list">
<li>
<IntegerSetting path=":pleroma.:connections_pool.:connect_timeout" />
</li>
<li>
<IntegerSetting path=":pleroma.:connections_pool.:connection_acquisition_retries" />
</li>
<li>
<IntegerSetting path=":pleroma.:connections_pool.:connection_acquisition_wait" />
</li>
<li>
<!-- CONFIRM what is this -->
<IntegerSetting path=":pleroma.:connections_pool.:retry" />
</li>
<li>
<IntegerSetting path=":pleroma.:connections_pool.:max_connections" />
</li>
<li>
<!-- CONFIRM what is this -->
<IntegerSetting path=":pleroma.:connections_pool.:max_idle_time" />
</li>
<li>
<IntegerSetting path=":pleroma.:connections_pool.:reclaim_multiplier" />
</li>
</ul>
</li>
<li>
<h4>{{ $t('admin_dash.job_queues.Gun.pools.title') }}</h4>
<ul class="setting-list">
<li>
<h5>{{ $t('admin_dash.job_queues.Gun.pools.default') }}</h5>
<ul class="setting-list suboptions">
<li>
<IntegerSetting path=":pleroma.:pools.:default.:size" />
</li>
<li>
<IntegerSetting path=":pleroma.:pools.:default.:max_waiting" />
</li>
<li>
<IntegerSetting path=":pleroma.:pools.:default.:recv_timeout" />
</li>
</ul>
<GroupSetting path=":pleroma.:pools.:default" />
</li>
<li>
<h5>{{ $t('admin_dash.job_queues.Gun.pools.federation') }}</h5>
<ul class="setting-list suboptions">
<li>
<IntegerSetting path=":pleroma.:pools.:federation.:size" />
</li>
<li>
<IntegerSetting path=":pleroma.:pools.:federation.:max_waiting" />
</li>
<li>
<IntegerSetting path=":pleroma.:pools.:federation.:recv_timeout" />
</li>
</ul>
<GroupSetting path=":pleroma.:pools.:federation" />
</li>
<li>
<!-- CONFIRM what is this? -->
<h5>{{ $t('admin_dash.job_queues.Gun.pools.rich_media') }}</h5>
<ul class="setting-list suboptions">
<li>
<IntegerSetting path=":pleroma.:pools.:rich_media.:size" />
</li>
<li>
<IntegerSetting path=":pleroma.:pools.:rich_media.:max_waiting" />
</li>
<li>
<IntegerSetting path=":pleroma.:pools.:rich_media.:recv_timeout" />
</li>
</ul>
<GroupSetting path=":pleroma.:pools.:rich_media" />
</li>
<li>
<h5>{{ $t('admin_dash.job_queues.Gun.pools.media') }}</h5>
<ul class="setting-list suboptions">
<li>
<IntegerSetting path=":pleroma.:pools.:media.:size" />
</li>
<li>
<IntegerSetting path=":pleroma.:pools.:media.:max_waiting" />
</li>
<li>
<IntegerSetting path=":pleroma.:pools.:media.:recv_timeout" />
</li>
</ul>
<GroupSetting path=":pleroma.:pools.:media" />
</li>
</ul>
</li>
</ul>
<h3>{{ $t('admin_dash.job_queues.Hackney.title') }}</h3>
<ul class="setting-list">
<li>
<h4>{{ $t('admin_dash.job_queues.Hackney.federation') }}</h4>
<ul class="setting-list">
<li>
<IntegerSetting path=":pleroma.:hackney_pools.:federation.:max_connections" />
</li>
<li>
<IntegerSetting path=":pleroma.:hackney_pools.:federation.:timeout" />
</li>
</ul>
<GroupSetting path=":pleroma.:hackney_pools.:federation" />
</li>
<li>
<h4>{{ $t('admin_dash.job_queues.Hackney.media') }}</h4>
<ul class="setting-list">
<li>
<IntegerSetting path=":pleroma.:hackney_pools.:media.:max_connections" />
</li>
<li>
<IntegerSetting path=":pleroma.:hackney_pools.:media.:timeout" />
</li>
</ul>
<GroupSetting path=":pleroma.:hackney_pools.:media" />
</li>
<li>
<!-- CONFIRM what is this -->
<h4>{{ $t('admin_dash.job_queues.Hackney.rich_media') }}</h4>
<ul class="setting-list">
<li>
<IntegerSetting path=":pleroma.:hackney_pools.:rich_media.:max_connections" />
</li>
<li>
<IntegerSetting path=":pleroma.:hackney_pools.:rich_media.:timeout" />
</li>
</ul>
<GroupSetting path=":pleroma.:hackney_pools.:rich_media" />
</li>
<li>
<h4>{{ $t('admin_dash.job_queues.Hackney.upload') }}</h4>
<ul class="setting-list">
<li>
<IntegerSetting path=":pleroma.:hackney_pools.:upload.:max_connections" />
</li>
<li>
<IntegerSetting path=":pleroma.:hackney_pools.:upload.:timeout" />
</li>
</ul>
<GroupSetting path=":pleroma.:hackney_pools.:upload" />
</li>
</ul>
</div>
</div>
</template>
<script src="./job_queues_tab.js"></script>

View file

@ -1,28 +1,19 @@
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
)
import StringSetting from '../helpers/string_setting.vue'
const LimitsTab = {
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
StringSetting
StringSetting,
},
computed: {
...SharedComputedObject()
}
...SharedComputedObject(),
},
}
export default LimitsTab

View file

@ -1,10 +1,10 @@
<template>
<div :label="$t('admin_dash.tabs.limits')">
<div class="setting-item">
<h2>{{ $t('admin_dash.limits.arbitrary_limits') }}</h2>
<div class="setting-section">
<h3>{{ $t('admin_dash.limits.arbitrary_limits') }}</h3>
<ul class="setting-list">
<li>
<h3>{{ $t('admin_dash.limits.posts') }}</h3>
<h4>{{ $t('admin_dash.limits.posts') }}</h4>
<ul class="setting-list">
<li>
<IntegerSetting
@ -24,7 +24,7 @@
</ul>
</li>
<li>
<h3>{{ $t('admin_dash.limits.uploads') }}</h3>
<h4>{{ $t('admin_dash.limits.uploads') }}</h4>
<ul class="setting-list">
<li>
<IntegerSetting
@ -50,7 +50,7 @@
</ul>
</li>
<li>
<h3>{{ $t('admin_dash.limits.users') }}</h3>
<h4>{{ $t('admin_dash.limits.users') }}</h4>
<ul class="setting-list">
<li>
<IntegerSetting
@ -74,7 +74,7 @@
/>
</li>
<li>
<h4>{{ $t('admin_dash.limits.profile_fields') }}</h4>
<h5>{{ $t('admin_dash.limits.profile_fields') }}</h5>
<ul class="setting-list">
<li>
<IntegerSetting
@ -108,7 +108,7 @@
</ul>
</li>
<li>
<h4>{{ $t('admin_dash.limits.user_uploads') }}</h4>
<h5>{{ $t('admin_dash.limits.user_uploads') }}</h5>
<ul class="setting-list">
<li>
<IntegerSetting
@ -128,6 +128,25 @@
</li>
</ul>
</li>
<li>
<h4>{{ $t('admin_dash.limits.other') }}</h4>
<ul class="setting-list">
<li>
<IntegerSetting
source="admin"
path=":pleroma.:instance.:max_report_comment_size"
draft-mode
/>
</li>
<li>
<IntegerSetting
source="admin"
path=":pleroma.:instance.:max_endorsed_users"
draft-mode
/>
</li>
</ul>
</li>
</ul>
</div>
</div>

View file

@ -0,0 +1,133 @@
import { get } from 'lodash'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import AttachmentSetting from '../helpers/attachment_setting.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import GroupSetting from '../helpers/group_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import ListSetting from '../helpers/list_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
const LinksTab = {
provide() {
return {
defaultDraftMode: true,
defaultSource: 'admin',
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
StringSetting,
AttachmentSetting,
GroupSetting,
ListSetting,
Checkbox,
},
computed: {
classIsPresent() {
return (
this.$store.state.adminSettings.draft[':pleroma']['Pleroma.Formatter'][
':class'
] !== false
)
},
relIsPresent() {
return (
this.$store.state.adminSettings.draft[':pleroma']['Pleroma.Formatter'][
':rel'
] !== false
)
},
truncateIsPresent() {
return (
this.$store.state.adminSettings.draft[':pleroma']['Pleroma.Formatter'][
':truncate'
] !== false
)
},
truncateDescription() {
return get(this.$store.state.adminSettings.descriptions, [
':pleroma',
'Pleroma.Formatter',
':truncate',
])
},
ttlSettersOptions() {
const desc = get(
this.$store.state.adminSettings.descriptions,
':pleroma.:rich_media.:ttl_setters',
)
return new Set(
desc.suggestions.map((option) => ({
label: option.replace('Pleroma.Web.RichMedia.Parser.TTL.', ''),
value: option,
})),
)
},
parsersOptions() {
const desc = get(
this.$store.state.adminSettings.descriptions,
':pleroma.:rich_media.:parsers',
)
return new Set(
desc.suggestions.map((option) => ({
label: option.replace('Pleroma.Web.RichMedia.Parsers.', ''),
value: option,
})),
)
},
validateTLDOptions() {
return [
{
label: this.$t('general.yes'),
value: true,
},
{
label: this.$t('general.no'),
value: false,
},
{
label: this.$t('admin_dash.links.no_scheme'),
value: ':no_scheme',
},
]
},
mediaProxyEnabled() {
return this.$store.state.adminSettings.draft[':pleroma'][':media_proxy'][
':enabled'
]
},
mediaInvalidationProvider() {
return this.$store.state.adminSettings.draft[':pleroma'][':media_proxy'][
':invalidation'
][':provider']
},
...SharedComputedObject(),
},
methods: {
checkRel(e) {
this.$store.commit('updateAdminDraft', {
path: [':pleroma', 'Pleroma.Formatter', ':rel'],
value: e ? '' : false,
})
},
checkClass(e) {
this.$store.commit('updateAdminDraft', {
path: [':pleroma', 'Pleroma.Formatter', ':class'],
value: e ? '' : false,
})
},
checkTruncate(e) {
this.$store.commit('updateAdminDraft', {
path: [':pleroma', 'Pleroma.Formatter', ':truncate'],
value: e ? 20 : false,
})
},
},
}
export default LinksTab

View file

@ -0,0 +1,45 @@
<template>
<div
class="LinksTab"
:label="$t('admin_dash.tabs.media_proxy')"
>
<div class="setting-section">
<h3>{{ $t('admin_dash.links.link_previews') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting path=":pleroma.:rich_media.:enabled" />
</li>
<li>
<ListSetting
override-available-options
:options="parsersOptions"
path=":pleroma.:rich_media.:parsers"
/>
</li>
<li>
<IntegerSetting
path=":pleroma.:rich_media.:timeout"
/>
</li>
<li>
<ListSetting
override-available-options
:options="ttlSettersOptions"
path=":pleroma.:rich_media.:ttl_setters"
/>
</li>
<li>
<ListSetting
path=":pleroma.:rich_media.:ignore_tld"
ignore-suggestions
/>
</li>
<li>
<ListSetting path=":pleroma.:rich_media.:ignore_hosts" />
</li>
</ul>
</div>
</div>
</template>
<script src="./links_tab.js"></script>

View file

@ -0,0 +1,70 @@
import AttachmentSetting from '../helpers/attachment_setting.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import ColorSetting from '../helpers/color_setting.vue'
import GroupSetting from '../helpers/group_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
const MailerTab = {
provide() {
return {
defaultDraftMode: true,
defaultSource: 'admin',
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
StringSetting,
AttachmentSetting,
ColorSetting,
GroupSetting,
},
computed: {
adaptersLabels() {
const prefix = 'Swoosh.Adapters.'
const descriptions = this.$store.state.adminSettings.descriptions
const options =
descriptions[':pleroma']['Pleroma.Emails.Mailer'][':adapter']
.suggestions
return Object.fromEntries(
options.map((value) => [value, value.replace(prefix, '')]),
)
},
startTLSLabels() {
return {
':always': this.$t('admin_dash.generic_enforcement.always'),
':if_available': this.$t('admin_dash.generic_enforcement.if_available'),
':never': this.$t('admin_dash.generic_enforcement.never'),
}
// return Object.fromEntries(options.map(value => [
// value, value.replace(prefix, '')
// ]))
},
adapter() {
return this.$store.state.adminSettings.draft[':pleroma'][
'Pleroma.Emails.Mailer'
][':adapter']
},
mailerEnabled() {
return this.$store.state.adminSettings.draft[':pleroma'][
'Pleroma.Emails.Mailer'
][':enabled']
},
...SharedComputedObject(),
},
methods: {
adapterHasKey(key) {
const descriptions = this.$store.state.adminSettings.descriptions
const mailerStuff = descriptions[':pleroma']['Pleroma.Emails.Mailer']
const adapterStuff = mailerStuff[':subgroup,' + this.adapter]
return Object.hasOwn(adapterStuff, key)
},
},
}
export default MailerTab

View file

@ -0,0 +1,179 @@
<template>
<div :label="$t('admin_dash.tabs.mailer')">
<div class="setting-section">
<h3>{{ $t('admin_dash.mailer.styling') }}</h3>
<ul class="setting-list">
<h4>{{ $t('admin_dash.mailer.assets') }}</h4>
<li>
<StringSetting :path="[':pleroma','Pleroma.Emails.UserEmail', ':logo']" />
</li>
<h4>{{ $t('admin_dash.mailer.colors') }}</h4>
<li>
<ColorSetting :path="[':pleroma','Pleroma.Emails.UserEmail', ':styling', ':background_color']" />
</li>
<li>
<ColorSetting :path="[':pleroma','Pleroma.Emails.UserEmail', ':styling', ':content_background_color']" />
</li>
<li>
<ColorSetting :path="[':pleroma','Pleroma.Emails.UserEmail', ':styling', ':header_color']" />
</li>
<li>
<ColorSetting :path="[':pleroma','Pleroma.Emails.UserEmail', ':styling', ':text_color']" />
</li>
<li>
<ColorSetting :path="[':pleroma','Pleroma.Emails.UserEmail', ':styling', ':link_color']" />
</li>
<li>
<ColorSetting :path="[':pleroma','Pleroma.Emails.UserEmail', ':styling', ':text_muted_color']" />
</li>
<li>
<GroupSetting :path="[':pleroma','Pleroma.Emails.UserEmail', ':styling']" />
</li>
</ul>
<h3>{{ $t('admin_dash.mailer.adapter') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting :path="[':pleroma','Pleroma.Emails.Mailer',':enabled']" />
</li>
<template v-if="mailerEnabled">
<li>
<ChoiceSetting
:path="[':pleroma','Pleroma.Emails.Mailer',':adapter']"
:option-label-map="adaptersLabels"
/>
<h4>{{ $t('admin_dash.mailer.auth') }}</h4>
<ul class="setting-list suboptions">
<li v-if="adapterHasKey(':api_key')">
<!-- authentication info -->
<StringSetting
:path="[':pleroma','Pleroma.Emails.Mailer',':api_key']"
:password="true"
:subgroup="adapter"
/>
</li>
<li v-if="adapterHasKey(':access_key')">
<StringSetting
:path="[':pleroma','Pleroma.Emails.Mailer',':access_key']"
:password="true"
:subgroup="adapter"
/>
</li>
<li v-if="adapterHasKey(':access_token')">
<StringSetting
:path="[':pleroma','Pleroma.Emails.Mailer',':access_token']"
:password="true"
:subgroup="adapter"
/>
</li>
<li v-if="adapterHasKey(':username')">
<StringSetting
:path="[':pleroma','Pleroma.Emails.Mailer',':username']"
:subgroup="adapter"
/>
</li>
<li v-if="adapterHasKey(':password')">
<StringSetting
:path="[':pleroma','Pleroma.Emails.Mailer',':password']"
:password="true"
:subgroup="adapter"
/>
</li>
<li v-if="adapterHasKey(':secret')">
<StringSetting
:path="[':pleroma','Pleroma.Emails.Mailer',':secret']"
:subgroup="adapter"
/>
</li>
<li v-if="adapterHasKey(':auth')">
<ChoiceSetting
:path="[':pleroma','Pleroma.Emails.Mailer',':auth']"
:password="true"
:subgroup="adapter"
/>
</li>
<!-- server info -->
<li v-if="adapterHasKey(':relay')">
<StringSetting
:path="[':pleroma','Pleroma.Emails.Mailer',':relay']"
:password="true"
:subgroup="adapter"
/>
</li>
<li v-if="adapterHasKey(':ssl')">
<BooleanSetting
:path="[':pleroma','Pleroma.Emails.Mailer',':ssl']"
:password="true"
:subgroup="adapter"
/>
</li>
<li v-if="adapterHasKey(':tls')">
<ChoiceSetting
:path="[':pleroma','Pleroma.Emails.Mailer',':tls']"
:option-label-map="startTLSLabels"
:password="true"
:subgroup="adapter"
/>
</li>
<li v-if="adapterHasKey(':port')">
<IntegerSetting
:path="[':pleroma','Pleroma.Emails.Mailer',':port']"
:subgroup="adapter"
/>
</li>
<li v-if="adapterHasKey(':server_id')">
<StringSetting
:path="[':pleroma','Pleroma.Emails.Mailer',':server_id']"
:subgroup="adapter"
/>
</li>
<li v-if="adapterHasKey(':region')">
<StringSetting
:path="[':pleroma','Pleroma.Emails.Mailer',':region']"
:subgroup="adapter"
/>
</li>
<li v-if="adapterHasKey(':domain')">
<StringSetting
:path="[':pleroma','Pleroma.Emails.Mailer',':domain']"
:subgroup="adapter"
/>
</li>
<!-- sendmail exclusive -->
<li v-if="adapterHasKey(':cmd_path')">
<StringSetting
:path="[':pleroma','Pleroma.Emails.Mailer',':cmd_path']"
:password="true"
:subgroup="adapter"
/>
</li>
<li v-if="adapterHasKey(':cmd_args')">
<StringSetting
:path="[':pleroma','Pleroma.Emails.Mailer',':cmd_args']"
:password="true"
:subgroup="adapter"
/>
</li>
<li v-if="adapterHasKey(':qmail')">
<BooleanSetting
:path="[':pleroma','Pleroma.Emails.Mailer',':qmail']"
:password="true"
:subgroup="adapter"
/>
</li>
<li v-if="adapterHasKey(':retries')">
<IntegerSetting
:path="[':pleroma','Pleroma.Emails.Mailer',':retries']"
:subgroup="adapter"
/>
</li>
</ul>
</li>
</template>
</ul>
</div>
</div>
</template>
<script src="./mailer_tab.js"></script>

View file

@ -0,0 +1,41 @@
import AttachmentSetting from '../helpers/attachment_setting.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import GroupSetting from '../helpers/group_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import ListSetting from '../helpers/list_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
const MediaProxyTab = {
provide() {
return {
defaultDraftMode: true,
defaultSource: 'admin',
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
StringSetting,
AttachmentSetting,
GroupSetting,
ListSetting,
},
computed: {
mediaProxyEnabled() {
return this.$store.state.adminSettings.draft[':pleroma'][':media_proxy'][
':enabled'
]
},
mediaInvalidationProvider() {
return this.$store.state.adminSettings.draft[':pleroma'][':media_proxy'][
':invalidation'
][':provider']
},
...SharedComputedObject(),
},
}
export default MediaProxyTab

View file

@ -0,0 +1,135 @@
<template>
<div :label="$t('admin_dash.tabs.media_proxy')">
<div class="setting-section">
<h3>{{ $t('admin_dash.media_proxy.basic') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting path=":pleroma.:media_proxy.:enabled" />
<ul
v-if="mediaProxyEnabled"
class="setting-list suboptions"
>
<li>
<StringSetting path=":pleroma.:media_proxy.:base_url" />
</li>
<li>
<BooleanSetting path=":pleroma.:media_proxy.:proxy_opts.:redirect_on_failure" />
</li>
<li>
<ListSetting
ignore-suggestions
path=":pleroma.:media_proxy.:whitelist"
/>
</li>
</ul>
</li>
</ul>
<template v-if="mediaProxyEnabled">
<h3>{{ $t('admin_dash.media_proxy.invalidation') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting path=":pleroma.:media_proxy.:invalidation.:enabled" />
<ul class="setting-list suboptions">
<li>
<ChoiceSetting
path=":pleroma.:media_proxy.:invalidation.:provider"
parent-path=":pleroma.:media_proxy.:invalidation.:enabled"
/>
</li>
<h4>{{ $t('admin_dash.media_proxy.invalidation_settings') }}</h4>
<ul class="setting-list suboptions">
<template v-if="mediaInvalidationProvider === 'Pleroma.Web.MediaProxy.Invalidation.Http'">
<li>
<StringSetting
:path="[':pleroma', 'Pleroma.Web.MediaProxy.Invalidation.Http', ':method']"
parent-path=":pleroma.:media_proxy.:invalidation.:enabled"
/>
</li>
<li>
<ListSetting
ignore-suggestions
:path="[':pleroma', 'Pleroma.Web.MediaProxy.Invalidation.Http', ':headers']"
parent-path=":pleroma.:media_proxy.:invalidation.:enabled"
/>
</li>
<li>
<ListSetting
:path="[':pleroma', 'Pleroma.Web.MediaProxy.Invalidation.Http', ':options']"
parent-path=":pleroma.:media_proxy.:invalidation.:enabled"
/>
</li>
</template>
<template v-if="mediaInvalidationProvider === 'Pleroma.Web.MediaProxy.Invalidation.Script'">
<!-- TODO: you know the drill by now - list component -->
<li>
<StringSetting
:path="[':pleroma', 'Pleroma.Web.MediaProxy.Invalidation.Script', ':script_path']"
parent-path=":pleroma.:media_proxy.:invalidation.:enabled"
/>
</li>
<li>
<StringSetting
:path="[':pleroma', 'Pleroma.Web.MediaProxy.Invalidation.Script', ':url_format']"
parent-path=":pleroma.:media_proxy.:invalidation.:enabled"
/>
</li>
</template>
</ul>
</ul>
</li>
</ul>
<h3>{{ $t('admin_dash.media_proxy.limits') }}</h3>
<ul class="setting-list">
<li>
<IntegerSetting
path=":pleroma.:media_proxy.:proxy_opts.:max_body_length"
/>
</li>
<li>
<IntegerSetting
path=":pleroma.:media_proxy.:proxy_opts.:max_read_duration"
/>
</li>
<li>
<GroupSetting path=":pleroma.:media_proxy.:proxy_opts" />
</li>
</ul>
<!-- TODO: add whitelist when we have list component (hehe) -->
<h3>{{ $t('admin_dash.media_proxy.thumbnails') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting path=":pleroma.:media_preview_proxy.:enabled" />
<ul class="setting-list suboptions">
<li>
<IntegerSetting
parent-path=":pleroma.:media_preview_proxy.:enabled"
path=":pleroma.:media_preview_proxy.:image_quality"
/>
</li>
<li>
<IntegerSetting
parent-path=":pleroma.:media_preview_proxy.:enabled"
path=":pleroma.:media_preview_proxy.:min_content_length"
/>
</li>
<li>
<IntegerSetting
parent-path=":pleroma.:media_preview_proxy.:enabled"
path=":pleroma.:media_preview_proxy.:thumbnail_max_width"
/>
</li>
<li>
<IntegerSetting
parent-path=":pleroma.:media_preview_proxy.:enabled"
path=":pleroma.:media_preview_proxy.:thumbnail_max_height"
/>
</li>
</ul>
</li>
</ul>
</template>
</div>
</div>
</template>
<script src="./media_proxy_tab.js"></script>

View file

@ -0,0 +1,37 @@
import AttachmentSetting from '../helpers/attachment_setting.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import GroupSetting from '../helpers/group_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import ListSetting from '../helpers/list_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faGlobe } from '@fortawesome/free-solid-svg-icons'
library.add(faGlobe)
const MonitoringTab = {
provide() {
return {
defaultDraftMode: true,
defaultSource: 'admin',
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
StringSetting,
AttachmentSetting,
GroupSetting,
ListSetting,
},
computed: {
...SharedComputedObject(),
},
methods: {},
}
export default MonitoringTab

View file

@ -0,0 +1,47 @@
<template>
<div :label="$t('admin_dash.tabs.monitoring')">
<div class="setting-section">
<h3>{{ $t('admin_dash.monitoring.builtins') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting :path="[':pleroma','Pleroma.Emails.NewUsersDigestEmail',':enabled']" />
</li>
<li>
<BooleanSetting path=":pleroma.:instance.:healthcheck" />
</li>
</ul>
<h3>{{ $t('admin_dash.monitoring.prometheus') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting :path="[':prometheus','Pleroma.Web.Endpoint.MetricsExporter',':enabled']" />
</li>
<li>
<BooleanSetting
:parent-path="[':prometheus','Pleroma.Web.Endpoint.MetricsExporter',':enabled']"
:path="[':prometheus','Pleroma.Web.Endpoint.MetricsExporter',':auth']"
/>
</li>
<li>
<StringSetting
:parent-path="[':prometheus','Pleroma.Web.Endpoint.MetricsExporter',':enabled']"
:path="[':prometheus','Pleroma.Web.Endpoint.MetricsExporter',':path']"
/>
</li>
<li>
<ChoiceSetting
:parent-path="[':prometheus','Pleroma.Web.Endpoint.MetricsExporter',':enabled']"
:path="[':prometheus','Pleroma.Web.Endpoint.MetricsExporter',':format']"
/>
</li>
<li>
<ListSetting
:parent-path="[':prometheus','Pleroma.Web.Endpoint.MetricsExporter',':enabled']"
:path="[':prometheus','Pleroma.Web.Endpoint.MetricsExporter',':ip_whitelist']"
/>
</li>
</ul>
</div>
</div>
</template>
<script src="./monitoring_tab.js"></script>

View file

@ -0,0 +1,37 @@
import AttachmentSetting from '../helpers/attachment_setting.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import ColorSetting from '../helpers/color_setting.vue'
import GroupSetting from '../helpers/group_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import ListSetting from '../helpers/list_setting.vue'
import MapSetting from '../helpers/map_setting.vue'
import PWAManifestIconsSetting from '../helpers/pwa_manifest_icons_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
const OtherTab = {
provide() {
return {
defaultDraftMode: true,
defaultSource: 'admin',
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
StringSetting,
ColorSetting,
AttachmentSetting,
ListSetting,
PWAManifestIconsSetting,
MapSetting,
GroupSetting,
},
computed: {
...SharedComputedObject(),
},
}
export default OtherTab

View file

@ -0,0 +1,52 @@
<template>
<div :label="$t('admin_dash.tabs.other')">
<div class="setting-section">
<h3>{{ $t('admin_dash.other.uncategorized') }}</h3>
<ul class="setting-list">
<li>
<StringSetting path=":pleroma.:instance.:static_dir" />
</li>
<li>
<BooleanSetting path=":pleroma.:instance.:profile_directory" />
</li>
</ul>
<h3>{{ $t('admin_dash.other.reports') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting path=":pleroma.:instance.:report_strip_status" />
</li>
</ul>
<h3>{{ $t('admin_dash.other.user_backup') }}</h3>
<ul class="setting-list">
<li>
<IntegerSetting :path="[':pleroma','Pleroma.User.Backup',':purge_after_days']" />
</li>
<li>
<IntegerSetting :path="[':pleroma','Pleroma.User.Backup',':limit_days']" />
</li>
<!-- CONFIRM what is this?
<li>
<StringSetting :path="[':pleroma','Pleroma.User.Backup',':dir']" />
</li>
-->
<li>
<IntegerSetting :path="[':pleroma','Pleroma.User.Backup',':process_chunk_size']" />
</li>
<li>
<IntegerSetting :path="[':pleroma','Pleroma.User.Backup',':timeout']" />
</li>
</ul>
<h3>{{ $t('admin_dash.other.privileges') }}</h3>
<ul class="setting-list">
<li>
<ListSetting path=":pleroma.:instance.:admin_privileges" />
</li>
<li>
<ListSetting path=":pleroma.:instance.:moderator_privileges" />
</li>
</ul>
</div>
</div>
</template>
<script src="./other_tab.js"></script>

View file

@ -0,0 +1,37 @@
import AttachmentSetting from '../helpers/attachment_setting.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import ColorSetting from '../helpers/color_setting.vue'
import GroupSetting from '../helpers/group_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import ListSetting from '../helpers/list_setting.vue'
import MapSetting from '../helpers/map_setting.vue'
import PWAManifestIconsSetting from '../helpers/pwa_manifest_icons_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
const PostsTab = {
provide() {
return {
defaultDraftMode: true,
defaultSource: 'admin',
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
StringSetting,
ColorSetting,
AttachmentSetting,
ListSetting,
PWAManifestIconsSetting,
MapSetting,
GroupSetting,
},
computed: {
...SharedComputedObject(),
},
}
export default PostsTab

View file

@ -0,0 +1,29 @@
<template>
<div :label="$t('admin_dash.tabs.posts')">
<div class="setting-section">
<h3>{{ $t('admin_dash.posts.global') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting path=":pleroma.:instances_favicons.:enabled" />
</li>
</ul>
<h3>{{ $t('admin_dash.posts.local') }}</h3>
<ul class="setting-list">
<li>
<ListSetting path=":pleroma.:instance.:allowed_post_formats" />
</li>
</ul>
<h3>{{ $t('admin_dash.posts.remote') }}</h3>
<ul class="setting-list">
<li>
<IntegerSetting path=":pleroma.:instance.:remote_post_retention_days" />
</li>
<li>
<BooleanSetting path=":pleroma.:instance.:skip_thread_containment" />
</li>
</ul>
</div>
</div>
</template>
<script src="./posts_tab.js"></script>

View file

@ -0,0 +1,19 @@
import RateSetting from '../helpers/rate_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
const RatesTab = {
provide() {
return {
defaultDraftMode: true,
defaultSource: 'admin',
}
},
components: {
RateSetting,
},
computed: {
...SharedComputedObject(),
},
}
export default RatesTab

View file

@ -0,0 +1,41 @@
<template>
<div :label="$t('admin_dash.tabs.instance')">
<div class="setting-section">
<h3>{{ $t('admin_dash.rate_limit.account_confirmation_resend') }}</h3>
<ul class="setting-list">
<li>
<RateSetting path=":pleroma.:rate_limit.:account_confirmation_resend" />
</li>
<li>
<RateSetting path=":pleroma.:rate_limit.:ap_routes" />
</li>
<li>
<RateSetting path=":pleroma.:rate_limit.:app_account_creation" />
</li>
<li>
<RateSetting path=":pleroma.:rate_limit.:authentication" />
</li>
<li>
<RateSetting path=":pleroma.:rate_limit.:oauth_app_creation" />
</li>
<li>
<RateSetting path=":pleroma.:rate_limit.:relation_id_action" />
</li>
<li>
<RateSetting path=":pleroma.:rate_limit.:search" />
</li>
<li>
<RateSetting path=":pleroma.:rate_limit.:status_id_action" />
</li>
<li>
<RateSetting path=":pleroma.:rate_limit.:statuses_actions" />
</li>
<li>
<RateSetting path=":pleroma.:rate_limit.:timeline" />
</li>
</ul>
</div>
</div>
</template>
<script src="./rates_tab.js"></script>

View file

@ -0,0 +1,33 @@
import AttachmentSetting from '../helpers/attachment_setting.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import GroupSetting from '../helpers/group_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import ListSetting from '../helpers/list_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
import TupleSetting from '../helpers/tuple_setting.vue'
const RegistrationsTab = {
provide() {
return {
defaultDraftMode: true,
defaultSource: 'admin',
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
StringSetting,
TupleSetting,
AttachmentSetting,
GroupSetting,
ListSetting,
},
computed: {
...SharedComputedObject(),
},
}
export default RegistrationsTab

View file

@ -0,0 +1,182 @@
<template>
<div :label="$t('admin_dash.tabs.instance')">
<div class="setting-section">
<h3>{{ $t('admin_dash.instance.registrations') }}</h3>
<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>
<h4>{{ $t('admin_dash.instance.captcha_header') }}</h4>
<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'"
>
<h5>{{ $t('admin_dash.instance.kocaptcha') }}</h5>
<ul class="setting-list">
<li>
<StringSetting :path="[':pleroma', 'Pleroma.Captcha.Kocaptcha', ':endpoint']" />
</li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
</ul>
<h3>{{ $t('admin_dash.registrations.autofollow') }}</h3>
<ul class="setting-list">
<li>
<ListSetting
path=":pleroma.:instance.:autofollowed_nicknames"
/>
</li>
<li>
<ListSetting
path=":pleroma.:instance.:autofollowing_nicknames"
/>
</li>
</ul>
<h3>{{ $t('admin_dash.registrations.welcome.title') }}</h3>
<ul class="setting-list">
<p>{{ $t('admin_dash.registrations.welcome.description') }}</p>
<li>
<h4>{{ $t('admin_dash.registrations.welcome.direct_message') }}</h4>
<ul class="setting-list">
<li>
<BooleanSetting
path=":pleroma.:welcome.:direct_message.:enabled"
/>
<ul class="setting-list suboptions">
<li>
<StringSetting
path=":pleroma.:welcome.:direct_message.:sender_nickname"
parent-path=":pleroma.:welcome.:direct_message.:enabled"
/>
</li>
<li>
<StringSetting
path=":pleroma.:welcome.:direct_message.:message"
parent-path=":pleroma.:welcome.:direct_message.:enabled"
/>
</li>
</ul>
<GroupSetting path=":pleroma.:welcome.:direct_message" />
</li>
</ul>
</li>
<li>
<h4>{{ $t('admin_dash.registrations.welcome.chat_message') }}</h4>
<ul class="setting-list">
<li>
<BooleanSetting
path=":pleroma.:welcome.:chat_message.:enabled"
/>
<ul class="setting-list suboptions">
<li>
<StringSetting
tuple
path=":pleroma.:welcome.:chat_message.:sender_nickname"
parent-path=":pleroma.:welcome.:chat_message.:enabled"
/>
</li>
<li>
<StringSetting
path=":pleroma.:welcome.:chat_message.:message"
parent-path=":pleroma.:welcome.:chat_message.:enabled"
/>
</li>
</ul>
<GroupSetting path=":pleroma.:welcome.:chat_message" />
</li>
</ul>
</li>
<li>
<h4>{{ $t('admin_dash.registrations.welcome.email_message') }}</h4>
<ul class="setting-list">
<li>
<BooleanSetting
path=":pleroma.:welcome.:email.:enabled"
/>
<ul class="setting-list suboptions">
<li>
<TupleSetting
path=":pleroma.:welcome.:email.:sender"
parent-path=":pleroma.:welcome.:email.:enabled"
/>
</li>
<li>
<StringSetting
path=":pleroma.:welcome.:email.:subject"
parent-path=":pleroma.:welcome.:email.:enabled"
/>
</li>
<li>
<StringSetting
path=":pleroma.:welcome.:email.:html"
parent-path=":pleroma.:welcome.:email.:enabled"
/>
</li>
</ul>
<GroupSetting path=":pleroma.:welcome.:email" />
</li>
</ul>
</li>
</ul>
<h3>{{ $t('admin_dash.registrations.restrictions') }}</h3>
<ul class="setting-list">
<li>
<ListSetting
ignore-suggestions
:path="[':pleroma', 'Pleroma.User', ':email_blacklist']"
/>
</li>
</ul>
</div>
</div>
</template>
<script src="./registrations_tab.js"></script>

View file

@ -0,0 +1,51 @@
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
const UploadsTab = {
provide() {
return {
defaultDraftMode: true,
defaultSource: 'admin',
}
},
data() {
return {
uploaders: [
{
key: 'Pleroma.Uploaders.Local',
value: 'Pleroma.Uploaders.Local',
label: this.$t('admin_dash.uploads.local_uploader'),
},
{
key: 'Pleroma.Uploaders.IPFS',
value: 'Pleroma.Uploaders.IPFS',
label: 'IPFS',
},
{
key: 'Pleroma.Uploaders.S3',
value: 'Pleroma.Uploaders.S3',
label: 'S3',
},
],
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
StringSetting,
},
computed: {
uploader() {
return this.$store.state.adminSettings.draft[':pleroma'][
'Pleroma.Upload'
][':uploader']
},
...SharedComputedObject(),
},
}
export default UploadsTab

View file

@ -0,0 +1,110 @@
<template>
<div :label="$t('admin_dash.tabs.uploads')">
<div class="setting-section">
<h3>{{ $t('admin_dash.uploads.upload') }}</h3>
<ul class="setting-list">
<li>
<ChoiceSetting
:path="[':pleroma','Pleroma.Upload',':uploader']"
:options="uploaders"
/>
<h4>{{ $t('admin_dash.uploads.uploader_settings') }}</h4>
<ul class="setting-list suboptions">
<template v-if="uploader === 'Pleroma.Uploaders.Local'">
<li>
<StringSetting
:path="[':pleroma','Pleroma.Uploaders.Local',':uploads']"
/>
</li>
</template>
<template v-else-if="uploader === 'Pleroma.Uploaders.IPFS'">
<li>
<StringSetting
:path="[':pleroma','Pleroma.Uploaders.IPFS',':get_gateway_url']"
/>
</li>
<li>
<StringSetting
:path="[':pleroma','Pleroma.Uploaders.IPFS',':post_gateway_url']"
/>
</li>
</template>
<template v-else-if="uploader === 'Pleroma.Uploaders.S3'">
<li>
<StringSetting
:path="[':pleroma','Pleroma.Uploaders.S3',':bucket']"
/>
</li>
<li>
<StringSetting
:path="[':pleroma','Pleroma.Uploaders.S3',':bucket_namespace']"
/>
</li>
<li>
<BooleanSetting
:path="[':pleroma','Pleroma.Uploaders.S3',':streaming_enabled']"
/>
</li>
<li>
<StringSetting
:path="[':pleroma','Pleroma.Uploaders.S3',':truncated_namespace']"
/>
</li>
</template>
<li>
<IntegerSetting
:path="[':pleroma','Pleroma.Uploaders.Uploader',':timeout']"
/>
</li>
</ul>
</li>
</ul>
<h3>{{ $t('admin_dash.uploads.attachments') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting
path=":pleroma.:instance.:attachment_links"
:options="uploaders"
/>
</li>
<li>
<BooleanSetting
path=":pleroma.:instance.:cleanup_attachments"
:options="uploaders"
/>
</li>
</ul>
<!-- CONFIRM how filters work -->
<h3>{{ $t('admin_dash.uploads.filenames') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting
:path="[':pleroma','Pleroma.Upload',':link_name']"
:subgroup="adapter"
/>
</li>
<li>
<IntegerSetting
:path="[':pleroma','Pleroma.Upload',':filename_display_max_length']"
:subgroup="adapter"
/>
</li>
<li>
<StringSetting
:path="[':pleroma','Pleroma.Upload',':default_description']"
:subgroup="adapter"
/>
</li>
<li>
<StringSetting
:path="[':pleroma','Pleroma.Upload',':base_url']"
:subgroup="adapter"
/>
</li>
<!-- TODO: add mime-type when we have a dynamic list component -->
</ul>
</div>
</div>
</template>
<script src="./uploads_tab.js"></script>

View file

@ -1,18 +1,18 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Select from 'src/components/select/select.vue'
import BasicUserCard from 'src/components/basic_user_card/basic_user_card.vue'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import AdminCard from 'src/components/settings_modal/admin_tabs/admin_card.vue'
import PageList from 'src/components/page_list/page_list.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import Popover from 'src/components/popover/popover.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import GenericConfirm from 'src/components/confirm_modal/generic_confirm.vue'
import PageList from 'src/components/page_list/page_list.vue'
import Popover from 'src/components/popover/popover.vue'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import Select from 'src/components/select/select.vue'
import AdminCard from 'src/components/settings_modal/admin_tabs/admin_card.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
const UsersTab = {
provide () {
provide() {
return {
defaultDraftMode: true,
defaultSource: 'admin'
defaultSource: 'admin',
}
},
data() {
@ -27,7 +27,7 @@ const UsersTab = {
filtersName: '',
filtersEmail: '',
expandedUser: null,
loading: false
loading: false,
}
},
computed: {
@ -35,44 +35,50 @@ const UsersTab = {
* do we filter for admins?
* @returns {boolean}
*/
filtersIsAdmin () {
return this.filtersPrivileges === 'admin' || this.filtersPrivileges === 'modsnadmins'
filtersIsAdmin() {
return (
this.filtersPrivileges === 'admin' ||
this.filtersPrivileges === 'modsnadmins'
)
},
/**
* do we filter for moderators?
* @returns {boolean}
*/
filtersIsModerator () {
return this.filtersPrivileges === 'moderator' || this.filtersPrivileges === 'modsnadmins'
filtersIsModerator() {
return (
this.filtersPrivileges === 'moderator' ||
this.filtersPrivileges === 'modsnadmins'
)
},
/**
* do we filter for active users?
* @returns {boolean}
*/
filtersActive () {
filtersActive() {
return this.filtersActivity === 'active'
},
/**
* do we filter for deactivated users?
* @returns {boolean}
*/
filtersDeactivated () {
filtersDeactivated() {
return this.filtersActivity === 'deactivated'
},
/**
* do we filter for local users?
* @returns {boolean}
*/
filtersLocal () {
filtersLocal() {
return this.filtersOrigin === 'local'
},
/**
* do we filter for external users?
* @return {boolean}
*/
filtersExternal () {
filtersExternal() {
return this.filtersOrigin === 'external'
}
},
},
components: {
Checkbox,
@ -83,7 +89,7 @@ const UsersTab = {
AdminCard,
TabSwitcher,
Popover,
GenericConfirm
GenericConfirm,
},
methods: {
/**
@ -91,8 +97,8 @@ const UsersTab = {
* @param {object} store
* @param {object} opts
*/
fetchPage (store, opts) {
if(!this.init) return new Promise(() => [])
fetchPage(store, opts) {
if (!this.init) return new Promise(() => [])
const filters = {
isAdmin: this.filtersIsAdmin,
isModerator: this.filtersIsModerator,
@ -101,20 +107,23 @@ const UsersTab = {
local: this.filtersLocal,
external: this.filtersExternal,
needApproval: this.filtersNeedApproval,
unconfirmed: this.filtersUnconfirmeUnconfirmed
unconfirmed: this.filtersUnconfirmeUnconfirmed,
}
const nopts = {
...opts,
...{
query: this.filtersQuery,
filters,
name: this.filtersName,
email: this.filtersEmail,
},
}
const nopts = { ...opts, ...{
query: this.filtersQuery,
filters,
name: this.filtersName,
email: this.filtersEmail
}}
return store.dispatch('fetchAdminUsers', nopts)
},
/**
* reset the userlist explicitly
*/
reset () {
reset() {
this.$refs.userList.reset()
},
/**
@ -132,23 +141,26 @@ const UsersTab = {
selectionConfirmed(action) {
const restricted = []
const s = this.$refs.userList.getSelected()
s.forEach(u => {
if (restricted.includes(action) !== false || u.id !== this.$store.state.users.currentUser.id) {
s.forEach((u) => {
if (
restricted.includes(action) !== false ||
u.id !== this.$store.state.users.currentUser.id
) {
const uf = this.$store.getters.findUser(u.id)
console.log('user: ', uf)
this.$store.dispatch(action, this.$store.getters.findUser(u.id))
}
})
this.reset()
}
},
},
/**
* mark as initialized and reset user list
*/
mounted () {
mounted() {
this.init = true
this.reset()
}
},
}
export default UsersTab

View file

@ -1,7 +1,10 @@
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'
import MediaUpload from 'src/components/media_upload/media_upload.vue'
import Setting from './setting.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { fileTypeExt } from 'src/services/file_type/file_type.service.js'
export default {
...Setting,
@ -11,34 +14,34 @@ export default {
acceptTypes: {
type: String,
required: false,
default: 'image/*'
}
default: 'image/*',
},
},
components: {
...Setting.components,
MediaUpload,
Attachment
Attachment,
},
computed: {
...Setting.computed,
attachment () {
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
const url = path.includes('://') ? path : useInstanceStore().server + path
return {
mimetype: fileTypeExt(url),
url
type: fileTypeExt(url),
url,
}
}
},
},
methods: {
...Setting.methods,
setMediaFile (fileInfo) {
setMediaFile(fileInfo) {
if (this.realDraftMode) {
this.draft = fileInfo.url
} else {
this.configSink(this.path, fileInfo.url)
}
}
}
},
},
}

View file

@ -1,10 +1,11 @@
<template>
<span
v-if="matchesExpertLevel"
class="AttachmentSetting"
class="AttachmentSetting setting-item"
:class="{ '-compact': compact }"
>
<label
class="setting-label"
:for="path"
:class="{ 'faint': shouldBeDisabled }"
>
@ -39,7 +40,7 @@
:changed="isChanged"
:onclick="reset"
/>
<ProfileSettingIndicator :is-profile="isProfileSetting" />
<LocalSettingIndicator :is-local="isLocalSetting" />
</div>
<div v-if="!compact">{{ $t('settings.preview') }}</div>
<Attachment

View file

@ -5,27 +5,27 @@ export default {
...Setting,
props: {
...Setting.props,
indeterminateState: [String, Object]
indeterminateState: [String, Object],
},
components: {
...Setting.components,
Checkbox
Checkbox,
},
computed: {
...Setting.computed,
isIndeterminate () {
isIndeterminate() {
return this.visibleState === this.indeterminateState
}
},
},
methods: {
...Setting.methods,
getValue (e) {
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
}
return e
}
}
},
},
}

View file

@ -1,9 +1,10 @@
<template>
<label
v-if="matchesExpertLevel"
class="BooleanSetting"
class="BooleanSetting setting-item"
>
<Checkbox
class="setting-control setting-label"
:model-value="visibleState"
:disabled="shouldBeDisabled"
:indeterminate="isIndeterminate"
@ -13,6 +14,12 @@
class="label"
:class="{ 'faint': shouldBeDisabled }"
>
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
<LocalSettingIndicator :is-local="isLocalSetting" />
{{ ' ' }}
<template v-if="backendDescriptionLabel">
{{ backendDescriptionLabel }}
</template>
@ -22,19 +29,16 @@
<slot v-else />
</span>
</Checkbox>
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
<ProfileSettingIndicator :is-profile="isProfileSetting" />
<DraftButtons />
<p
v-if="backendDescriptionDescription"
v-if="backendDescriptionDescription || showDescription"
class="setting-description"
:class="{ 'faint': shouldBeDisabled }"
>
{{ backendDescriptionDescription + ' ' }}
<slot name="description">
{{ backendDescriptionDescription + ' ' }}
</slot>
</p>
<DraftButtons />
</label>
</template>

View file

@ -5,37 +5,50 @@ export default {
...Setting,
components: {
...Setting.components,
Select
Select,
},
props: {
...Setting.props,
overrideOptions: {
type: Boolean,
required: false,
},
options: {
type: Array,
required: false
required: false,
},
optionLabelMap: {
type: Object,
required: false,
default: {}
}
default: {},
},
},
computed: {
...Setting.computed,
realOptions () {
realOptions() {
if (this.overrideOptions) {
return this.options
}
if (this.realSource === 'admin') {
return this.backendDescriptionSuggestions.map(x => ({
if (
!this.backendDescriptionSuggestions?.length ||
this.backendDescriptionSuggestions?.length === 0
) {
return this.options
}
return this.backendDescriptionSuggestions.map((x) => ({
key: x,
value: x,
label: this.optionLabelMap[x] || x
label: this.optionLabelMap[x] || x,
}))
}
return this.options
}
},
},
methods: {
...Setting.methods,
getValue (e) {
getValue(e) {
return e
}
}
},
},
}

View file

@ -1,18 +1,27 @@
<template>
<label
v-if="matchesExpertLevel"
class="ChoiceSetting"
class="ChoiceSetting setting-item"
:class="{ 'faint': shouldBeDisabled }"
>
<template v-if="backendDescriptionLabel">
{{ backendDescriptionLabel }}
</template>
<template v-else>
<slot />
</template>
{{ ' ' }}
<span class="setting-label">
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
<LocalSettingIndicator :is-local="isLocalSetting" />
{{ ' ' }}
<template v-if="backendDescriptionLabel">
{{ backendDescriptionLabel }}
</template>
<template v-else>
<slot />
</template>
</span>
<Select
:model-value="realDraftMode ? draft :state"
:disabled="disabled"
class="setting-control"
:model-value="realDraftMode ? draft : state"
:disabled="shouldBeDisabled"
@update:model-value="update"
>
<option
@ -24,11 +33,6 @@
{{ option.value === defaultState ? $t('settings.instance_default_simple') : '' }}
</option>
</Select>
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
<ProfileSettingIndicator :is-profile="isProfileSetting" />
<DraftButtons />
<p
v-if="backendDescriptionDescription"
@ -40,3 +44,16 @@
</template>
<script src="./choice_setting.js"></script>
<style lang="scss">
.ChoiceSetting.setting-item {
.-mobile & {
display: block;
.setting-label {
display: block;
margin-bottom: 0.5em
}
}
}
</style>

View file

@ -0,0 +1,16 @@
import ColorInput from 'src/components/color_input/color_input.vue'
import Setting from './setting.js'
export default {
...Setting,
components: {
...Setting.components,
ColorInput,
},
methods: {
...Setting.methods,
getValue(e) {
return e
},
},
}

View file

@ -0,0 +1,72 @@
<template>
<label
v-if="matchesExpertLevel"
class="ColorSetting setting-item"
>
<label
v-if="!hideLabel"
:for="path"
class="setting-label"
:class="{ 'faint': shouldBeDisabled }"
>
<template v-if="backendDescriptionLabel">
{{ backendDescriptionLabel + ' ' }}
</template>
<template v-else-if="source === 'admin'">
MISSING LABEL FOR {{ path }}
</template>
<slot v-else />
</label>
{{ ' ' }}
<ColorInput
:id="path"
:name="path"
class="setting-control color-setting-input"
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
:placeholder="backendDescriptionSuggestions"
:model-value="realDraftMode ? draft : state"
@update:model-value="update"
/>
{{ ' ' }}
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
<LocalSettingIndicator :is-local="isLocalSetting" />
<DraftButtons v-if="!hideDraftButtons" />
<p
v-if="backendDescriptionDescription"
class="setting-description"
:class="{ 'faint': shouldBeDisabled }"
>
{{ backendDescriptionDescription + ' ' }}
</p>
</label>
</template>
<script src="./color_setting.js"></script>
<style lang="scss">
.ColorSetting {
&.setting-item {
display: grid;
grid-template-areas:
"label control"
". desc"
". draft";
.setting-label {
text-align: right;
align-self: center;
}
.setting-control {
align-self: end;
}
}
.color-setting-input {
align-self: baseline;
}
}
</style>

View file

@ -2,6 +2,7 @@
<!-- TODO make it reusable -->
<template>
<span
v-if="$parent.isDirty || $parent.canHardReset"
class="DraftButtons"
>
<Popover
@ -57,27 +58,24 @@
<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
)
library.add(faWrench)
export default {
components: { Popover },
props: ['changed']
props: ['changed'],
}
</script>
<style lang="scss">
.DraftButtons {
display: inline-block;
display: inline-flex;
position: relative;
.button-default {
margin-left: 0.5em;
}
gap: 0.5em;
margin-top: 0.5em
}
.draft-tooltip {

View file

@ -150,63 +150,70 @@
<script>
import Popover from 'components/popover/popover.vue'
import ConfirmModal from 'components/confirm_modal/confirm_modal.vue'
import StillImage from 'components/still-image/still-image.vue'
import SelectComponent from 'components/select/select.vue'
import { defineAsyncComponent } from 'vue'
export default {
components: { Popover, ConfirmModal, StillImage, SelectComponent },
components: {
Popover,
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
SelectComponent,
},
inject: ['emojiAddr'],
props: {
placement: {
type: String,
required: true
required: true,
},
newUpload: Boolean,
title: {
type: String,
required: true
required: true,
},
packName: {
type: String,
required: true
required: true,
},
shortcode: {
type: String,
// Only exists when this is not a new upload
default: ''
default: '',
},
file: {
type: String,
// Only exists when this is not a new upload
default: ''
default: '',
},
// Only exists for emojis from remote packs
remote: {
type: Object,
default: undefined
default: undefined,
},
knownLocalPacks: {
type: Object,
default: undefined
}
default: undefined,
},
},
emits: ['updatePackFiles', 'displayError'],
data () {
data() {
return {
uploadFile: [],
uploadURL: '',
editedShortcode: this.shortcode,
editedFile: this.file,
deleteModalVisible: false,
copyToPack: ''
copyToPack: '',
}
},
computed: {
emojiPreview () {
emojiPreview() {
if (this.newUpload && this.uploadFile.length > 0) {
return URL.createObjectURL(this.uploadFile[0])
} else if (this.newUpload && this.uploadURL !== '') {
@ -217,73 +224,92 @@ export default {
return null
},
isEdited () {
return !this.newUpload && (this.editedShortcode !== this.shortcode || this.editedFile !== this.file)
isEdited() {
return (
!this.newUpload &&
(this.editedShortcode !== this.shortcode ||
this.editedFile !== this.file)
)
},
saveButtonDisabled() {
if (this.remote === undefined)
return this.newUpload ? (this.uploadURL === "" && this.uploadFile.length == 0) : !this.isEdited
else
return this.copyToPack === ""
}
return this.newUpload
? this.uploadURL === '' && this.uploadFile.length == 0
: !this.isEdited
else return this.copyToPack === ''
},
},
methods: {
saveEditedEmoji () {
saveEditedEmoji() {
if (!this.isEdited) return
this.$store.state.api.backendInteractor.updateEmojiFile(
{ packName: this.packName, shortcode: this.shortcode, newShortcode: this.editedShortcode, newFilename: this.editedFile, force: false }
).then(resp => {
if (resp.error !== undefined) {
this.$emit('displayError', resp.error)
return Promise.reject(resp.error)
}
this.$store.state.api.backendInteractor
.updateEmojiFile({
packName: this.packName,
shortcode: this.shortcode,
newShortcode: this.editedShortcode,
newFilename: this.editedFile,
force: false,
})
.then((resp) => {
if (resp.error !== undefined) {
this.$emit('displayError', resp.error)
return Promise.reject(resp.error)
}
return resp.json()
}).then(resp => this.$emit('updatePackFiles', resp))
return resp.json()
})
.then((resp) => this.$emit('updatePackFiles', resp))
},
uploadEmoji () {
uploadEmoji() {
let packName = this.remote === undefined ? this.packName : this.copyToPack
this.$store.state.api.backendInteractor.addNewEmojiFile({
packName: packName,
file: this.remote === undefined
? (this.uploadURL !== "" ? this.uploadURL : this.uploadFile[0])
: this.emojiAddr(this.file),
shortcode: this.editedShortcode,
filename: this.editedFile
}).then(resp => resp.json()).then(resp => {
if (resp.error !== undefined) {
this.$emit('displayError', resp.error)
return
}
this.$store.state.api.backendInteractor
.addNewEmojiFile({
packName: packName,
file:
this.remote === undefined
? this.uploadURL !== ''
? this.uploadURL
: this.uploadFile[0]
: this.emojiAddr(this.file),
shortcode: this.editedShortcode,
filename: this.editedFile,
})
.then((resp) => resp.json())
.then((resp) => {
if (resp.error !== undefined) {
this.$emit('displayError', resp.error)
return
}
this.$emit('updatePackFiles', resp, packName)
this.$refs.emojiPopover.hidePopover()
this.$emit('updatePackFiles', resp, packName)
this.$refs.emojiPopover.hidePopover()
this.editedFile = ''
this.editedShortcode = ''
this.uploadFile = []
})
this.editedFile = ''
this.editedShortcode = ''
this.uploadFile = []
})
},
revertEmoji () {
revertEmoji() {
this.editedFile = this.file
this.editedShortcode = this.shortcode
},
deleteEmoji () {
deleteEmoji() {
this.deleteModalVisible = false
this.$store.state.api.backendInteractor.deleteEmojiFile(
{ packName: this.packName, shortcode: this.shortcode }
).then(resp => resp.json()).then(resp => {
if (resp.error !== undefined) {
this.$emit('displayError', resp.error)
return
}
this.$store.state.api.backendInteractor
.deleteEmojiFile({ packName: this.packName, shortcode: this.shortcode })
.then((resp) => resp.json())
.then((resp) => {
if (resp.error !== undefined) {
this.$emit('displayError', resp.error)
return
}
this.$emit('updatePackFiles', resp, this.packName)
})
}
}
this.$emit('updatePackFiles', resp, this.packName)
})
},
},
}
</script>

View file

@ -10,7 +10,7 @@
import NumberSetting from './number_setting.vue'
export default {
components: {
NumberSetting
}
NumberSetting,
},
}
</script>

View file

@ -1,13 +1,5 @@
import { isEqual } from 'lodash'
import Setting from './setting.js'
export default {
...Setting,
computed: {
...Setting.computed,
isDirty () {
return !isEqual(this.state, this.draft)
}
}
}

View file

@ -1,13 +1,13 @@
<template>
<span
v-if="matchesExpertLevel"
class="GroupSetting"
class="GroupSetting setting-item"
>
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
<ProfileSettingIndicator :is-profile="isProfileSetting" />
<LocalSettingIndicator :is-local="isLocalSetting" />
<DraftButtons />
</span>
</template>

View file

@ -5,7 +5,6 @@
:trigger-attrs="{ 'aria-label': $t('settings.setting_changed') }"
>
<template #trigger>
&nbsp;
<FAIcon icon="circle-question" />
</template>
<template #content>
@ -19,15 +18,14 @@
<script>
import Popover from 'src/components/popover/popover.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleQuestion } from '@fortawesome/free-solid-svg-icons'
library.add(
faCircleQuestion
)
library.add(faCircleQuestion)
export default {
components: { Popover }
components: { Popover },
}
</script>

View file

@ -11,7 +11,7 @@
import NumberSetting from './number_setting.vue'
export default {
components: {
NumberSetting
}
NumberSetting,
},
}
</script>

View file

@ -0,0 +1,135 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Setting from './setting.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
export default {
...Setting,
data() {
return {
newValue: '',
}
},
components: {
...Setting.components,
Checkbox,
},
props: {
...Setting.props,
ignoreSuggestions: {
required: false,
type: Boolean,
},
overrideAvailableOptions: {
required: false,
type: Boolean,
},
options: {
required: false,
type: Set,
},
allowNew: {
required: false,
type: Boolean,
default: true,
},
forceNew: {
required: false,
type: Boolean,
default: false,
},
},
computed: {
...Setting.computed,
showNew() {
if (this.forceNew) return true
if (!this.allowNew) return false
const isExpert = useMergedConfigStore().mergedConfig.expertLevel > 0
const hasBuiltins = this.builtinEntries.length > 0
if (hasBuiltins) {
return isExpert
} else {
return true
}
},
valueSet() {
return new Set(this.visibleState)
},
suggestionsSet() {
const suggestions = this.backendDescriptionSuggestions
if (suggestions) {
return new Set(suggestions)
} else {
return new Set()
}
},
extraEntries() {
if (this.ignoreSuggestions) return [...this.valueSet.values()]
if (!this.suggestionsSet) return []
return [...this.valueSet.values()].filter((x) => {
return !this.builtinEntriesValueSet.has(x)
})
},
builtinEntries() {
if (this.ignoreSuggestions) return []
if (this.overrideAvailableOptions) {
return [...this.options]
}
if (!this.suggestionsSet) return []
const builtins = [...this.suggestionsSet.values()]
return builtins.map((option) => ({
label: option,
value: option,
}))
},
builtinEntriesValueSet() {
return new Set(this.builtinEntries.map((x) => x.value))
},
},
methods: {
...Setting.methods,
optionPresent(option) {
return this.valueSet.has(option)
},
getValue({ event, value, index, eventType }) {
switch (eventType) {
case 'toggle': {
this.newValue = ''
const newSet = new Set(this.valueSet.values())
if (event) {
newSet.add(value)
} else {
newSet.delete(value)
}
return [...newSet.values()]
}
case 'add': {
if (!this.newValue) return this.valueSet.values()
const res = [...this.valueSet.values(), this.newValue]
this.newValue = ''
return res
}
case 'remove': {
const pre = [...this.valueSet.values()].slice(0, index)
const post = [...this.valueSet.values()].slice(index + 1)
return [...pre, ...post]
}
case 'edit': {
const pre = [...this.valueSet.values()].slice(0, index)
const post = [...this.valueSet.values()].slice(index + 1)
const string = event.target.value
if (!string) return [...this.valueSet.values()]
return [...pre, string, ...post]
}
}
},
},
}

View file

@ -0,0 +1,92 @@
<template>
<div
v-if="matchesExpertLevel"
class="ListSetting setting-item"
>
<label
class="setting-label"
: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>
<ul class="setting-list">
<li
v-for="(item, i) in builtinEntries"
:key="i"
>
<Checkbox
:disabled="shouldBeDisabled"
:model-value="optionPresent(item.value)"
@update:model-value="event => update({ event, value: item.value, eventType: 'toggle' })"
>
{{ item.label }}
</Checkbox>
</li>
<li
v-for="(item, index) in extraEntries"
:key="index"
>
<div class="btn-group">
<input
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:value="item"
@change="e => update({ event: e, index, eventType: 'edit' })"
>
<button
class="button-default"
@click="e => update({ index, eventType: 'remove' })"
>
<FAIcon icon="times" />
</button>
</div>
</li>
<li v-if="showNew">
<div class="btn-group">
<input
v-model="newValue"
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
>
<button
class="button-default"
@click="e => update({ eventType: 'add' })"
>
<FAIcon icon="plus" />
</button>
</div>
</li>
</ul>
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
<LocalSettingIndicator :is-local="isLocalSetting" />
<DraftButtons />
</div>
</template>
<script src="./list_setting.js"></script>
<style lang="scss">
.ListSetting {
.setting-list {
.checkbox {
padding: 0.5em 0;
}
}
}
</style>

View file

@ -0,0 +1,44 @@
import ListSetting from './list_setting.js'
export default {
...ListSetting,
data() {
return {
newValue: ['', ''],
}
},
methods: {
...ListSetting.methods,
getValue({ event, index, eventType, tuple }) {
switch (eventType) {
case 'add': {
if (!this.newValue[0] || !this.newValue[1]) return this.visibleState
const res = [...this.visibleState, this.newValue]
this.newValue = ['', '']
return res
}
case 'remove': {
const pre = this.visibleState.slice(0, index)
const post = this.visibleState.slice(index + 1)
return [...pre, ...post]
}
case 'edit': {
const pre = this.visibleState.slice(0, index)
const post = this.visibleState.slice(index + 1)
const item = this.visibleState[index]
const string = event.target.value
if (!string) return this.visibleState
if (tuple === 0) {
return [...pre, [string, item[1]], ...post]
} else {
return [...pre, [item[0], string], ...post]
}
}
}
},
},
}

View file

@ -0,0 +1,112 @@
<template>
<div
v-if="matchesExpertLevel"
class="ListTupleSetting"
>
<label
class="setting-label"
: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>
<ul class="setting-list">
<li
v-for="(item, index) in visibleState"
:key="index"
>
<div class="btn-group">
<input
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:value="item.tuple[0]"
@change="e => update({ event: e, index, eventType: 'edit', tuple: 0 })"
>
<input
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:value="item.tuple[1]"
@change="e => update({ event: e, index, eventType: 'edit', tuple: 1 })"
>
<button
class="button-default"
@click="e => update({ index, eventType: 'remove' })"
>
<FAIcon icon="times" />
</button>
</div>
</li>
<li>
<div class="btn-group">
<input
v-model="newValue[0]"
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
:placeholder="backendDescriptionSuggestions[0][0]"
>
<input
v-model="newValue[1]"
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
:placeholder="backendDescriptionSuggestions[0][1]"
>
<button
class="button-default"
@click="e => update({ eventType: 'add' })"
>
<FAIcon icon="plus" />
</button>
</div>
</li>
</ul>
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
<DraftButtons />
</div>
</template>
<script src="./list_tuple_setting.js"></script>
<style lang="scss">
.ListTupleSetting {
.btn-group {
display: flex
}
dl {
display: inline-grid;
grid-template-columns: auto auto;
gap: 0.5em;
align-items: baseline;
dt {
display: inline;
font-weight: 800;
&::after {
content: ':'
}
}
dd {
display: inline;
margin: 0
}
}
}
</style>

View file

@ -1,21 +1,20 @@
<template>
<span
v-if="isProfile"
class="ProfileSettingIndicator"
v-if="isLocal"
class="LocalSettingIndicator"
>
<Popover
trigger="hover"
>
<template #trigger>
&nbsp;
<FAIcon
icon="server"
:aria-label="$t('settings.setting_server_side')"
icon="desktop"
:aria-label="$t('settings.setting_local_side')"
/>
</template>
<template #content>
<div class="profilesetting-tooltip">
{{ $t('settings.setting_server_side') }}
{{ $t('settings.setting_local_side') }}
</div>
</template>
</Popover>
@ -24,21 +23,20 @@
<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
)
import { library } from '@fortawesome/fontawesome-svg-core'
import { faDesktop } from '@fortawesome/free-solid-svg-icons'
library.add(faDesktop)
export default {
components: { Popover },
props: ['isProfile']
props: ['isLocal'],
}
</script>
<style lang="scss">
.ProfileSettingIndicator {
.LocalSettingIndicator {
display: inline-block;
position: relative;
}

View file

@ -0,0 +1,73 @@
import Setting from './setting.js'
export default {
...Setting,
props: {
...Setting.props,
allowNew: {
required: false,
type: Boolean,
default: true,
},
},
data() {
return {
newValue: ['', ''], // avoiding extra complexity by just using an array instead of an object
}
},
computed: {
...Setting.computed,
// state that we'll show in the UI, i.e. transforming map into list
displayState() {
return Object.entries(this.visibleState)
},
},
methods: {
...Setting.methods,
getValue({ event, key, eventType, isKey }) {
switch (eventType) {
case 'add': {
if (key === '') return this.visibleState
const res = {
...this.visibleState,
...Object.fromEntries([this.newValue]),
}
this.newValue = ['', '']
return res
}
case 'remove': {
// initial state for this type is empty array
if (Array.isArray(this.visibleState)) return this.visibleState
const newEntries = Object.entries(this.visibleState).filter(
([k]) => k !== key,
)
if (newEntries.length === 0) return []
return Object.fromEntries(newEntries)
}
case 'edit': {
const string = event.target.value
const newEntries = Object.entries(this.visibleState).map(([k, v]) => {
if (isKey) {
if (k === key) {
return [string, v]
} else {
return [k, v]
}
} else {
if (k === key) {
return [k, string]
} else {
return [k, v]
}
}
})
return Object.fromEntries(newEntries)
}
}
},
},
}

View file

@ -0,0 +1,112 @@
<template>
<div
v-if="matchesExpertLevel"
class="MapSetting setting-item"
>
<label
class="setting-label"
: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>
<ul class="setting-list">
<li
v-for="(item, i) in displayState"
:key="i"
>
<div class="btn-group">
<input
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:value="item[0]"
@change="e => update({ event: e, key: item[0], eventType: 'edit', isKey: true })"
>
<input
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:value="item[1]"
@change="e => update({ event: e, key: item[0], eventType: 'edit', isKey: false })"
>
<button
class="button-default"
@click="e => update({ key: item[0], eventType: 'remove' })"
>
<FAIcon icon="times" />
</button>
</div>
</li>
<li v-if="allowNew">
<div class="btn-group">
<input
v-model="newValue[0]"
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
:placeholder="backendDescriptionSuggestions[0][0]"
>
<input
v-model="newValue[1]"
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
:placeholder="backendDescriptionSuggestions[0][1]"
>
<button
class="button-default"
@click="e => update({ eventType: 'add' })"
>
<FAIcon icon="plus" />
</button>
</div>
</li>
</ul>
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
<LocalSettingIndicator :is-local="isLocalSetting" />
<DraftButtons />
</div>
</template>
<script src="./map_setting.js"></script>
<style lang="scss">
.MapSetting {
.btn-group {
display: flex
}
dl {
display: inline-grid;
grid-template-columns: auto auto;
gap: 0.5em;
dt {
display: inline;
font-weight: 800;
&::after {
content: ':'
}
}
dd {
display: inline;
margin: 0
}
}
}
</style>

View file

@ -8,7 +8,6 @@
:trigger-attrs="{ 'aria-label': $t('settings.setting_changed') }"
>
<template #trigger>
&nbsp;
<FAIcon
icon="wrench"
/>
@ -24,12 +23,11 @@
<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
)
library.add(faWrench)
export default {
components: { Popover },
@ -37,9 +35,9 @@ export default {
changed: Boolean,
messageKey: {
type: String,
default: 'settings.setting_changed'
}
}
default: 'settings.setting_changed',
},
},
}
</script>

View file

@ -7,33 +7,33 @@ export default {
min: {
type: Number,
required: false,
default: 1
default: 1,
},
max: {
type: Number,
required: false,
default: 1
default: 1,
},
step: {
type: Number,
required: false,
default: 1
default: 1,
},
truncate: {
type: Number,
required: false,
default: 1
}
default: 1,
},
},
methods: {
...Setting.methods,
getValue (e) {
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 parseFloat(e.target.value)
}
}
},
},
}

View file

@ -1,12 +1,21 @@
<template>
<span
v-if="matchesExpertLevel"
class="NumberSetting"
class="NumberSetting setting-item"
>
<label
v-if="!hideLabel"
:for="path"
class="setting-label"
:class="{ 'faint': shouldBeDisabled }"
>
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
<LocalSettingIndicator :is-local="isLocalSetting" />
{{ ' ' }}
<DraftButtons v-if="!hideDraftButtons" />
<template v-if="backendDescriptionLabel">
{{ backendDescriptionLabel + ' ' }}
</template>
@ -18,21 +27,15 @@
{{ ' ' }}
<input
:id="path"
class="input number-input"
class="input number-input setting-control"
type="number"
:step="step || 1"
:disabled="shouldBeDisabled"
:placeholder="backendDescriptionSuggestions"
:min="min || 0"
:value="realDraftMode ? draft :state"
@change="update"
>
{{ ' ' }}
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
<ProfileSettingIndicator :is-profile="isProfileSetting" />
<DraftButtons />
<p
v-if="backendDescriptionDescription"
class="setting-description"

View file

@ -0,0 +1,54 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Setting from './setting.js'
const getUrl = (state) =>
state?.tuple ? state.tuple[1] + ':' + state.tuple[2] : state
const getSocks = (state) => state?.tuple
export default {
...Setting,
data() {
return {
urlField: '',
socksField: false,
}
},
created() {
Setting.created()
this.urlField = getUrl(this.realDraftMode ? this.draft : this.state)
this.socksField = getSocks(this.realDraftMode ? this.draft : this.state)
},
computed: {
...Setting.computed,
// state that we'll show in the UI, i.e. transforming map into list
displayState() {
if (this.visibleState?.tuple) {
return this.visibleState.tuple[1] + ':' + this.visibleState.tuple[2]
}
return this.visibleState
},
socksState() {
return getSocks(this.visibleState)
},
},
components: {
...Setting.components,
Checkbox,
},
methods: {
...Setting.methods,
getValue({ event, isProxy }) {
if (isProxy) {
this.socksField = event
} else {
this.urlField = event.target.value
}
if (this.socksField) {
return { tuple: [':socks5', ...this.urlField.split(':')] }
} else {
return this.urlField
}
},
},
}

View file

@ -0,0 +1,58 @@
<template>
<label
v-if="matchesExpertLevel"
class="ProxySetting setting-item"
>
<label
v-if="!hideLabel"
:for="path"
class="setting-label"
:class="{ 'faint': shouldBeDisabled }"
>
<template v-if="backendDescriptionLabel">
{{ backendDescriptionLabel + ' ' }}
</template>
<template v-else-if="source === 'admin'">
MISSING LABEL FOR {{ path }}
</template>
<slot v-else />
</label>
{{ ' ' }}
<div class="setting-control">
<input
:id="path"
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
:placeholder="backendDescriptionSuggestions[0]"
:value="displayState"
@change="event => update({ event })"
>
{{ ' ' }}
<Checkbox
:model-value="socksState"
:disabled="shouldBeDisabled"
:indeterminate="isIndeterminate"
@update:model-value="event => update({ event, isProxy: true})"
>
SOCKS5
{{ ' ' }}
</Checkbox>
</div>
{{ ' ' }}
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
<DraftButtons v-if="!hideDraftButtons" />
<p
v-if="backendDescriptionDescription"
class="setting-description"
:class="{ 'faint': shouldBeDisabled }"
>
{{ backendDescriptionDescription + ' ' }}
</p>
</label>
</template>
<script src="./proxy_setting.js"></script>

View file

@ -0,0 +1,102 @@
import { clone } from 'lodash'
import Attachment from 'src/components/attachment/attachment.vue'
import MediaUpload from 'src/components/media_upload/media_upload.vue'
import Select from 'src/components/select/select.vue'
import Setting from './setting.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { fileTypeExt } from 'src/services/file_type/file_type.service.js'
export default {
...Setting,
components: {
...Setting.components,
Select,
Attachment,
MediaUpload,
},
computed: {
...Setting.computed,
purposeOptions() {
return ['any', 'monochrome', 'maskable'].map((value) => ({
value,
key: value,
label: this.$t('admin_dash.instance.pwa.icon.' + value),
}))
},
},
methods: {
...Setting.methods,
attachment(e) {
const path = e[':src']
if (!path) {
return {
type: '',
url: '',
}
}
const url = path.includes('://') ? path : useInstanceStore().server + path
return {
type: fileTypeExt(url),
url,
}
},
setMediaFile({ event, index }) {
this.update({
event: {
target: {
value: event.url,
},
},
index,
eventType: 'edit',
field: ':src',
})
},
setPurpose({ event, index }) {
this.update({
event: {
target: {
value: event,
},
},
index,
eventType: 'edit',
field: ':purpose',
})
},
getValue({ event, field, index, eventType }) {
switch (eventType) {
case 'add': {
const res = [...this.visibleState, {}]
return res
}
case 'remove': {
const pre = this.visibleState.slice(0, index)
const post = this.visibleState.slice(index + 1)
return [...pre, ...post]
}
case 'edit': {
const pre = this.visibleState.slice(0, index)
const post = this.visibleState.slice(index + 1)
const item = clone(this.visibleState[index])
const string = event.target.value
if (!string) {
delete item[field]
} else {
item[field] = string
}
return [...pre, item, ...post]
}
}
},
},
}

View file

@ -0,0 +1,225 @@
<template>
<div
v-if="matchesExpertLevel"
class="PWAManifestIconsSetting setting-item"
>
<label
class="pwa-label setting-label"
:class="{ 'faint': shouldBeDisabled }"
>
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
{{ ' ' }}
<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="setting-control">
<ul class="item-list">
<li
v-if="visibleState.length === 0"
class="no_items"
>
{{ $t('admin_dash.instance.pwa.no_icons') }}
<button
v-if="visibleState.length === 0"
class="button-default add-button"
@click="e => update({ eventType: 'add' })"
>
<FAIcon icon="plus" />
</button>
</li>
<li
v-for="(item, index) in visibleState"
:key="index"
>
<div class="icon-element">
<div class="src-field">
<Attachment
class="src-attachment"
:compact="true"
:attachment="attachment(item)"
size="small"
hide-description
/>
<div class="src-url">
<label for="path">{{ $t('settings.url') }}</label>
<input
:id="path"
class="input string-input"
:disabled="shouldBeDisabled"
:value="item[':src']"
@change="event => update({ event, index, eventType: 'edit', field: ':src' })"
>
</div>
<MediaUpload
ref="mediaUpload"
class="src-upload media-upload-icon"
:class="{ disabled: shouldBeDisabled }"
normal-button
accept-types="image"
@uploaded="event => setMediaFile({ event, index })"
/>
</div>
<dl>
<dt>{{ $t('admin_dash.instance.pwa.icon.purpose') }}</dt>
<dd>
<Select
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
:model-value="item[':purpose']"
@update:model-value="event => setPurpose({ event, index })"
>
<option
v-for="(purpose, index2) in purposeOptions"
:key="index2"
:value="purpose.value"
>
{{ purpose.label }}
</option>
</Select>
</dd>
<dt><code>sizes</code>{{ $t('admin_dash.instance.pwa.optional') }}</dt>
<dd>
<input
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:value="item[':sizes']"
@change="e => update({ event: e, index, eventType: 'edit', field: ':sizes' })"
>
</dd>
<dt><code>type</code>{{ $t('admin_dash.instance.pwa.optional') }}</dt>
<dd>
<input
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:value="item[':type']"
@change="e => update({ event: e, index, eventType: 'edit', field: ':type' })"
>
</dd>
</dl>
<div class="buttons">
<button
v-if="index === visibleState.length - 1"
class="button-default add-button"
@click="e => update({ eventType: 'add' })"
>
<FAIcon icon="plus" />
</button>
<button
class="button-default delete-button"
@click="e => update({ index, eventType: 'remove' })"
>
<FAIcon icon="times" />
</button>
</div>
</div>
</li>
</ul>
</div>
</div>
</template>
<script src="./pwa_manifest_icons_setting.js"></script>
<style lang="scss">
div.PWAManifestIconsSetting {
margin-left: 3em;
&.setting-item {
display: grid;
grid-template-areas:
"label"
"desc"
"control"
"draft";
grid-template-rows: 2em auto 1fr;
grid-template-columns: 1fr;
}
.pwa-label.setting-label {
align-self: end;
text-align: left;
}
.buttons {
display: flex;
gap: 0.5em;
justify-content: right;
margin-top: 0.5em;
button {
line-height: 2;
}
}
ul {
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fit, 12em);
grid-gap: 2em;
}
dl {
display: grid;
grid-template-columns: 1fr;
margin-top: 0.5em;
gap: 0.25em;
align-items: baseline;
dt {
font-weight: 800;
&::after {
content: ':'
}
}
dd {
margin: 0
}
}
.src-field {
display: grid;
grid-template-columns: auto;
justify-items: center;
gap: 0.5em;
.src-attachment {
width: 10em;
height: 10em;
display: block;
margin-bottom: 0.5em;
}
.src-upload {
display: block;
width: 100%;
}
.src-url {
display: flex;
flex-direction: column;
gap: 0.25em;
width: 100%;
label {
display: block;
}
}
}
}
</style>

View file

@ -0,0 +1,53 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Setting from './setting.js'
export default {
...Setting,
data() {
return {
newValue: '',
}
},
components: {
...Setting.components,
Checkbox,
},
props: {
...Setting.props,
},
computed: {
...Setting.computed,
isSeparate() {
// [[a1, b1], [a2, b2]] vs [a, b]
return Array.isArray(this.visibleState[0])
},
normalizedState() {
if (this.isSeparate) {
return this.visibleState.map((y) => y.map((x) => Number(x) || 0))
} else {
return [this.visibleState.map((x) => Number(x) || 0)]
}
},
},
methods: {
...Setting.methods,
getValue({ event, side, index, eventType }) {
if (eventType === 'edit') {
const value = Number(event.target.value)
if (Number.isNaN(value)) return this.visibleState
const newVal = [...this.normalizedState.map((x) => [...x])]
newVal[side][index] = value
return newVal
}
if (eventType === 'toggleMode') {
if (event === 'split') {
return [this.normalizedState[0], this.normalizedState[0]]
} else if (event === 'join') {
return [this.normalizedState[0]]
}
}
},
},
}

View file

@ -0,0 +1,140 @@
<template>
<div
v-if="matchesExpertLevel"
class="RateSetting setting-item"
>
<label
class="setting-label"
:class="{ 'faint': shouldBeDisabled }"
>
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
<LocalSettingIndicator :is-local="isLocalSetting" />
{{ ' ' }}
<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="setting-control">
<table>
<thead>
<tr>
<th>&nbsp;</th>
<th>
{{ $t('admin_dash.rate_limit.period') }}
</th>
<th>
{{ $t('admin_dash.rate_limit.amount') }}
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
{{ isSeparate ? $t('admin_dash.rate_limit.unauthenticated') : $t('admin_dash.rate_limit.rate_limit') }}
</td>
<td>
<input
class="input string-input"
type="number"
:value="normalizedState[0][0]"
@change="e => update({ event: e, index: 0, side: 0, eventType: 'edit' })"
>
</td>
<td>
<input
class="input string-input"
type="number"
:value="normalizedState[0][1]"
@change="e => update({ event: e, index: 1, side: 0, eventType: 'edit' })"
>
</td>
</tr>
<tr v-if="isSeparate">
<td>
{{ $t('admin_dash.rate_limit.authenticated') }}
</td>
<td>
<input
class="input string-input"
type="number"
:value="normalizedState[1][0]"
@change="e => update({ event: e, index: 0, side: 1, eventType: 'edit' })"
>
</td>
<td>
<input
class="input string-input"
type="number"
:value="normalizedState[1][1]"
@change="e => update({ event: e, index: 1, side: 1, eventType: 'edit' })"
>
</td>
</tr>
</tbody>
</table>
<Checkbox
:model-value="isSeparate"
@update:model-value="event => update({ event: event ? 'join' : 'split', eventType: 'toggleMode' })"
>
{{ $t('admin_dash.rate_limit.separate') }}
</Checkbox>
</div>
<DraftButtons />
</div>
</template>
<script src="./rate_setting.js"></script>
<style lang="scss">
.RateSetting {
&.setting-item {
display: grid;
grid-template-areas:
"label control"
"desc control"
". draft";
.setting-label {
text-align: right;
align-self: center;
}
.setting-description {
text-align: right;
}
.setting-control {
align-self: end;
}
}
table {
margin-top: 0.5em;
}
th {
font-weight: normal;
}
td {
input {
width: 15ch;
}
}
margin-bottom: 2em;
}
</style>

View file

@ -1,99 +1,147 @@
import ModifiedIndicator from './modified_indicator.vue'
import ProfileSettingIndicator from './profile_setting_indicator.vue'
import { cloneDeep, get, isEqual, set } from 'lodash'
import DraftButtons from './draft_buttons.vue'
import { get, set, cloneDeep } from 'lodash'
import LocalSettingIndicator from './local_setting_indicator.vue'
import ModifiedIndicator from './modified_indicator.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
export default {
components: {
ModifiedIndicator,
DraftButtons,
ProfileSettingIndicator
LocalSettingIndicator,
},
props: {
modelValue: {
type: String,
default: null
default: null,
},
description: {
type: String,
default: null,
},
path: {
type: [String, Array],
required: false
required: false,
},
showDescription: {
type: Boolean,
required: false,
},
descriptionPathOverride: {
type: [String, Array],
required: false,
},
suggestions: {
type: [String, Array],
required: false,
},
subgroup: {
type: String,
required: false,
},
disabled: {
type: Boolean,
default: false
default: false,
},
local: {
type: Boolean,
default: false,
},
parentPath: {
type: [String, Array]
type: [String, Array],
},
parentInvert: {
type: Boolean,
default: false
default: false,
},
expert: {
type: [Number, String],
default: 0
default: 0,
},
source: {
type: String,
default: undefined
default: undefined,
},
hideDraftButtons: {
// this is for the weird backend hybrid (Boolean|String or Boolean|Number) settings
required: false,
type: Boolean,
},
hideLabel: {
type: Boolean,
},
hideDescription: {
type: Boolean
type: Boolean,
},
swapDescriptionAndLabel: {
type: Boolean
type: Boolean,
},
backendDescriptionPath: {
type: [String, Array],
},
overrideBackendDescription: {
type: Boolean
type: Boolean,
},
overrideBackendDescriptionLabel: {
type: Boolean
type: [Boolean, String],
},
draftMode: {
type: Boolean,
default: undefined
default: undefined,
},
timedApplyMode: {
type: Boolean,
default: false
}
default: false,
},
},
inject: {
defaultSource: {
default: 'default'
default: 'default',
},
defaultDraftMode: {
default: false
}
default: false,
},
},
data () {
data() {
return {
localDraft: null
localDraft: null,
}
},
created () {
if (this.realDraftMode && (this.realSource !== 'admin' || this.path == null)) {
this.draft = this.state
created() {
if (
this.realDraftMode &&
(this.realSource !== 'admin' || this.path == null)
) {
this.draft = cloneDeep(this.state)
}
},
computed: {
draft: {
get () {
get() {
if (this.realSource === 'admin' || this.path == null) {
return get(this.$store.state.adminSettings.draft, this.canonPath)
} else {
return this.localDraft
}
},
set (value) {
set(value) {
if (this.realSource === 'admin' || this.path == null) {
this.$store.commit('updateAdminDraft', { path: this.canonPath, value })
this.$store.commit('updateAdminDraft', {
path: this.canonPath,
value,
})
} else {
this.localDraft = value
}
}
},
},
state () {
state() {
if (this.path == null) {
return this.modelValue
}
@ -104,98 +152,189 @@ export default {
return value
}
},
visibleState () {
visibleState() {
return this.realDraftMode ? this.draft : this.state
},
realSource () {
realSource() {
return this.source || this.defaultSource
},
realDraftMode () {
return typeof this.draftMode === 'undefined' ? this.defaultDraftMode : this.draftMode
realDraftMode() {
return typeof this.draftMode === 'undefined'
? this.defaultDraftMode
: this.draftMode
},
backendDescription () {
return get(this.$store.state.adminSettings.descriptions, this.path)
backendDescription() {
return get(
this.$store.state.adminSettings.descriptions,
this.descriptionPath,
)
},
backendDescriptionLabel () {
backendDescriptionLabel() {
if (this.realSource !== 'admin') return ''
if (
this.overrideBackendDescriptionLabel !== '' &&
typeof this.overrideBackendDescriptionLabel === 'string'
) {
return this.overrideBackendDescriptionLabel
}
if (!this.backendDescription || this.overrideBackendDescriptionLabel) {
return this.$t([
'admin_dash',
'temp_overrides',
...this.canonPath.map(p => p.replace(/\./g, '_DOT_')),
'label'
].join('.'))
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 () {
backendDescriptionDescription() {
if (this.description) return this.description
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('.'))
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
backendDescriptionSuggestions() {
return this.backendDescription?.suggestions || this.suggestions
},
shouldBeDisabled () {
shouldBeDisabled() {
if (this.path == null) {
return this.disabled
}
const parentValue = this.parentPath !== undefined ? get(this.configSource, this.parentPath) : null
return this.disabled || (parentValue !== null ? (this.parentInvert ? parentValue : !parentValue) : false)
let parentValue = null
if (this.parentPath !== undefined && this.realSource === 'admin') {
if (this.realDraftMode) {
parentValue = get(
this.$store.state.adminSettings.draft,
this.parentPath,
)
} else {
parentValue = get(this.configSource, this.parentPath)
}
}
return (
this.disabled ||
(parentValue !== null
? this.parentInvert
? parentValue
: !parentValue
: false)
)
},
configSource () {
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
return useMergedConfigStore().mergedConfig
}
},
configSink () {
configSink() {
if (this.path == null) {
return (k, v) => this.$emit('update:modelValue', v)
}
switch (this.realSource) {
case 'profile':
return (k, v) => this.$store.dispatch('setProfileOption', { name: k, value: v })
return (k, v) =>
this.$store.dispatch('setProfileOption', { name: k, value: v })
case 'admin':
return (k, v) => this.$store.dispatch('pushAdminSetting', { path: k, value: v })
return (k, v) =>
this.$store.dispatch('pushAdminSetting', { path: k, value: v })
default:
if (this.timedApplyMode) {
return (k, v) => this.$store.dispatch('setOptionTemporarily', { name: k, value: v })
} else {
return (k, v) => this.$store.dispatch('setOption', { name: k, value: v })
return (readPath, value) => {
const writePath = `${readPath}`
if (!this.timedApplyMode) {
if (this.local) {
useLocalConfigStore().set({
path: writePath,
value,
})
} else {
useSyncConfigStore().setSimplePrefAndSave({
path: writePath,
value,
})
}
} else {
if (useInterfaceStore().temporaryChangesTimeoutId !== null) {
console.error("Can't track more than one temporary change")
return
}
const oldValue = get(this.configSource, readPath)
if (this.local) {
useLocalConfigStore().setTemporarily({ path: writePath, value })
} else {
useSyncConfigStore().setPreference({ path: writePath, value })
}
const confirm = () => {
if (this.local) {
useLocalConfigStore().set({ path: writePath, value })
} else {
useSyncConfigStore().pushSyncConfig()
}
useInterfaceStore().clearTemporaryChanges()
}
const revert = () => {
if (this.local) {
useLocalConfigStore().unsetTemporarily({
path: writePath,
value,
})
} else {
useSyncConfigStore().setPreference({
path: writePath,
value: oldValue,
})
}
useInterfaceStore().clearTemporaryChanges()
}
useInterfaceStore().setTemporaryChanges({ confirm, revert })
}
}
}
},
defaultState () {
defaultState() {
switch (this.realSource) {
case 'profile':
return {}
default:
return get(this.$store.getters.defaultConfig, this.path)
default: {
return get(useMergedConfigStore().mergedConfigDefault, this.path)
}
}
},
isProfileSetting () {
isProfileSetting() {
return this.realSource === 'profile'
},
isChanged () {
isLocalSetting() {
return this.local
},
isChanged() {
if (this.path == null) return false
switch (this.realSource) {
case 'profile':
@ -205,57 +344,85 @@ export default {
return this.state !== this.defaultState
}
},
canonPath () {
canonPath() {
if (this.path == null) return null
return Array.isArray(this.path) ? this.path : this.path.split('.')
},
isDirty () {
descriptionPath() {
if (this.path == null) return null
if (this.descriptionPathOverride) return this.descriptionPathOverride
const path = Array.isArray(this.path) ? this.path : this.path.split('.')
if (this.subgroup) {
return [
...path.slice(0, path.length - 1),
':subgroup,' + this.subgroup,
...path.slice(path.length - 1),
]
}
return path
},
isDirty() {
if (this.path == null) return false
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
return this.realDraftMode && !isEqual(this.draft, this.state)
}
},
canHardReset () {
return this.realSource === 'admin' && this.$store.state.adminSettings.modifiedPaths &&
this.$store.state.adminSettings.modifiedPaths.has(this.canonPath.join(' -> '))
canHardReset() {
return (
this.realSource === 'admin' &&
this.$store.state.adminSettings.modifiedPaths?.has(
this.canonPath.join(' -> '),
)
)
},
matchesExpertLevel() {
const settingExpertLevel = this.expert || 0
const userToggleExpert =
useMergedConfigStore().mergedConfig.expertLevel || 0
return settingExpertLevel <= userToggleExpert
},
matchesExpertLevel () {
return (this.expert || 0) <= this.$store.state.config.expertLevel > 0
}
},
methods: {
getValue (e) {
getValue(e) {
return e.target.value
},
update (e) {
update(e) {
if (this.realDraftMode) {
this.draft = this.getValue(e)
} else {
this.configSink(this.path, this.getValue(e))
}
},
commitDraft () {
commitDraft() {
if (this.realDraftMode) {
this.configSink(this.path, this.draft)
}
},
reset () {
reset() {
if (this.realDraftMode) {
this.draft = cloneDeep(this.state)
} else {
set(this.$store.getters.mergedConfig, this.path, cloneDeep(this.defaultState))
set(
useMergedConfigStore().mergedConfig,
this.path,
cloneDeep(this.defaultState),
)
}
},
hardReset () {
hardReset() {
switch (this.realSource) {
case 'admin':
return this.$store.dispatch('resetAdminSetting', { path: this.path })
.then(() => { this.draft = this.state })
return this.$store
.dispatch('resetAdminSetting', { path: this.path })
.then(() => {
this.draft = this.state
})
default:
console.warn('Hard reset not implemented yet!')
}
}
}
},
},
}

View file

@ -1,19 +1,18 @@
import { mapState as mapPiniaState } from 'pinia'
import { mapState } from 'vuex'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
const SharedComputedObject = () => ({
user () {
return this.$store.state.users.currentUser
},
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
}
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
...mapPiniaState(useMergedConfigStore, {
expertLevel: (store) => store.mergedConfig.expertLevel,
}),
...mapState({
adminConfig: (state) => state.adminSettings.config,
adminDraft: (state) => state.adminSettings.draft,
user: (state) => state.users.currentUser,
}),
})
export default SharedComputedObject

View file

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

View file

@ -1,13 +1,20 @@
<template>
<label
<span
v-if="matchesExpertLevel"
class="StringSetting"
class="StringSetting setting-item"
>
<label
v-if="!hideLabel"
:for="path"
class="setting-label"
:class="{ 'faint': shouldBeDisabled }"
>
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
<LocalSettingIndicator :is-local="isLocalSetting" />
{{ ' ' }}
<template v-if="backendDescriptionLabel">
{{ backendDescriptionLabel + ' ' }}
</template>
@ -16,21 +23,17 @@
</template>
<slot v-else />
</label>
{{ ' ' }}
<input
:id="path"
class="input string-input"
class="setting-control input string-input"
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
:placeholder="backendDescriptionSuggestions"
:value="realDraftMode ? draft : state"
@change="update"
>
{{ ' ' }}
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
<ProfileSettingIndicator :is-profile="isProfileSetting" />
<DraftButtons />
<DraftButtons v-if="!hideDraftButtons" />
<p
v-if="backendDescriptionDescription"
class="setting-description"
@ -38,7 +41,7 @@
>
{{ backendDescriptionDescription + ' ' }}
</p>
</label>
</span>
</template>
<script src="./string_setting.js"></script>

View file

@ -0,0 +1,16 @@
import Setting from './setting.js'
export default {
...Setting,
methods: {
...Setting.methods,
getValue({ e, side }) {
const [a, b] = this.visibleState || []
if (side === 0) {
return { tuple: [e.target.value, b] }
} else {
return { tuple: [a, e.target.value] }
}
},
},
}

View file

@ -0,0 +1,58 @@
<template>
<span
v-if="matchesExpertLevel"
class="setting-item"
>
<label
v-if="!hideLabel"
:for="path"
class="setting-label"
:class="{ 'faint': shouldBeDisabled }"
>
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
<LocalSettingIndicator :is-local="isLocalSetting" />
{{ ' ' }}
<template v-if="backendDescriptionLabel">
{{ backendDescriptionLabel + ' ' }}
</template>
<template v-else-if="source === 'admin'">
MISSING LABEL FOR {{ path }}
</template>
<slot v-else />
</label>
<span class="setting-control">
<input
:id="path"
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
:placeholder="backendDescriptionSuggestions?.[0]?.[0]"
:value="visibleState?.tuple?.[0]"
@change="e => update({ e, side: 0 })"
>
{{ ' ' }}
<input
:id="path"
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
:placeholder="backendDescriptionSuggestions?.[0]?.[1]"
:value="visibleState?.tuple?.[1]"
@change="e => update({ e, side: 1 })"
>
</span>
<DraftButtons v-if="!hideDraftButtons" />
<p
v-if="backendDescriptionDescription"
class="setting-description"
:class="{ 'faint': shouldBeDisabled }"
>
{{ backendDescriptionDescription + ' ' }}
</p>
</span>
</template>
<script src="./tuple_setting.js"></script>

View file

@ -1,7 +1,23 @@
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 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']
@ -9,47 +25,51 @@ export default {
...Setting,
components: {
...Setting.components,
Select
Select,
},
props: {
...Setting.props,
min: Number,
units: {
type: Array,
default: () => allCssUnits
default: () => allCssUnits,
},
unitSet: {
type: String,
default: 'none'
default: 'none',
},
step: {
type: Number,
default: 1
default: 1,
},
resetDefault: {
type: Object,
default: null
}
default: null,
},
},
computed: {
...Setting.computed,
stateUnit () {
return typeof this.state === 'string' ? this.state.replace(/[0-9,.]+/, '') : ''
stateUnit() {
return typeof this.state === 'string'
? this.state.replace(/[0-9,.]+/, '')
: ''
},
stateValue() {
return typeof this.state === 'string'
? this.state.replace(/[^0-9,.]+/, '')
: ''
},
stateValue () {
return typeof this.state === 'string' ? this.state.replace(/[^0-9,.]+/, '') : ''
}
},
methods: {
...Setting.methods,
getUnitString (value) {
getUnitString(value) {
if (this.unitSet === 'none') return value
return this.$t(['settings', 'units', this.unitSet, value].join('.'))
},
updateValue (e) {
updateValue(e) {
this.configSink(this.path, parseFloat(e.target.value) + this.stateUnit)
},
updateUnit (e) {
updateUnit(e) {
let value = this.stateValue
const newUnit = e.target.value
if (this.resetDefault) {
@ -59,6 +79,6 @@ export default {
}
}
this.configSink(this.path, value + newUnit)
}
}
},
},
}

View file

@ -1,16 +1,21 @@
<template>
<span
v-if="matchesExpertLevel"
class="UnitSetting"
class="UnitSetting setting-item"
>
<label
:for="path"
class="size-label"
class="setting-label size-label"
>
<LocalSettingIndicator :is-local="isLocalSetting" />
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
{{ ' ' }}
<slot />
</label>
{{ ' ' }}
<span class="no-break">
<span class="no-break setting-control">
<input
:id="path"
class="input number-input"
@ -38,10 +43,6 @@
</Select>
</span>
{{ ' ' }}
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
</span>
</template>
@ -50,7 +51,7 @@
<style lang="scss">
.UnitSetting {
.no-break {
display: inline-block;
display: inline-flex;
}
.number-input {

View file

@ -0,0 +1,209 @@
// eslint-disable-next-line no-unused
import { throttle } from 'lodash'
import { mapState as mapPiniaState, mapState } from 'pinia'
import { Fragment, h } from 'vue'
import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome'
import './vertical_tab_switcher.scss'
import { useInterfaceStore } from 'src/stores/interface.js'
const findFirstUsable = (slots) => slots.findIndex((_) => _.props)
export default {
name: 'VerticalTabSwitcher',
props: {
renderOnlyFocused: {
required: false,
type: Boolean,
default: false,
},
onSwitch: {
required: false,
type: Function,
default: undefined,
},
activeTab: {
required: false,
type: String,
default: undefined,
},
bodyScrollLock: {
required: false,
type: Boolean,
default: false,
},
},
data() {
return {
active: findFirstUsable(this.slots()),
resizeHandler: null,
navSide: 'tabs',
}
},
computed: {
activeIndex() {
// In case of controlled component
if (this.activeTab) {
return this.slots().findIndex(
(slot) => slot && slot.props && this.activeTab === slot.props.key,
)
} else {
return this.active
}
},
isActive() {
return (tabName) => {
const isWanted = (slot) =>
slot.props && slot.props['data-tab-name'] === tabName
return this.$slots.default().findIndex(isWanted) === this.activeIndex
}
},
...mapPiniaState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
},
beforeUpdate() {
const currentSlot = this.slots()[this.active]
if (!currentSlot.props) {
this.active = findFirstUsable(this.slots())
}
},
methods: {
clickTab(index) {
return (e) => {
e.preventDefault()
this.setTab(index)
}
},
setTab(index) {
if (typeof this.onSwitch === 'function') {
this.onSwitch.call(null, this.slots()[index].key)
}
this.active = index
this.changeNavSide('content')
},
changeNavSide(side) {
if (this.navSide !== side) {
this.navSide = side
}
},
// DO NOT put it to computed, it doesn't work (caching?)
slots() {
if (this.$slots.default()[0].type === Fragment) {
return this.$slots.default()[0].children
}
return this.$slots.default()
},
},
render() {
const tabs = this.slots().map((slot, index) => {
const props = slot.props
if (!props) return
const classesTab = ['vertical-tab', 'menu-item']
if (
this.activeIndex === index &&
useInterfaceStore().layoutType !== 'mobile'
) {
classesTab.push('-active')
}
return (
<button
disabled={props.disabled}
onClick={this.clickTab(index)}
class={classesTab.join(' ')}
type="button"
role="tab"
title={props.label}
>
{!props.icon ? (
''
) : (
<FAIcon class="tab-icon" size="1x" fixed-width icon={props.icon} />
)}
<span class="text">{props.label}</span>
</button>
)
})
const contents = this.slots().map((slot, index) => {
const props = slot.props
if (!props) return
const active = this.activeIndex === index
let delayRender = slot.props['delay-render']
if (delayRender && active) {
slot.props['delay-render'] = false
delayRender = false
}
const renderSlot =
!delayRender && (!this.renderOnlyFocused || active) ? slot : ''
const headerClasses = ['tab-content-label']
const header = (
<h2 class={headerClasses}>
<button
type="button"
onClick={() => this.changeNavSide('tabs')}
title={this.$t('nav.back')}
class="button-unstyled"
>
<FAIcon size="lg" class="back-button-icon" icon="chevron-left" />
</button>
{props.label}
</h2>
)
const wrapperClasses = [
'tab-content-wrapper',
active ? '-active' : '-hidden',
]
const contentClasses = ['tab-content']
if (props['full-width'] || props['full-width'] === '') {
contentClasses.push('-full-width')
wrapperClasses.push('-full-width')
}
if (props['full-height'] || props['full-width'] === '') {
contentClasses.push('-full-height')
wrapperClasses.push('-full-height')
}
return (
<div class={wrapperClasses}>
<div class="tab-mobile-header">{header}</div>
<div class="tab-slot-wrapper">
<div class={contentClasses}>{renderSlot}</div>
</div>
</div>
)
})
const rootClasses = ['vertical-tab-switcher']
if (useInterfaceStore().layoutType === 'mobile') {
rootClasses.push('-mobile')
}
if (this.navSide === 'tabs') {
rootClasses.push('-nav-tabs')
} else {
rootClasses.push('-nav-contents')
}
return (
<div ref="root" class={rootClasses.join(' ')}>
<div class="tabs" role="tablist" ref="nav">
{tabs}
</div>
<div
role="tabpanel"
class="contents"
v-body-scroll-lock={this.bodyScrollLock}
ref="contents"
>
{contents}
</div>
</div>
)
},
}

View file

@ -0,0 +1,184 @@
.vertical-tab-switcher {
display: flex;
flex-direction: row;
container-type: inline-size;
> .tabs {
flex: 0 0 15em;
flex-direction: column;
overflow: hidden auto;
white-space: nowrap;
text-overflow: ellipsis;
width: 15em;
min-width: 15em;
border-right: 1px solid;
border-right-color: var(--border);
box-sizing: border-box;
> .menu-item {
padding: 0.5em 1em;
.tab-icon {
vertical-align: middle;
margin-right: 0.75em;
}
}
}
> .contents {
.tab-content-label {
box-sizing: border-box;
margin: 0;
border-bottom: 1px solid var(--border);
display: none;
button {
box-sizing: border-box;
padding: 0.5em;
}
}
.tab-slot-wrapper {
flex: 1 1 auto;
height: 100%;
padding: 0 1em;
overflow-y: auto;
display: grid;
grid-template-columns: minmax(1em, 1fr) minmax(min-content, 45em) minmax(1em, 1fr);
grid-template-areas: ". content .";
flex-direction: column;
.tab-content {
grid-area: content;
&.-full-width {
grid-column: 1 / 4;
}
&.-full-height {
> * {
height: 100%;
}
}
}
}
.tab-content-wrapper {
flex: 1 1 auto;
height: 100%;
display: flex;
flex-direction: column;
&.-hidden {
display: none;
}
}
}
&.-mobile {
> .contents {
.tab-content-label {
display: block
}
}
&.-nav-contents {
> .contents {
display: block;
flex-grow: 1;
flex-shrink: 1;
}
> .tabs {
display: none;
flex-grow: 0;
flex-shrink: 1;
}
}
&.-nav-tabs {
> .tabs {
display: block;
flex-grow: 1;
}
> .contents {
display: none;
flex-grow: 0;
flex-shrink: 1;
}
}
.tab-slot-wrapper {
grid-template-columns: 0 minmax(min-content, 45em) 0;
}
}
@supports (container-type: inline-size) {
&,
&.-mobile {
&.-nav-contents,
&.-nav-tabs {
/* I THINK it's a false positive and eslint doesn't understand the @-rule */
/* stylelint-disable no-descending-specificity */
> .contents {
display: block;
flex-grow: 1;
}
> .tabs {
display: block;
flex-grow: 0;
}
/* stylelint-enable no-descending-specificity */
}
}
@container (width < 50em) {
> .contents {
.tab-content-label {
display: block
}
}
&.-mobile {
> .contents {
.tab-content-label {
display: block
}
}
}
&,
&.-mobile {
&.-nav-contents {
> .contents {
display: block;
flex-grow: 1;
}
> .tabs {
display: none;
flex-grow: 0;
flex-shrink: 1;
}
}
&.-nav-tabs {
/* stylelint-disable no-descending-specificity */
> .tabs {
display: block;
flex-grow: 1;
}
> .contents {
display: none;
flex-grow: 0;
flex-shrink: 1;
}
/* stylelint-enable no-descending-specificity */
}
}
}
}
}

View file

@ -1,27 +1,38 @@
import { cloneDeep, isEqual } from 'lodash'
import { mapActions, mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import AsyncComponentError from 'src/components/async_component_error/async_component_error.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Modal from 'src/components/modal/modal.vue'
import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
import AsyncComponentError from 'src/components/async_component_error/async_component_error.vue'
import getResettableAsyncComponent from 'src/services/resettable_async_component.js'
import Popover from '../popover/popover.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ConfirmModal from 'src/components/confirm_modal/confirm_modal.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { cloneDeep, isEqual } from 'lodash'
import { mapState, mapActions } from 'pinia'
import Popover from 'src/components/popover/popover.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import {
LOCAL_ONLY_KEYS,
ROOT_CONFIG,
ROOT_CONFIG_DEFINITIONS,
validateSetting,
} from 'src/modules/default_config_state.js'
import {
newExporter,
newImporter,
newExporter
} from 'src/services/export_import/export_import.js'
import getResettableAsyncComponent from 'src/services/resettable_async_component.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faWindowMinimize } from '@fortawesome/free-regular-svg-icons'
import {
faTimes,
faFileUpload,
faChevronDown,
faFileDownload,
faChevronDown
faFileUpload,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
import {
faWindowMinimize
} from '@fortawesome/free-regular-svg-icons'
import { useInterfaceStore } from 'src/stores/interface'
const PLEROMAFE_SETTINGS_MAJOR_VERSION = 1
const PLEROMAFE_SETTINGS_MINOR_VERSION = 0
@ -31,60 +42,63 @@ library.add(
faWindowMinimize,
faFileUpload,
faFileDownload,
faChevronDown
faChevronDown,
)
const SettingsModal = {
data () {
data() {
return {
dataImporter: newImporter({
validator: this.importValidator,
onImport: this.onImport,
onImportFailure: this.onImportFailure
onImportFailure: this.onImportFailure,
}),
dataThemeExporter: newExporter({
filename: 'pleromafe_settings.full',
getExportedObject: () => this.generateExport(true)
getExportedObject: () => this.generateExport(true),
}),
dataExporter: newExporter({
filename: 'pleromafe_settings',
getExportedObject: () => this.generateExport()
})
getExportedObject: () => this.generateExport(),
}),
}
},
components: {
Modal,
Popover,
Checkbox,
ConfirmModal,
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
SettingsModalUserContent: getResettableAsyncComponent(
() => import('./settings_modal_user_content.vue'),
{
loadingComponent: PanelLoading,
errorComponent: AsyncComponentError,
delay: 0
}
delay: 0,
},
),
SettingsModalAdminContent: getResettableAsyncComponent(
() => import('./settings_modal_admin_content.vue'),
{
loadingComponent: PanelLoading,
errorComponent: AsyncComponentError,
delay: 0
}
)
delay: 0,
},
),
},
methods: {
closeModal () {
closeModal() {
useInterfaceStore().closeSettingsModal()
},
peekModal () {
peekModal() {
useInterfaceStore().togglePeekSettingsModal()
},
importValidator (data) {
importValidator(data) {
if (!Array.isArray(data._pleroma_settings_version)) {
return {
messageKey: 'settings.file_import_export.invalid_file'
messageKey: 'settings.file_import_export.invalid_file',
}
}
@ -95,8 +109,8 @@ const SettingsModal = {
messageKey: 'settings.file_export_import.errors.file_too_new',
messageArgs: {
fileMajor: major,
feMajor: PLEROMAFE_SETTINGS_MAJOR_VERSION
}
feMajor: PLEROMAFE_SETTINGS_MAJOR_VERSION,
},
}
}
@ -105,94 +119,157 @@ const SettingsModal = {
messageKey: 'settings.file_export_import.errors.file_too_old',
messageArgs: {
fileMajor: major,
feMajor: PLEROMAFE_SETTINGS_MAJOR_VERSION
}
feMajor: PLEROMAFE_SETTINGS_MAJOR_VERSION,
},
}
}
if (minor > PLEROMAFE_SETTINGS_MINOR_VERSION) {
useInterfaceStore().pushGlobalNotice({
level: 'warning',
messageKey: 'settings.file_export_import.errors.file_slightly_new'
messageKey: 'settings.file_export_import.errors.file_slightly_new',
})
}
return true
},
onImportFailure (result) {
onImportFailure(result) {
if (result.error) {
useInterfaceStore().pushGlobalNotice({ messageKey: 'settings.invalid_settings_imported', level: 'error' })
useInterfaceStore().pushGlobalNotice({
messageKey: 'settings.invalid_settings_imported',
level: 'error',
})
} else {
useInterfaceStore().pushGlobalNotice({ ...result.validationResult, level: 'error' })
useInterfaceStore().pushGlobalNotice({
...result.validationResult,
level: 'error',
})
}
},
onImport (data) {
if (data) { this.$store.dispatch('loadSettings', data) }
onImport(input) {
if (!input) return
const { _pleroma_settings_version, ...data } = input
Object.entries(data).forEach(([path, value]) => {
const definition = ROOT_CONFIG_DEFINITIONS[path]
const finalValue = validateSetting({
path,
value,
definition,
throwError: false,
defaultState: ROOT_CONFIG,
})
if (finalValue === undefined) return
if (LOCAL_ONLY_KEYS.has(path)) {
useLocalConfigStore().set({ path, value: finalValue })
} else {
if (path.startsWith('muteFilters')) {
Object.keys(
useMergedConfigStore().mergedConfig.muteFilters,
).forEach((key) => {
useSyncConfigStore().unsetPreference({
path: `simple.${path}.${key}`,
})
})
Object.entries(value).forEach(([key, filter]) => {
useSyncConfigStore().setPreference({
path: `simple.${path}.${key}`,
value: filter,
})
})
} else {
if (finalValue !== undefined) {
useSyncConfigStore().setPreference({
path: `simple.${path}`,
value: finalValue,
})
}
}
}
})
useSyncConfigStore().pushSyncConfig()
},
restore () {
restore() {
this.dataImporter.importData()
},
backup () {
backup() {
this.dataExporter.exportData()
},
backupWithTheme () {
backupWithTheme() {
this.dataThemeExporter.exportData()
},
generateExport (theme = false) {
const { config } = this.$store.state
generateExport(theme = false) {
const config = useMergedConfigStore().mergedConfigWithoutDefaults
let sample = config
if (!theme) {
const ignoreList = new Set([
'theme',
'customTheme',
'customThemeSource',
'colors'
'colors',
'style',
'styleCustomData',
'palette',
'paletteCustomData',
'themeChecksum',
])
sample = Object.fromEntries(
Object
.entries(sample)
.filter(([key]) => !ignoreList.has(key))
Object.entries(sample).filter(
([key, value]) => !ignoreList.has(key) && value !== undefined,
),
)
}
const clone = cloneDeep(sample)
clone._pleroma_settings_version = [
PLEROMAFE_SETTINGS_MAJOR_VERSION,
PLEROMAFE_SETTINGS_MINOR_VERSION
PLEROMAFE_SETTINGS_MINOR_VERSION,
]
return clone
},
resetAdminDraft () {
resetAdminDraft() {
this.$store.commit('resetAdminDraft')
},
pushAdminDraft () {
pushAdminDraft() {
this.$store.dispatch('pushAdminDraft')
},
...mapActions(useInterfaceStore, ['temporaryChangesRevert', 'temporaryChangesConfirm'])
...mapActions(useInterfaceStore, [
'temporaryChangesRevert',
'temporaryChangesConfirm',
]),
},
computed: {
...mapState(useInterfaceStore, {
temporaryChangesTimeoutId: store => store.temporaryChangesTimeoutId,
currentSaveStateNotice: store => store.settings.currentSaveStateNotice,
modalActivated: store => store.settingsModalState !== 'hidden',
modalMode: store => store.settingsModalMode,
modalOpenedOnceUser: store => store.settingsModalLoadedUser,
modalOpenedOnceAdmin: store => store.settingsModalLoadedAdmin,
modalPeeked: store => store.settingsModalState === 'minimized'
temporaryChangesCountdown: (store) => store.temporaryChangesCountdown,
currentSaveStateNotice: (store) => store.settings.currentSaveStateNotice,
modalActivated: (store) => store.settingsModalState !== 'hidden',
modalMode: (store) => store.settingsModalMode,
modalOpenedOnceUser: (store) => store.settingsModalLoadedUser,
modalOpenedOnceAdmin: (store) => store.settingsModalLoadedAdmin,
modalPeeked: (store) => store.settingsModalState === 'minimized',
}),
expertLevel: {
get () {
return this.$store.state.config.expertLevel > 0
get() {
return useMergedConfigStore().mergedConfig.expertLevel > 0
},
set(value) {
useSyncConfigStore().setSimplePrefAndSave({
path: 'expertLevel',
value: value ? 1 : 0,
})
},
set (value) {
this.$store.dispatch('setOption', { name: 'expertLevel', value: value ? 1 : 0 })
}
},
adminDraftAny () {
adminDraftAny() {
return !isEqual(
this.$store.state.adminSettings.config,
this.$store.state.adminSettings.draft
this.$store.state.adminSettings.draft,
)
}
}
},
},
}
export default SettingsModal

View file

@ -1,48 +1,179 @@
.settings-modal {
overflow: hidden;
h4 {
h2 {
font-size: 1.3rem;
font-weight: 500;
margin-top: 1em;
margin-bottom: 1em;
margin-right: 1em;
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-top: 1em;
margin-bottom: 0.5em;
margin-right: 1em;
border-bottom: 1px solid var(--border);
padding-bottom: 0.25em;
box-sizing: border-box;
padding-left: 0.5em;
}
h4 {
font-size: 1.1rem;
margin-top: 1em;
margin-right: 1em;
margin-bottom: 0.5em;
margin-left: 1em;
}
h5 {
font-size: 1rem;
margin-left: 1em;
margin-bottom: 0.25em;
margin-top: 0.75em;
}
p {
line-height: 1.5;
margin-left: 2em;
}
.sidenote {
margin-left: 5em;
padding: 0.25em 1em;
margin-top: 0.25em;
}
.setting-description {
margin-top: 0.2em;
margin-bottom: 0;
margin-left: 0;
font-size: 80%;
}
.setting-item {
display: grid;
grid-template-areas:
"label control"
"label desc"
". draft";
grid-template-columns: 4fr 5fr;
column-gap: 0.5em;
align-items: baseline;
padding: 0.5em 0;
line-height: 1.5em;
.setting-label {
grid-area: label;
text-align: right;
}
.ModifiedIndicator,
.LocalSettingIndicator {
grid-area: indicator;
}
.setting-control {
grid-area: control;
&.textarea {
align-self: baseline;
overflow: auto;
}
&.checkbox {
display: grid;
grid-template-columns: subgrid;
.label {
grid-area: label;
text-align: right;
}
.checkbox-indicator {
grid-area: control;
height: 1.5em;
line-height: 1.5em;
align-self: baseline;
}
.-mobile & {
.label {
text-align: left;
}
}
}
}
.setting-control.setting-label {
grid-column: 1 / 3;
text-align: left;
}
.setting-description {
grid-area: desc;
}
.DraftButtons {
grid-area: draft;
}
}
.vertical-tab-switcher {
height: 100%;
}
.setting-list,
.option-list {
list-style-type: none;
padding-left: 2em;
padding-left: 0;
margin: 0;
&.suboptions {
margin-left: 2em;
border-top: 1px dotted var(--border);
border-bottom: 1px dotted var(--border);
}
.btn:not(.dropdown-button) {
padding: 0 2em;
}
li {
margin-bottom: 0.5em;
}
.suboptions {
margin-top: 0.3em;
.btn-group {
.button-default {
flex: 0 1 auto;
}
}
&.two-column {
column-count: 2;
display: grid;
grid-template-columns: 1fr 1fr;
margin-left: 2em;
border-bottom: 1px solid var(--border);
padding-bottom: 0.5em;
margin-bottom: 1em;
.setting-item {
grid-template-columns: 3fr 1fr;
}
> li {
margin: 0;
break-inside: avoid;
}
}
}
.setting-description {
margin-top: 0.2em;
margin-bottom: 2em;
font-size: 70%;
}
.settings-modal-panel {
overflow: hidden;
transition: transform;
transition-timing-function: ease-in-out;
transition-duration: 300ms;
width: 1000px;
width: 70em;
max-width: 90vw;
height: 90vh;
@ -77,20 +208,64 @@
}
&.-mobile {
.setting-list,
.tabs {
.menu-item {
font-size: 1.2em;
padding-top: 0.75em;
padding-bottom: 0.75em;
}
}
li {
.sidenote {
margin-left: 1em;
}
}
/* stylelint-disable no-descending-specificity */
.setting-item {
grid-template-columns: 1fr min-content;
column-gap: 0.5em;
padding: 1em;
align-items: center;
.setting-label {
text-align: left;
}
.checkbox {
.label {
text-align: left;
order: 2;
}
.checkbox-indicator {
order: 1;
}
}
}
ul {
padding: 0;
li:not(:first-child) {
.setting-item {
border-top: 1px solid var(--border);
}
}
}
.setting-list:not(.suboptions),
.option-list {
padding-left: 0.25em;
> li {
margin: 1em 0;
line-height: 1.5em;
vertical-align: middle;
}
&.two-column {
column-count: 1;
grid-template-columns: 1fr;
}
}
.UnitSetting {
padding-right: 0.5em;
}
}
&.peek {

View file

@ -158,14 +158,14 @@
</div>
<teleport to="#modal">
<ConfirmModal
v-if="temporaryChangesTimeoutId"
v-if="temporaryChangesCountdown > 0"
:title="$t('settings.confirm_new_setting')"
:cancel-text="$t('settings.revert')"
:confirm-text="$t('settings.confirm')"
@cancelled="temporaryChangesRevert"
@accepted="temporaryChangesConfirm"
>
{{ $t('settings.confirm_new_question') }}
{{ $t('settings.confirm_new_question_countdown', temporaryChangesCountdown) }}
</ConfirmModal>
</teleport>
</Modal>

View file

@ -1,83 +1,138 @@
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import InstanceTab from './admin_tabs/instance_tab.vue'
import UsersTab from './admin_tabs/users_tab.vue'
import LimitsTab from './admin_tabs/limits_tab.vue'
import FrontendsTab from './admin_tabs/frontends_tab.vue'
import AuthTab from './admin_tabs/auth_tab.vue'
import EmojiTab from './admin_tabs/emoji_tab.vue'
import { useInterfaceStore } from 'src/stores/interface'
import FederationTab from './admin_tabs/federation_tab.vue'
import FrontendsTab from './admin_tabs/frontends_tab.vue'
import HTTPTab from './admin_tabs/http_tab.vue'
import InstanceTab from './admin_tabs/instance_tab.vue'
import JobQueuesTab from './admin_tabs/job_queues_tab.vue'
import LimitsTab from './admin_tabs/limits_tab.vue'
import LinksTab from './admin_tabs/links_tab.vue'
import MailerTab from './admin_tabs/mailer_tab.vue'
import MediaProxyTab from './admin_tabs/media_proxy_tab.vue'
import MonitoringTab from './admin_tabs/monitoring_tab.vue'
import OtherTab from './admin_tabs/other_tab.vue'
import PostsTab from './admin_tabs/posts_tab.vue'
import RatesTab from './admin_tabs/rates_tab.vue'
import RegistrationsTab from './admin_tabs/registrations_tab.vue'
import UploadsTab from './admin_tabs/uploads_tab.vue'
import UsersTab from './admin_tabs/users_tab.vue'
import VerticalTabSwitcher from './helpers/vertical_tab_switcher.jsx'
import { useInterfaceStore } from 'src/stores/interface.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faWrench,
faHand,
faLaptopCode,
faPaintBrush,
faBell,
faChain,
faChartLine,
faCircleNodes,
faDoorOpen,
faDownload,
faEllipsis,
faEnvelope,
faEyeSlash,
faGauge,
faGears,
faGlobe,
faHand,
faInfo,
faUser
faKey,
faLaptopCode,
faMessage,
faPaintBrush,
faTowerBroadcast,
faUpload,
faUser,
faWrench,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faWrench,
faHand,
faChain,
faGlobe,
faLaptopCode,
faPaintBrush,
faBell,
faDownload,
faEyeSlash,
faInfo,
faUser
faUser,
faTowerBroadcast,
faEnvelope,
faChartLine,
faDoorOpen,
faGears,
faKey,
faCircleNodes,
faUpload,
faMessage,
faEllipsis,
faGauge,
)
const SettingsModalAdminContent = {
components: {
TabSwitcher,
VerticalTabSwitcher,
InstanceTab,
UsersTab,
LimitsTab,
RegistrationsTab,
EmojiTab,
FrontendsTab,
EmojiTab
FederationTab,
MailerTab,
UploadsTab,
MediaProxyTab,
LinksTab,
JobQueuesTab,
AuthTab,
HTTPTab,
MonitoringTab,
RatesTab,
OtherTab,
PostsTab,
},
computed: {
user () {
user() {
return this.$store.state.users.currentUser
},
isLoggedIn () {
isLoggedIn() {
return !!this.$store.state.users.currentUser
},
open () {
open() {
return useInterfaceStore().settingsModalState !== 'hidden'
},
bodyLock () {
bodyLock() {
return useInterfaceStore().settingsModalState === 'visible'
},
adminDbLoaded () {
adminDbLoaded() {
return this.$store.state.adminSettings.loaded
},
adminDescriptionsLoaded () {
adminDescriptionsLoaded() {
return this.$store.state.adminSettings.descriptions !== null
},
noDb () {
noDb() {
return this.$store.state.adminSettings.dbConfigEnabled === false
}
},
},
created () {
created() {
if (this.user.rights.admin) {
this.$store.dispatch('loadAdminStuff')
}
},
methods: {
onOpen () {
onOpen() {
const targetTab = useInterfaceStore().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
})
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)
}
@ -85,16 +140,16 @@ const SettingsModalAdminContent = {
// Clear the state of target tab, so that next time settings is opened
// it doesn't force it.
useInterfaceStore().clearSettingsModalTargetTab()
}
},
},
mounted () {
mounted() {
this.onOpen()
},
watch: {
open: function (value) {
if (value) this.onOpen()
}
}
},
},
}
export default SettingsModalAdminContent

View file

@ -1,48 +0,0 @@
.settings_tab-switcher {
height: 100%;
.setting-item {
border-bottom: 2px solid var(--border);
margin: 1em 1em 1.4em;
padding-bottom: 1.4em;
> div,
> label {
display: block;
margin-bottom: 0.5em;
&:last-child {
margin-bottom: 0;
}
}
.select-multiple {
margin-top: 0.5em;
display: flex;
flex-direction: column;
.option-list {
margin: 0;
margin-top: 0.5em;
padding-left: 0.5em;
}
}
&:last-child {
border-bottom: none;
padding-bottom: 0;
margin-bottom: 1em;
}
textarea {
width: 100%;
max-width: 100%;
height: 100px;
}
.unavailable,
.unavailable svg {
color: var(--cRed);
}
}
}

View file

@ -1,8 +1,8 @@
<template>
<tab-switcher
<vertical-tab-switcher
v-if="adminDescriptionsLoaded && (noDb || adminDbLoaded)"
ref="tabSwitcher"
class="settings_tab-switcher"
class="settings-admin-content settings_tab-switcher"
:side-tab-bar="true"
:scrollable-tabs="true"
:render-only-focused="true"
@ -15,7 +15,7 @@
data-tab-name="nodb-notice"
>
<div :label="$t('admin_dash.tabs.nodb')">
<div class="setting-item">
<div class="setting-section">
<h2>{{ $t('admin_dash.nodb.heading') }}</h2>
<i18n-t
scope="global"
@ -48,6 +48,41 @@
>
<InstanceTab />
</div>
<div
:label="$t('admin_dash.tabs.registrations')"
icon="door-open"
data-tab-name="registrations"
>
<RegistrationsTab />
</div>
<div
:label="$t('admin_dash.tabs.auth')"
icon="key"
data-tab-name="monitoring"
>
<AuthTab />
</div>
<div
:label="$t('admin_dash.tabs.emoji')"
icon="face-smile-beam"
data-tab-name="emoji"
full-width
>
<EmojiTab />
</div>
<div
:label="$t('admin_dash.tabs.frontends')"
icon="laptop-code"
data-tab-name="frontends"
full-width
>
<FrontendsTab />
</div>
<div
v-if="adminDbLoaded"
:label="$t('admin_dash.tabs.users')"
@ -64,24 +99,104 @@
>
<LimitsTab />
</div>
<div
:label="$t('admin_dash.tabs.frontends')"
icon="laptop-code"
data-tab-name="frontends"
v-if="adminDbLoaded"
:label="$t('admin_dash.tabs.rate_limit')"
icon="gauge"
data-tab-name="rate_limits"
>
<FrontendsTab />
<RatesTab />
</div>
<div
:label="$t('admin_dash.tabs.emoji')"
icon="face-smile-beam"
data-tab-name="emoji"
:label="$t('admin_dash.tabs.uploads')"
icon="upload"
data-tab-name="uploads"
>
<EmojiTab />
<UploadsTab />
</div>
</tab-switcher>
<div
:label="$t('admin_dash.tabs.media_proxy')"
icon="tower-broadcast"
data-tab-name="media_proxy"
>
<MediaProxyTab />
</div>
<div
:label="$t('admin_dash.tabs.posts')"
icon="message"
data-tab-name="other"
>
<PostsTab />
</div>
<div
:label="$t('admin_dash.tabs.links')"
icon="chain"
data-tab-name="links"
>
<LinksTab />
</div>
<div
:label="$t('admin_dash.tabs.mailer')"
icon="envelope"
data-tab-name="mailer"
>
<MailerTab />
</div>
<div
:label="$t('admin_dash.tabs.federation')"
icon="circle-nodes"
data-tab-name="monitoring"
>
<FederationTab />
</div>
<div
:label="$t('admin_dash.tabs.http')"
icon="globe"
data-tab-name="http"
>
<HTTPTab />
</div>
<div
:label="$t('admin_dash.tabs.job_queues')"
icon="gears"
data-tab-name="job_queues"
>
<JobQueuesTab />
</div>
<div
:label="$t('admin_dash.tabs.monitoring')"
icon="chart-line"
data-tab-name="monitoring"
>
<MonitoringTab />
</div>
<div
:label="$t('admin_dash.tabs.other')"
icon="ellipsis"
data-tab-name="other"
>
<OtherTab />
</div>
</vertical-tab-switcher>
</template>
<script src="./settings_modal_admin_content.js"></script>
<style src="./settings_modal_admin_content.scss" lang="scss"></style>
<style lang="scss">
.settings-admin-content {
.setting-item {
grid-template-columns: 1fr 3fr;
}
}
</style>

View file

@ -1,48 +1,61 @@
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import VerticalTabSwitcher from './helpers/vertical_tab_switcher.jsx'
import AppearanceTab from './tabs/appearance_tab.vue'
import ClutterTab from './tabs/clutter_tab.vue'
import ComposingTab from './tabs/composing_tab.vue'
import DataImportExportTab from './tabs/data_import_export_tab.vue'
import DeveloperTab from './tabs/developer_tab.vue'
import FilteringTab from './tabs/filtering_tab.vue'
import GeneralTab from './tabs/general_tab.vue'
import LayoutTab from './tabs/layout_tab.vue'
import MutesAndBlocksTab from './tabs/mutes_and_blocks_tab.vue'
import NotificationsTab from './tabs/notifications_tab.vue'
import FilteringTab from './tabs/filtering_tab.vue'
import SecurityTab from './tabs/security_tab/security_tab.vue'
import OldThemeTab from './tabs/old_theme_tab/old_theme_tab.vue'
import PostsTab from './tabs/posts_tab.vue'
import ProfileTab from './tabs/profile_tab.vue'
import GeneralTab from './tabs/general_tab.vue'
import AppearanceTab from './tabs/appearance_tab.vue'
import VersionTab from './tabs/version_tab.vue'
import ThemeTab from './tabs/theme_tab/theme_tab.vue'
import SecurityTab from './tabs/security_tab/security_tab.vue'
import StyleTab from './tabs/style_tab/style_tab.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faWrench,
faUser,
faFilter,
faPaintBrush,
faPalette,
faBell,
faBroom,
faCode,
faColumns,
faDownload,
faEyeSlash,
faInfo,
faWindowRestore
faFilter,
faLock,
faMessage,
faPaintBrush,
faPalette,
faUser,
faWindowRestore,
faWrench,
} from '@fortawesome/free-solid-svg-icons'
import { useInterfaceStore } from 'src/stores/interface'
library.add(
faWrench,
faUser,
faFilter,
faPaintBrush,
faPalette,
faMessage,
faWindowRestore,
faColumns,
faBell,
faDownload,
faFilter,
faEyeSlash,
faInfo,
faWindowRestore
faBroom,
faLock,
faDownload,
faPalette,
faPaintBrush,
faCode,
)
const SettingsModalContent = {
components: {
TabSwitcher,
VerticalTabSwitcher,
DataImportExportTab,
MutesAndBlocksTab,
@ -51,36 +64,45 @@ const SettingsModalContent = {
SecurityTab,
ProfileTab,
GeneralTab,
PostsTab,
ComposingTab,
ClutterTab,
LayoutTab,
AppearanceTab,
StyleTab,
VersionTab,
ThemeTab
DeveloperTab,
OldThemeTab,
},
computed: {
isLoggedIn () {
isLoggedIn() {
return !!this.$store.state.users.currentUser
},
open () {
open() {
return useInterfaceStore().settingsModalState !== 'hidden'
},
bodyLock () {
bodyLock() {
return useInterfaceStore().settingsModalState === 'visible'
},
expertLevel () {
return this.$store.state.config.expertLevel
expertLevel() {
return useMergedConfigStore().mergedConfig.expertLevel
},
isMobileLayout () {
return useInterfaceStore().layoutType === 'mobile'
},
data() {
return {
navCollapsed: false,
navHideHeader: false,
}
},
methods: {
onOpen () {
onOpen() {
const targetTab = useInterfaceStore().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
})
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)
}
@ -88,16 +110,16 @@ const SettingsModalContent = {
// Clear the state of target tab, so that next time settings is opened
// it doesn't force it.
useInterfaceStore().clearSettingsModalTargetTab()
}
},
},
mounted () {
mounted() {
this.onOpen()
},
watch: {
open: function (value) {
if (value) this.onOpen()
}
}
},
},
}
export default SettingsModalContent

View file

@ -1,62 +0,0 @@
.settings_tab-switcher {
height: 100%;
h1 {
margin-bottom: 0.5em;
margin-top: 0.5em;
}
h4 {
margin-bottom: 0;
margin-top: 0.25em;
}
h5 {
margin-bottom: 0;
margin-top: 0.25em;
}
.setting-item {
border-bottom: 2px solid var(--border);
margin: 1em 1em 1.4em;
padding-bottom: 1.4em;
> div,
> label {
margin-bottom: 0.5em;
&:last-child {
margin-bottom: 0;
}
}
.select-multiple {
margin-top: 1em;
display: flex;
flex-direction: column;
.option-list {
margin: 0;
margin-top: 0.5em;
padding-left: 0.5em;
}
}
&:last-child {
border-bottom: none;
padding-bottom: 0;
margin-bottom: 1em;
}
textarea {
width: 100%;
max-width: 100%;
height: 100px;
}
.unavailable,
.unavailable svg {
color: var(--cRed);
}
}
}

View file

@ -1,10 +1,10 @@
<template>
<tab-switcher
<vertical-tab-switcher
ref="tabSwitcher"
class="settings_tab-switcher"
:side-tab-bar="true"
:scrollable-tabs="true"
:body-scroll-lock="bodyLock"
:hide-header="navHideHeader"
>
<div
:label="$t('settings.general')"
@ -14,6 +14,33 @@
<GeneralTab />
</div>
<div
v-if="isLoggedIn"
:label="$t('settings.profile_tab')"
icon="user"
data-tab-name="profile"
:full-width="true"
>
<ProfileTab />
</div>
<div
v-if="isLoggedIn"
:label="$t('settings.composing')"
icon="pen-alt"
data-tab-name="composing"
:delay-render="true"
>
<ComposingTab />
</div>
<div
:label="$t('settings.posts')"
icon="message"
data-tab-name="posts"
:delay-render="true"
>
<PostsTab />
</div>
<div
:full-width="true"
:label="$t('settings.appearance')"
icon="window-restore"
data-tab-name="appearance"
@ -22,47 +49,22 @@
<AppearanceTab />
</div>
<div
v-if="expertLevel > 0"
:label="$t('settings.style.themes3.editor.title')"
icon="palette"
data-tab-name="style"
:label="$t('settings.layout')"
icon="table-columns"
data-tab-name="layout"
:delay-render="true"
>
<StyleTab />
</div>
<div
v-if="expertLevel > 0"
:label="$t('settings.theme_old')"
icon="paint-brush"
data-tab-name="theme"
:delay-render="true"
>
<ThemeTab />
</div>
<div
v-if="isLoggedIn"
:label="$t('settings.profile_tab')"
icon="user"
data-tab-name="profile"
>
<ProfileTab />
<LayoutTab />
</div>
<div
v-if="isLoggedIn"
:full-width="true"
:label="$t('settings.notifications')"
icon="bell"
data-tab-name="notifications"
>
<NotificationsTab />
</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"
@ -73,12 +75,28 @@
<div
v-if="isLoggedIn"
:label="$t('settings.mutes_and_blocks')"
:fullHeight="true"
icon="eye-slash"
data-tab-name="mutesAndBlocks"
:full-width="true"
:full-height="true"
>
<MutesAndBlocksTab />
</div>
<div
:label="$t('settings.clutter')"
icon="broom"
data-tab-name="clutter"
>
<ClutterTab />
</div>
<div
v-if="isLoggedIn"
:label="$t('settings.security_tab')"
icon="lock"
data-tab-name="security"
>
<SecurityTab />
</div>
<div
v-if="isLoggedIn"
:label="$t('settings.data_import_export_tab')"
@ -88,15 +106,34 @@
<DataImportExportTab />
</div>
<div
:label="$t('settings.version.title')"
icon="info"
data-tab-name="version"
v-if="expertLevel > 0"
:label="$t('settings.style.themes3.editor.title')"
icon="palette"
data-tab-name="style"
:delay-render="true"
:full-width="true"
>
<VersionTab />
<StyleTab />
</div>
</tab-switcher>
<div
v-if="expertLevel > 0"
:label="$t('settings.theme_old')"
icon="paint-brush"
data-tab-name="theme"
:delay-render="true"
:full-width="true"
>
<OldThemeTab />
</div>
<div
v-if="expertLevel > 0"
:label="$t('settings.developer')"
icon="code"
data-tab-name="developer"
>
<DeveloperTab />
</div>
</vertical-tab-switcher>
</template>
<script src="./settings_modal_user_content.js"></script>
<style src="./settings_modal_user_content.scss" lang="scss"></style>

View file

@ -1,40 +1,30 @@
import { mapActions } from 'pinia'
import fileSizeFormatService from 'src/components/../services/file_size_format/file_size_format.js'
import PaletteEditor from 'src/components/palette_editor/palette_editor.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import FloatSetting from '../helpers/float_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
import { defaultHorizontalUnits } from '../helpers/unit_setting.js'
import PaletteEditor from 'src/components/palette_editor/palette_editor.vue'
import Preview from './theme_tab/theme_preview.vue'
import FontControl from 'src/components/font_control/font_control.vue'
import Preview from './old_theme_tab/theme_preview.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { normalizeThemeData, useInterfaceStore } from 'src/stores/interface.js'
import { newImporter } from 'src/services/export_import/export_import.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
import { init } from 'src/services/theme_data/theme_data_3.service.js'
import {
getCssRules
} from 'src/services/theme_data/css_utils.js'
adoptStyleSheets,
createStyleSheet,
} from 'src/services/style_setter/style_setter.js'
import { getCssRules } from 'src/services/theme_data/css_utils.js'
import { deserialize } from 'src/services/theme_data/iss_deserializer.js'
import { createStyleSheet, adoptStyleSheets } from 'src/services/style_setter/style_setter.js'
import fileSizeFormatService from 'src/components/../services/file_size_format/file_size_format.js'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
import { mapActions } from 'pinia'
import { useInterfaceStore, normalizeThemeData } from 'src/stores/interface'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faGlobe
} from '@fortawesome/free-solid-svg-icons'
library.add(
faGlobe
)
import { init } from 'src/services/theme_data/theme_data_3.service.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
const AppearanceTab = {
data () {
data() {
return {
availableThemesV3: [],
availableThemesV2: [],
@ -45,7 +35,7 @@ const AppearanceTab = {
validator: this.importValidator,
onImport: this.onImport,
parser: this.importParser,
onImportFailure: this.onImportFailure
onImportFailure: this.onImportFailure,
}),
palettesKeys: [
'bg',
@ -55,27 +45,29 @@ const AppearanceTab = {
'cRed',
'cGreen',
'cBlue',
'cOrange'
'cOrange',
],
userPalette: {},
intersectionObserver: null,
thirdColumnModeOptions: ['none', 'notifications', 'postform'].map(mode => ({
key: mode,
value: mode,
label: this.$t(`settings.third_column_mode_${mode}`)
})),
forcedRoundnessOptions: ['disabled', 'sharp', 'nonsharp', 'round'].map((mode, i) => ({
key: mode,
value: i - 1,
label: this.$t(`settings.style.themes3.hacks.forced_roundness_mode_${mode}`)
})),
forcedRoundnessOptions: ['disabled', 'sharp', 'nonsharp', 'round'].map(
(mode, i) => ({
key: mode,
value: i - 1,
label: this.$t(
`settings.style.themes3.hacks.forced_roundness_mode_${mode}`,
),
}),
),
underlayOverrideModes: ['none', 'opaque', 'transparent'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.style.themes3.hacks.underlay_override_mode_${mode}`)
label: this.$t(
`settings.style.themes3.hacks.underlay_override_mode_${mode}`,
),
})),
backgroundUploading: false,
background: null,
backgroundError: null,
backgroundPreview: null,
}
},
@ -85,17 +77,15 @@ const AppearanceTab = {
IntegerSetting,
FloatSetting,
UnitSetting,
ProfileSettingIndicator,
FontControl,
Preview,
PaletteEditor
PaletteEditor,
},
mounted () {
mounted() {
useInterfaceStore().getThemeData()
const updateIndex = (resource) => {
const capitalizedResource = resource[0].toUpperCase() + resource.slice(1)
const currentIndex = this.$store.state.instance[`${resource}sIndex`]
const currentIndex = useInstanceStore()[`${resource}sIndex`]
let promise
if (currentIndex) {
@ -104,120 +94,151 @@ const AppearanceTab = {
promise = useInterfaceStore()[`fetch${capitalizedResource}sIndex`]()
}
return promise.then(index => {
return Object
.entries(index)
.map(([k, func]) => [k, func()])
return promise.then((index) => {
return Object.entries(index).map(([k, func]) => [k, func()])
})
}
updateIndex('style').then(styles => {
styles.forEach(([key, stylePromise]) => stylePromise.then(data => {
const meta = data.find(x => x.component === '@meta')
this.availableThemesV3.push({ key, data, name: meta.directives.name, version: 'v3' })
}))
updateIndex('style').then((styles) => {
styles.forEach(([key, stylePromise]) =>
stylePromise.then((data) => {
const meta = data.find((x) => x.component === '@meta')
this.availableThemesV3.push({
key,
data,
name: meta.directives.name,
version: 'v3',
})
}),
)
})
updateIndex('theme').then(themes => {
themes.forEach(([key, themePromise]) => themePromise.then(data => {
if (!data) {
console.warn(`Theme with key ${key} is empty or malformed`)
} else if (Array.isArray(data)) {
console.warn(`Theme with key ${key} is a v1 theme and should be moved to static/palettes/index.json`)
} else if (!data.source && !data.theme) {
console.warn(`Theme with key ${key} is malformed`)
} else {
this.availableThemesV2.push({ key, data, name: data.name, version: 'v2' })
}
}))
updateIndex('theme').then((themes) => {
themes.forEach(([key, themePromise]) =>
themePromise.then((data) => {
if (!data) {
console.warn(`Theme with key ${key} is empty or malformed`)
} else if (Array.isArray(data)) {
console.warn(
`Theme with key ${key} is a v1 theme and should be moved to static/palettes/index.json`,
)
} else if (!data.source && !data.theme) {
console.warn(`Theme with key ${key} is malformed`)
} else {
this.availableThemesV2.push({
key,
data,
name: data.name,
version: 'v2',
})
}
}),
)
})
this.userPalette = useInterfaceStore().paletteDataUsed || {}
updateIndex('palette').then(bundledPalettes => {
bundledPalettes.forEach(([key, palettePromise]) => palettePromise.then(v => {
let palette
if (Array.isArray(v)) {
const [
name,
bg,
fg,
text,
link,
cRed = '#FF0000',
cGreen = '#00FF00',
cBlue = '#0000FF',
cOrange = '#E3FF00'
] = v
palette = { key, name, bg, fg, text, link, cRed, cBlue, cGreen, cOrange }
} else {
palette = { key, ...v }
}
if (!palette.key.startsWith('style.')) {
this.bundledPalettes.push(palette)
}
}))
updateIndex('palette').then((bundledPalettes) => {
bundledPalettes.forEach(([key, palettePromise]) =>
palettePromise.then((v) => {
let palette
if (Array.isArray(v)) {
const [
name,
bg,
fg,
text,
link,
cRed = '#FF0000',
cGreen = '#00FF00',
cBlue = '#0000FF',
cOrange = '#E3FF00',
] = v
palette = {
key,
name,
bg,
fg,
text,
link,
cRed,
cBlue,
cGreen,
cOrange,
}
} else {
palette = { key, ...v }
}
if (!palette.key.startsWith('style.')) {
this.bundledPalettes.push(palette)
}
}),
)
})
this.previewTheme('stock', 'v3')
if (window.IntersectionObserver) {
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(({ target, isIntersecting }) => {
if (!isIntersecting) return
const theme = this.availableStyles.find(x => x.key === target.dataset.themeKey)
this.$nextTick(() => {
if (theme) this.previewTheme(theme.key, theme.version, theme.data)
this.intersectionObserver = new IntersectionObserver(
(entries, observer) => {
entries.forEach(({ target, isIntersecting }) => {
if (!isIntersecting) return
const theme = this.availableStyles.find(
(x) => x.key === target.dataset.themeKey,
)
this.$nextTick(() => {
if (theme) this.previewTheme(theme.key, theme.version, theme.data)
})
observer.unobserve(target)
})
observer.unobserve(target)
})
}, {
root: this.$refs.themeList
})
},
{
root: this.$refs.themeList,
},
)
} else {
this.availableStyles.forEach(theme => this.previewTheme(theme.key, theme.version, theme.data))
this.availableStyles.forEach((theme) =>
this.previewTheme(theme.key, theme.version, theme.data),
)
}
},
updated () {
updated() {
this.$nextTick(() => {
this.$refs.themeList.querySelectorAll('.theme-preview').forEach(node => {
this.intersectionObserver.observe(node)
})
this.$refs.themeList
.querySelectorAll('.theme-preview')
.forEach((node) => {
this.intersectionObserver.observe(node)
})
})
},
watch: {
paletteDataUsed () {
paletteDataUsed() {
this.userPalette = this.paletteDataUsed || {}
}
},
},
computed: {
isDefaultBackground () {
return !(this.$store.state.users.currentUser.background_image)
isDefaultBackground() {
return !this.$store.state.users.currentUser.background_image
},
switchInProgress () {
switchInProgress() {
return useInterfaceStore().themeChangeInProgress
},
paletteDataUsed () {
paletteDataUsed() {
return useInterfaceStore().paletteDataUsed
},
availableStyles () {
return [
...this.availableThemesV3,
...this.availableThemesV2
]
availableStyles() {
return [...this.availableThemesV3, ...this.availableThemesV2]
},
availablePalettes () {
return [
...this.bundledPalettes,
...this.stylePalettes
]
availablePalettes() {
return [...this.bundledPalettes, ...this.stylePalettes]
},
stylePalettes () {
stylePalettes() {
const ruleset = useInterfaceStore().styleDataUsed || []
if (!ruleset && ruleset.length === 0) return
const meta = ruleset.find(x => x.component === '@meta')
const result = ruleset.filter(x => x.component.startsWith('@palette'))
.map(x => {
const meta = ruleset.find((x) => x.component === '@meta')
const result = ruleset
.filter((x) => x.component.startsWith('@palette'))
.map((x) => {
const { variant, directives } = x
const {
bg,
@ -229,7 +250,7 @@ const AppearanceTab = {
cBlue,
cGreen,
cOrange,
wallpaper
wallpaper,
} = directives
const result = {
@ -244,126 +265,106 @@ const AppearanceTab = {
cBlue,
cGreen,
cOrange,
wallpaper
wallpaper,
}
return Object.fromEntries(Object.entries(result).filter(([, v]) => v))
})
return result
},
noIntersectionObserver () {
noIntersectionObserver() {
return !window.IntersectionObserver
},
horizontalUnits () {
return defaultHorizontalUnits
instanceWallpaper() {
useInstanceStore().instanceIdentity.background
},
fontsOverride () {
return this.$store.getters.mergedConfig.fontsOverride
},
columns () {
const mode = this.$store.getters.mergedConfig.thirdColumnMode
const notif = mode === 'none' ? [] : ['notifs']
if (this.$store.getters.mergedConfig.sidebarRight || mode === 'postform') {
return [...notif, 'content', 'sidebar']
} else {
return ['sidebar', 'content', ...notif]
}
},
instanceWallpaperUsed () {
return this.$store.state.instance.background &&
instanceWallpaperUsed() {
return (
useInstanceStore().instanceIdentity.background &&
!this.$store.state.users.currentUser.background_image
)
},
language: {
get: function () { return this.$store.getters.mergedConfig.interfaceLanguage },
set: function (val) {
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
}
},
customThemeVersion () {
customThemeVersion() {
const { themeVersion } = useInterfaceStore()
return themeVersion
},
isCustomThemeUsed () {
isCustomThemeUsed() {
const { customTheme, customThemeSource } = this.mergedConfig
return customTheme != null || customThemeSource != null
},
isCustomStyleUsed () {
isCustomStyleUsed() {
const { styleCustomData } = this.mergedConfig
return styleCustomData != null
},
...SharedComputedObject()
...SharedComputedObject(),
},
methods: {
updateFont (key, value) {
this.$store.dispatch('setOption', {
name: 'theme3hacks',
value: {
...this.mergedConfig.theme3hacks,
fonts: {
...this.mergedConfig.theme3hacks.fonts,
[key]: value
}
}
})
},
importFile () {
importFile() {
this.fileImporter.importData()
},
importParser (file, filename) {
importParser(file, filename) {
if (filename.endsWith('.json')) {
return JSON.parse(file)
} else if (filename.endsWith('.iss')) {
return deserialize(file)
}
},
onImport (parsed, filename) {
onImport(parsed, filename) {
if (filename.endsWith('.json')) {
useInterfaceStore().setThemeCustom(parsed.source || parsed.theme)
} else if (filename.endsWith('.iss')) {
useInterfaceStore().setStyleCustom(parsed)
}
},
onImportFailure (result) {
onImportFailure(result) {
console.error('Failure importing theme:', result)
useInterfaceStore().pushGlobalNotice({ messageKey: 'settings.invalid_theme_imported', level: 'error' })
useInterfaceStore().pushGlobalNotice({
messageKey: 'settings.invalid_theme_imported',
level: 'error',
})
},
importValidator (parsed, filename) {
importValidator(parsed, filename) {
if (filename.endsWith('.json')) {
const version = parsed._pleroma_theme_version
return version >= 1 || version <= 2
} else if (filename.endsWith('.iss')) {
if (!Array.isArray(parsed)) return false
if (parsed.length < 1) return false
if (parsed.find(x => x.component === '@meta') == null) return false
if (parsed.find((x) => x.component === '@meta') == null) return false
return true
}
},
isThemeActive (key) {
return key === (this.mergedConfig.theme || this.$store.state.instance.theme)
isThemeActive(key) {
return (
key ===
(this.mergedConfig.theme || useInstanceStore().instanceIdentity.theme)
)
},
isStyleActive (key) {
return key === (this.mergedConfig.style || this.$store.state.instance.style)
isStyleActive(key) {
return (
key ===
(this.mergedConfig.style || useInstanceStore().instanceIdentity.style)
)
},
isPaletteActive (key) {
return key === (this.mergedConfig.palette || this.$store.state.instance.palette)
isPaletteActive(key) {
return (
key ===
(this.mergedConfig.palette ||
useInstanceStore().instanceIdentity.palette)
)
},
...mapActions(useInterfaceStore, [
'setStyle',
'setTheme'
]),
setPalette (name, data) {
...mapActions(useInterfaceStore, ['setStyle', 'setTheme']),
setPalette(name, data) {
useInterfaceStore().setPalette(name)
this.userPalette = data
},
setPaletteCustom (data) {
setPaletteCustom(data) {
useInterfaceStore().setPaletteCustom(data)
this.userPalette = data
},
resetTheming () {
resetTheming() {
useInterfaceStore().setStyle('stock')
},
previewTheme (key, version, input) {
previewTheme(key, version, input) {
let theme3
if (this.compilationCache[key]) {
theme3 = this.compilationCache[key]
@ -376,10 +377,10 @@ const AppearanceTab = {
ultimateBackgroundColor: '#000000',
liteMode: true,
debug: true,
onlyNormalState: true
onlyNormalState: true,
})
} else if (version === 'v3') {
const palette = input.find(x => x.component === '@palette')
const palette = input.find((x) => x.component === '@palette')
let paletteRule
if (palette) {
const { directives } = palette
@ -388,21 +389,20 @@ const AppearanceTab = {
paletteRule = {
component: 'Root',
directives: Object.fromEntries(
Object
.entries(directives)
Object.entries(directives)
.filter(([k]) => k && k !== 'name')
.map(([k, v]) => ['--' + k, 'color | ' + v])
)
.map(([k, v]) => ['--' + k, 'color | ' + v]),
),
}
} else {
paletteRule = null
}
theme3 = init({
inputRuleset: [...input, paletteRule].filter(x => x),
inputRuleset: [...input, paletteRule].filter((x) => x),
ultimateBackgroundColor: '#000000',
liteMode: true,
onlyNormalState: true
onlyNormalState: true,
})
}
} else {
@ -410,7 +410,7 @@ const AppearanceTab = {
inputRuleset: [],
ultimateBackgroundColor: '#000000',
liteMode: true,
onlyNormalState: true
onlyNormalState: true,
})
}
@ -418,22 +418,29 @@ const AppearanceTab = {
this.compilationCache[key] = theme3
}
const sheet = createStyleSheet('appearance-tab-previews', 90)
sheet.addRule([
'#theme-preview-', key, ' {\n',
getCssRules(theme3.eager).join('\n'),
'\n}'
].join(''))
sheet.addRule(
[
'#theme-preview-',
key,
' {\n',
getCssRules(theme3.eager).join('\n'),
'\n}',
].join(''),
)
sheet.ready = true
adoptStyleSheets()
},
uploadFile (slot, e) {
uploadFile(slot, e) {
const file = e.target.files[0]
if (!file) { return }
if (file.size > this.$store.state.instance[slot + 'limit']) {
if (!file) {
return
}
if (file.size > useInstanceStore()[slot + 'limit']) {
const filesize = fileSizeFormatService.fileSizeFormat(file.size)
const allowedsize = fileSizeFormatService.fileSizeFormat(this.$store.state.instance[slot + 'limit'])
const allowedsize = fileSizeFormatService.fileSizeFormat(
useInstanceStore()[slot + 'limit'],
)
useInterfaceStore().pushGlobalNotice({
messageKey: 'upload.error.message',
messageArgs: [
@ -441,10 +448,10 @@ const AppearanceTab = {
filesize: filesize.num,
filesizeunit: filesize.unit,
allowedsize: allowedsize.num,
allowedsizeunit: allowedsize.unit
})
allowedsizeunit: allowedsize.unit,
}),
],
level: 'error'
level: 'error',
})
return
}
@ -457,26 +464,42 @@ const AppearanceTab = {
}
reader.readAsDataURL(file)
},
resetBackground () {
const confirmed = window.confirm(this.$t('settings.reset_background_confirm'))
resetBackground() {
const confirmed = window.confirm(
this.$t('settings.reset_background_confirm'),
)
if (confirmed) {
this.submitBackground('')
}
},
submitBackground (background) {
if (!this.backgroundPreview && background !== '') { return }
resetUploadedBackground() {
this.backgroundPreview = null
},
clearBackgroundError() {
this.backgroundError = null
},
submitBackground(background) {
if (!this.backgroundPreview && background !== '') {
return
}
this.backgroundUploading = true
this.$store.state.api.backendInteractor.updateProfileImages({ background })
this.$store.state.api.backendInteractor
.updateProfileImages({ background })
.then((data) => {
this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data)
this.backgroundPreview = null
this.backgroundError = null
})
.catch((e) => {
this.backgroundError = e
})
.finally(() => {
this.backgroundUploading = false
})
.catch(this.displayUploadError)
.finally(() => { this.backgroundUploading = false })
},
}
},
}
export default AppearanceTab

View file

@ -1,27 +1,18 @@
.appearance-tab {
margin: 1em;
h3 {
border: none
}
.palette,
.theme-notice {
padding: 0.5em;
margin: 1em;
}
.setting-item {
padding-bottom: 0;
&.heading {
display: grid;
align-items: baseline;
grid-template-columns: 1fr auto auto auto;
grid-gap: 0.5em;
h2 {
flex: 1 0 auto;
}
}
}
h4 {
margin: 0.5em 0;
.theme-name {
font-weight: 900;
padding-bottom: 0.5em;
}
input[type="file"] {
@ -29,7 +20,31 @@
height: auto;
}
.banner-background {
display: flex;
gap: 1em;
flex-wrap: wrap;
h4 {
margin: 0;
}
}
.banner-background-input {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5em;
.custom-bg-control {
display: grid;
gap: 0.5em;
grid-template-columns: 1fr 1fr;
}
}
.banner-background-preview {
display: flex;
max-width: 100%;
width: 300px;
position: relative;
@ -37,40 +52,95 @@
img {
width: 100%;
}
}
.reset-button {
position: absolute;
top: 0.2em;
right: 0.2em;
border-radius: var(--roundness);
background-color: rgb(0 0 0 / 60%);
opacity: 0.7;
width: 1.5em;
height: 1.5em;
text-align: center;
line-height: 1.5em;
font-size: 1.5em;
cursor: pointer;
.fun-monitor {
position: relative;
pointer-events: none;
display: flex;
flex-direction: column;
align-items: center;
&:hover {
opacity: 1;
}
* {
line-height: 1;
}
svg {
color: white;
&-display-bezel,
&-display-screen {
aspect-ratio: 16 / 9;
width: 16em;
}
img {
object-fit: cover;
}
.wallpaper {
position: absolute;
inset: 0;
background-color: var(--wallpaper);
}
&-display-uploading {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
place-items: center;
place-content: center;
background-color: rgb(0 0 0 / 60%);
font-size: 4em;
}
&-display-screen {
padding: 0;
overflow: hidden;
position: relative;
&-overlay {
background: transparent;
position: absolute;
inset: 0;
z-index: 2;
}
&-image {
aspect-ratio: 16 / 9
}
}
&-display-bezel {
padding: 1em;
margin: 0;
order: 1;
z-index: 3;
}
&-neck {
width: 5em;
height: 3em;
margin-top: -1em;
margin-bottom: -0.5em;
order: 2
}
&-stand {
width: 8em;
height: 1em;
order: 3;
z-index: 1
}
}
}
.palettes-container {
height: 15em;
overflow: hidden auto;
scrollbar-gutter: stable;
border-radius: var(--roundness);
border: 1px solid var(--border);
margin: -0.5em;
margin-bottom: 0.5em;
margin-top: 0;
padding: 0.5em;
}
.palettes {
@ -80,9 +150,9 @@
padding: 0.5em;
width: 100%;
h4 {
margin: 0;
h5 {
grid-column: 1 / span 2;
margin-bottom: 0;
}
}
@ -160,7 +230,7 @@
.theme-preview {
font-size: 1rem; // fix for firefox
width: 19rem;
width: 14rem;
display: flex;
flex-direction: column;
align-items: center;

View file

@ -1,10 +1,15 @@
<template>
<div
class="appearance-tab"
:label="$t('settings.general')"
:label="$t('settings.interface')"
icon="table-columns"
>
<div class="setting-item">
<h2>{{ $t('settings.theme') }}</h2>
<div
class="setting-section"
:label="$t('settings.theme')"
icon="paintbrush"
>
<h3>{{ $t('settings.style.style_section') }}</h3>
<ul
ref="themeList"
class="theme-list"
@ -17,10 +22,10 @@
@click="resetTheming"
>
<preview id="theme-preview-stock" />
<h4 class="theme-name">
<span class="theme-name">
{{ $t('settings.style.stock_theme_used') }}
<span class="alert neutral version">v3</span>
</h4>
</span>
</button>
<button
v-if="isCustomThemeUsed"
@ -28,10 +33,10 @@
class="button-default theme-preview toggled"
>
<preview />
<h4 class="theme-name">
<span class="theme-name">
{{ $t('settings.style.custom_theme_used') }}
<span class="alert neutral version">v2</span>
</h4>
</span>
</button>
<button
v-if="isCustomStyleUsed"
@ -39,25 +44,25 @@
class="button-default theme-preview toggled"
>
<preview />
<h4 class="theme-name">
<span class="theme-name">
{{ $t('settings.style.custom_style_used') }}
<span class="alert neutral version">v3</span>
</h4>
</span>
</button>
<button
v-for="style in availableStyles"
:key="style.key"
:data-theme-key="style.key"
class="button-default theme-preview"
:class="{ toggled: isThemeActive(style.key), disabled: switchInProgress }"
:class="{ toggled: isStyleActive(style.key), disabled: switchInProgress }"
:disabled="switchInProgress"
@click="style.version === 'v2' ? setTheme(style.key) : setStyle(style.key)"
>
<preview :id="'theme-preview-' + style.key" />
<h4 class="theme-name">
<span class="theme-name">
{{ style.name }}
<span class="alert neutral version">{{ style.version }}</span>
</h4>
</span>
</button>
</ul>
<div class="import-file-container">
@ -70,16 +75,14 @@
<FAIcon icon="folder-open" />
{{ $t('settings.style.themes3.editor.load_style') }}
</button>
</div>
<div class="setting-item">
<h2>{{ $t('settings.style.themes3.palette.label') }}</h2>
<h4>{{ $t('settings.style.themes3.palette.label') }}</h4>
<div
v-if="customThemeVersion === 'v3'"
class="palettes-container"
>
<h4 v-if="stylePalettes?.length > 0">
<h5 v-if="stylePalettes?.length > 0">
{{ $t('settings.style.themes3.palette.style') }}
</h4>
</h5>
<div class="palettes">
<button
v-for="p in stylePalettes || []"
@ -103,7 +106,7 @@
/>
</div>
</button>
<h4>{{ $t('settings.style.themes3.palette.bundled') }}</h4>
<h5>{{ $t('settings.style.themes3.palette.bundled') }}</h5>
<button
v-for="p in bundledPalettes"
:key="p.name"
@ -130,9 +133,9 @@
</div>
<div>
<template v-if="customThemeVersion === 'v3'">
<h4 v-if="expertLevel > 0">
<h5 v-if="expertLevel > 0">
{{ $t('settings.style.themes3.palette.user') }}
</h4>
</h5>
<PaletteEditor
v-if="expertLevel > 0"
v-model="userPalette"
@ -150,236 +153,93 @@
</template>
</div>
</div>
</div>
<div class="setting-item">
<h2>{{ $t('settings.background') }}</h2>
<div class="banner-background-preview">
<img :src="user.background_image">
<button
v-if="!isDefaultBackground"
class="button-unstyled reset-button"
:title="$t('settings.reset_profile_background')"
@click="resetBackground"
>
<FAIcon
icon="times"
type="button"
/>
</button>
</div>
<p>{{ $t('settings.set_new_background') }}</p>
<img
v-if="backgroundPreview"
class="banner-background-preview"
:src="backgroundPreview"
>
<div>
<input
type="file"
class="input"
@change="uploadFile('background', $event)"
>
</div>
<FAIcon
v-if="backgroundUploading"
class="uploading"
spin
icon="circle-notch"
/>
<button
v-else-if="backgroundPreview"
class="btn button-default"
@click="submitBackground(background)"
>
{{ $t('settings.save') }}
</button>
</div>
<div class="setting-item">
<h2>{{ $t('settings.scale_and_layout') }}</h2>
<div class="alert neutral theme-notice">
{{ $t("settings.style.appearance_tab_note") }}
</div>
<ul class="setting-list">
<li>
<UnitSetting
path="textSize"
:step="0.1"
:units="['px', 'rem']"
:reset-default="{ 'px': 14, 'rem': 1 }"
timed-apply-mode
>
{{ $t('settings.text_size') }}
</UnitSetting>
<div>
<small>
<i18n-t
scope="global"
keypath="settings.text_size_tip"
tag="span"
>
<code>px</code>
<code>rem</code>
</i18n-t>
<br>
<i18n-t
scope="global"
keypath="settings.text_size_tip2"
tag="span"
>
<code>14px</code>
</i18n-t>
</small>
<h3>{{ $t('settings.background') }}</h3>
<div class="banner-background">
<div class="banner-background-preview">
<div class="fun-monitor">
<div class="fun-monitor-stand button-default" />
<div class="fun-monitor-neck button-default" />
<div class="fun-monitor-display-bezel button-default">
<div class="fun-monitor-display-screen input">
<img
v-if="backgroundPreview || user.background_image || instanceWallpaper"
class="fun-monitor-display-screen-image"
:src="backgroundPreview || user.background_image || instanceWallpaper"
>
<div
v-else
class="wallpaper"
/>
<div class="fun-monitor-display-screen-overlay input" />
<div
v-if="backgroundUploading"
class="fun-monitor-display-uploading"
>
<FAIcon
class="fun-monitor-display-screen-uploading"
spin
icon="circle-notch"
/>
</div>
</div>
</div>
</div>
</li>
<li>
<UnitSetting
path="emojiSize"
:step="0.1"
:units="['px', 'rem']"
:reset-default="{ 'px': 32, 'rem': 2.2 }"
</div>
<div class="banner-background-input">
<h4>{{ $t('settings.set_new_background') }}</h4>
<input
type="file"
class="input"
@change="uploadFile('background', $event)"
>
{{ $t('settings.emoji_size') }}
</UnitSetting>
<ul
class="setting-list suboptions"
>
<li>
<FloatSetting
v-if="user"
path="emojiReactionsScale"
expert="1"
>
{{ $t('settings.emoji_reactions_scale') }}
</FloatSetting>
</li>
</ul>
</li>
<li>
<UnitSetting
path="navbarSize"
:step="0.1"
:units="['px', 'rem']"
:reset-default="{ 'px': 55, 'rem': 3.5 }"
>
{{ $t('settings.navbar_size') }}
</UnitSetting>
</li>
<h3>{{ $t('settings.style.interface_font_user_override') }}</h3>
<li>
<FontControl
:model-value="mergedConfig.theme3hacks.fonts.interface"
name="ui"
:label="$t('settings.style.fonts.components.interface')"
:fallback="{ family: 'sans-serif' }"
no-inherit="1"
@update:model-value="v => updateFont('interface', v)"
/>
</li>
<li>
<FontControl
v-if="expertLevel > 0"
:model-value="mergedConfig.theme3hacks.fonts.input"
name="input"
:fallback="{ family: 'inherit' }"
:label="$t('settings.style.fonts.components.input')"
@update:model-value="v => updateFont('input', v)"
/>
</li>
<li>
<FontControl
v-if="expertLevel > 0"
:model-value="mergedConfig.theme3hacks.fonts.post"
name="post"
:fallback="{ family: 'inherit' }"
:label="$t('settings.style.fonts.components.post')"
@update:model-value="v => updateFont('post', v)"
/>
</li>
<li>
<FontControl
v-if="expertLevel > 0"
:model-value="mergedConfig.theme3hacks.fonts.monospace"
name="postCode"
:fallback="{ family: 'monospace' }"
:label="$t('settings.style.fonts.components.monospace')"
@update:model-value="v => updateFont('monospace', v)"
/>
</li>
<h3>{{ $t('settings.columns') }}</h3>
<li>
<UnitSetting
path="panelHeaderSize"
:step="0.1"
:units="['px', 'rem']"
:reset-default="{ 'px': 52, 'rem': 3.2 }"
timed-apply-mode
>
{{ $t('settings.panel_header_size') }}
</UnitSetting>
</li>
<li>
<BooleanSetting path="sidebarRight">
{{ $t('settings.right_sidebar') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="navbarColumnStretch">
{{ $t('settings.navbar_column_stretch') }}
</BooleanSetting>
</li>
<li>
<ChoiceSetting
v-if="user"
id="thirdColumnMode"
path="thirdColumnMode"
:options="thirdColumnModeOptions"
>
{{ $t('settings.third_column_mode') }}
</ChoiceSetting>
</li>
<li v-if="expertLevel > 0">
{{ $t('settings.column_sizes') }}
<div class="column-settings">
<UnitSetting
v-for="column in columns"
:key="column"
:path="column + 'ColumnWidth'"
:units="horizontalUnits"
expert="1"
<div class="custom-bg-control">
<button
:disabled="!backgroundPreview"
class="btn button-default"
@click="submitBackground(background)"
>
{{ $t('settings.column_sizes_' + column) }}
</UnitSetting>
{{ $t('settings.save') }}
</button>
<button
:disabled="!backgroundPreview"
class="btn button-default"
@click="resetUploadedBackground"
>
{{ $t('settings.reset') }}
</button>
</div>
</li>
<li>
<BooleanSetting path="disableStickyHeaders">
{{ $t('settings.disable_sticky_headers') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="showScrollbars">
{{ $t('settings.show_scrollbars') }}
</BooleanSetting>
</li>
<li>
<UnitSetting
path="themeEditorMinWidth"
:units="['px', 'rem']"
expert="1"
<div
v-if="backgroundError"
class="alert error -dismissible"
>
{{ $t('settings.theme_editor_min_width') }}
</UnitSetting>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.visual_tweaks') }}</h2>
<span>
{{ backgroundError }}
</span>
<button
class="button-unstyled"
@click="clearBackgroundError"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
/>
</button>
</div>
<button
v-if="!isDefaultBackground"
class="btn button-default reset-button"
:title="$t('settings.reset_profile_background')"
@click="resetBackground"
>
{{ $t('settings.reset_profile_background') }}
</button>
</div>
</div>
<h3>{{ $t('settings.visual_tweaks') }}</h3>
<div class="alert neutral theme-notice">
{{ $t("settings.style.visual_tweaks_section_note") }}
</div>
<ul class="setting-list">
<li>
<BooleanSetting path="modalMobileCenter">
{{ $t('settings.mobile_center_dialog') }}
</BooleanSetting>
</li>
<li>
<ChoiceSetting
id="forcedRoundness"
@ -392,7 +252,7 @@
<li>
<ChoiceSetting
id="underlayOverride"
path="theme3hacks.underlay"
path="underlay"
:options="underlayOverrideModes"
>
{{ $t('settings.style.themes3.hacks.underlay_overrides') }}
@ -404,19 +264,13 @@
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="forceThemeRecompilation"
:expert="1"
>
{{ $t('settings.force_theme_recompilation_debug') }}
<BooleanSetting path="allowForeignUserBackground">
{{ $t('settings.foreign_user_background') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="themeDebug"
:expert="1"
>
{{ $t('settings.theme_debug') }}
<BooleanSetting path="compactProfiles">
{{ $t('settings.compact_profiles') }}
</BooleanSetting>
</li>
</ul>

View file

@ -0,0 +1,159 @@
import { mapActions, mapState } from 'pinia'
import { v4 as uuidv4 } from 'uuid'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Select from 'src/components/select/select.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import HelpIndicator from '../helpers/help_indicator.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
const ClutterTab = {
components: {
BooleanSetting,
ChoiceSetting,
UnitSetting,
IntegerSetting,
Checkbox,
Select,
HelpIndicator,
},
computed: {
...SharedComputedObject(),
...mapState(useInstanceCapabilitiesStore, ['shoutAvailable']),
...mapState(useInstanceStore, {
showFeaturesPanel: (store) => store.instanceIdentity.showFeaturesPanel,
instanceSpecificPanelPresent: (store) =>
store.instanceIdentity.showInstanceSpecificPanel &&
store.instanceIdentity.instanceSpecificPanelContent,
}),
...mapState(useSyncConfigStore, {
muteFilters: (store) =>
Object.entries(store.prefsStorage.simple.muteFilters),
muteFiltersObject: (store) => store.prefsStorage.simple.muteFilters,
}),
},
methods: {
...mapActions(useSyncConfigStore, [
'setSimplePrefAndSave',
'unsetSimplePrefAndSave',
'pushSyncConfig',
]),
getDatetimeLocal(timestamp) {
const date = new Date(timestamp)
const fmt = new Intl.NumberFormat('en-US', { minimumIntegerDigits: 2 })
const datetime = [
date.getFullYear(),
'-',
fmt.format(date.getMonth() + 1),
'-',
fmt.format(date.getDate()),
'T',
fmt.format(date.getHours()),
':',
fmt.format(date.getMinutes()),
].join('')
return datetime
},
checkRegexValid(id) {
const filter = this.muteFiltersObject[id]
if (filter.type !== 'regexp') return true
if (filter.type !== 'user_regexp') return true
const { value } = filter
let valid = true
try {
new RegExp(value)
} catch {
valid = false
console.error('Invalid RegExp: ' + value)
}
return valid
},
createFilter(
filter = {
type: 'word',
value: '',
name: 'New Filter',
enabled: true,
expires: null,
hide: false,
},
) {
const newId = uuidv4()
filter.order = this.muteFilters.length + 2
this.muteFiltersDraftObject[newId] = filter
this.setSimplePrefAndSave({ path: 'muteFilters.' + newId, value: filter })
this.pushSyncConfig()
},
exportFilter(id) {
this.exportedFilter = { ...this.muteFiltersDraftObject[id] }
delete this.exportedFilter.order
this.filterExporter.exportData()
},
importFilter() {
this.filterImporter.importData()
},
copyFilter(id) {
const filter = { ...this.muteFiltersDraftObject[id] }
const newId = uuidv4()
this.muteFiltersDraftObject[newId] = filter
this.setSimplePrefAndSave({ path: 'muteFilters.' + newId, value: filter })
this.pushSyncConfig()
},
deleteFilter(id) {
delete this.muteFiltersDraftObject[id]
this.unsetSimplePrefAndSave({ path: 'muteFilters.' + id, value: null })
this.pushSyncConfig()
},
purgeExpiredFilters() {
this.muteFiltersExpired.forEach(([id]) => {
delete this.muteFiltersDraftObject[id]
this.unsetSimplePrefAndSave({ path: 'muteFilters.' + id, value: null })
})
this.pushSyncConfig()
},
updateFilter(id, field, value) {
const filter = { ...this.muteFiltersDraftObject[id] }
if (field === 'expires-never') {
if (!value) {
const offset = 1000 * 60 * 60 * 24 * 14 // 2 weeks
const date = Date.now() + offset
filter.expires = date
} else {
filter.expires = null
}
} else if (field === 'expires') {
const parsed = Date.parse(value)
filter.expires = parsed.valueOf()
} else {
filter[field] = value
}
this.muteFiltersDraftObject[id] = filter
this.muteFiltersDraftDirty[id] = true
},
saveFilter(id) {
this.setSimplePrefAndSave({
path: 'muteFilters.' + id,
value: this.muteFiltersDraftObject[id],
})
this.pushSyncConfig()
this.muteFiltersDraftDirty[id] = false
},
},
// Updating nested properties
watch: {
replyVisibility() {
this.$store.dispatch('queueFlushAll')
},
},
}
export default ClutterTab

View file

@ -0,0 +1,95 @@
<template>
<div class="clutter-tab">
<div class="setting-section">
<h3>{{ $t('settings.interface') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting path="alwaysShowSubjectInput">
{{ $t('settings.subject_input_always_show') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="minimalScopesMode">
{{ $t('settings.minimal_scopes_mode') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hidePostStats">
{{ $t('settings.hide_post_stats') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hideUserStats">
{{ $t('settings.hide_user_stats') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hideBotIndication">
{{ $t('settings.hide_actor_type_indication') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hideScrobbles">
{{ $t('settings.hide_scrobbles') }}
</BooleanSetting>
<ul class="setting-list suboptions">
<li>
<UnitSetting
key="hideScrobblesAfter"
path="hideScrobblesAfter"
:units="['m', 'h', 'd']"
unit-set="time"
>
{{ $t('settings.hide_scrobbles_after') }}
</UnitSetting>
</li>
<li v-if="instanceSpecificPanelPresent">
<BooleanSetting path="hideISP">
{{ $t('settings.hide_isp') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
<BooleanSetting path="userCardHidePersonalMarks">
{{ $t('settings.user_card_hide_personal_marks') }}
</BooleanSetting>
</li>
<li v-if="shoutAvailable">
<BooleanSetting path="hideShoutbox">
{{ $t('settings.hide_shoutbox') }}
</BooleanSetting>
</li>
</ul>
<h3>{{ $t('settings.attachments') }}</h3>
<ul class="setting-list">
<li>
<IntegerSetting
path="maxThumbnails"
:min="0"
>
{{ $t('settings.max_thumbnails') }}
</IntegerSetting>
</li>
<li>
<BooleanSetting
:local="true"
path="hideAttachments"
>
{{ $t('settings.hide_attachments_in_tl') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
:local="true"
path="hideAttachmentsInConv"
>
{{ $t('settings.hide_attachments_in_convo') }}
</BooleanSetting>
</li>
</ul>
</div>
</div>
</template>
<script src="./clutter_tab.js"></script>

View file

@ -0,0 +1,189 @@
import { mapState } from 'pinia'
import FontControl from 'src/components/font_control/font_control.vue'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
import Select from 'src/components/select/select.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import FloatSetting from '../helpers/float_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import localeService from 'src/services/locale/locale.service.js'
import { cacheKey, clearCache, emojiCacheKey } from 'src/services/sw/sw.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faDatabase,
faGlobe,
faMessage,
faPenAlt,
faSliders,
} from '@fortawesome/free-solid-svg-icons'
library.add(faGlobe, faMessage, faPenAlt, faDatabase, faSliders)
const ComposingTab = {
data() {
return {
subjectLineOptions: ['email', 'noop', 'masto'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(
`settings.subject_line_${mode === 'masto' ? 'mastodon' : mode}`,
),
})),
conversationDisplayOptions: ['tree', 'linear'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.conversation_display_${mode}`),
})),
absoluteTime12hOptions: ['24h', '12h'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.absolute_time_format_12h_${mode}`),
})),
conversationOtherRepliesButtonOptions: ['below', 'inside'].map(
(mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.conversation_other_replies_button_${mode}`),
}),
),
mentionLinkDisplayOptions: ['short', 'full_for_remote', 'full'].map(
(mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.mention_link_display_${mode}`),
}),
),
userPopoverAvatarActionOptions: ['close', 'zoom', 'open'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.user_popover_avatar_action_${mode}`),
})),
unsavedPostActionOptions: ['save', 'discard', 'confirm'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.unsaved_post_action_${mode}`),
})),
loopSilentAvailable:
// Firefox
Object.getOwnPropertyDescriptor(
HTMLVideoElement.prototype,
'mozHasAudio',
) ||
// Chrome-likes
Object.getOwnPropertyDescriptor(
HTMLMediaElement.prototype,
'webkitAudioDecodedByteCount',
) ||
// Future spec, still not supported in Nightly 63 as of 08/2018
Object.getOwnPropertyDescriptor(
HTMLMediaElement.prototype,
'audioTracks',
),
emailLanguage: this.$store.state.users.currentUser.language || [''],
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
FloatSetting,
UnitSetting,
InterfaceLanguageSwitcher,
ScopeSelector,
Select,
FontControl,
},
computed: {
postFormats() {
return useInstanceCapabilitiesStore().postFormats
},
postContentOptions() {
return this.postFormats.map((format) => ({
key: format,
value: format,
label: this.$t(`post_status.content_type["${format}"]`),
}))
},
language: {
get: function () {
return useMergedConfigStore().mergedConfig.interfaceLanguage
},
set: function (val) {
useSyncConfigStore().setSimplePrefAndSave({
path: 'interfaceLanguage',
value: val,
})
},
},
...SharedComputedObject(),
...mapState(useInstanceStore, ['blockExpiration']),
},
methods: {
changeDefaultScope(value) {
this.$store.dispatch('setProfileOption', { name: 'defaultScope', value })
},
clearCache(key) {
clearCache(key)
.then(() => {
this.$store.dispatch('settingsSaved', { success: true })
})
.catch((error) => {
this.$store.dispatch('settingsSaved', { error })
})
},
tooSmall() {
this.$emit('tooSmall')
},
tooBig() {
this.$emit('tooBig')
},
getNavMode() {
return this.$refs.tabSwitcher.getNavMode()
},
clearAssetCache() {
this.clearCache(cacheKey)
},
clearEmojiCache() {
this.clearCache(emojiCacheKey)
},
updateProfile() {
const params = {
language: localeService.internalToBackendLocaleMulti(
this.emailLanguage,
),
}
this.$store.state.api.backendInteractor
.updateProfile({ params })
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
})
},
updateFont(key, value) {
useSyncConfigStore().setSimplePrefAndSave({
path: 'theme3hacks',
value: {
...this.mergedConfig.theme3hacks,
fonts: {
...this.mergedConfig.theme3hacks.fonts,
[key]: value,
},
},
})
},
},
}
export default ComposingTab

View file

@ -0,0 +1,117 @@
<template>
<div :label="$t('settings.posts')">
<div class="setting-section">
<h3>{{ $t('settings.general') }}</h3>
<ul class="setting-list">
<li>
<label
class="setting-item "
for="default-vis"
>
<ScopeSelector
class="scope-selector setting-control"
:show-all="true"
:user-default="$store.state.profileConfig.defaultScope"
:initial-scope="$store.state.profileConfig.defaultScope"
:on-scope-change="changeDefaultScope"
:unstyled="false"
/>
</label>
</li>
<li>
<!-- <BooleanSetting source="profile" path="defaultNSFW"> -->
<BooleanSetting path="sensitiveByDefault">
{{ $t('settings.sensitive_by_default') }}
</BooleanSetting>
</li>
<li v-if="postFormats.length > 0">
<ChoiceSetting
id="postContentType"
path="postContentType"
:options="postContentOptions"
:local="true"
>
{{ $t('settings.default_post_status_content_type') }}
</ChoiceSetting>
</li>
<li>
<BooleanSetting path="padEmoji">
{{ $t('settings.pad_emoji') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="autocompleteSelect"
expert="1"
>
{{ $t('settings.autocomplete_select_first') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="autoSaveDraft"
>
{{ $t('settings.auto_save_draft') }}
</BooleanSetting>
</li>
<li v-if="!mergedConfig.autoSaveDraft">
<ChoiceSetting
id="unsavedPostAction"
path="unsavedPostAction"
:options="unsavedPostActionOptions"
expert="1"
>
{{ $t('settings.unsaved_post_action') }}
</ChoiceSetting>
</li>
</ul>
<h3>{{ $t('settings.replies') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting
path="scopeCopy"
>
{{ $t('settings.scope_copy') }}
</BooleanSetting>
</li>
<li>
<ChoiceSetting
id="subjectLineBehavior"
path="subjectLineBehavior"
:options="subjectLineOptions"
>
{{ $t('settings.subject_line_behavior') }}
</ChoiceSetting>
</li>
</ul>
<h3 v-if="expertLevel > 0">
{{ $t('settings.attachments') }}
</h3>
<ul class="setting-list">
<li>
<BooleanSetting
path="imageCompression"
:local="true"
expert="1"
>
{{ $t('settings.image_compression') }}
</BooleanSetting>
<ul class="setting-list suboptions">
<li>
<BooleanSetting
path="alwaysUseJpeg"
:local="true"
expert="1"
parent-path="imageCompression"
>
{{ $t('settings.always_use_jpeg') }}
</BooleanSetting>
</li>
</ul>
</li>
</ul>
</div>
</div>
</template>
<script src="./composing_tab.js"></script>

View file

@ -1,85 +1,90 @@
import Importer from 'src/components/importer/importer.vue'
import Exporter from 'src/components/exporter/exporter.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import { mapState } from 'vuex'
import { useOAuthTokensStore } from 'src/stores/oauth_tokens'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Exporter from 'src/components/exporter/exporter.vue'
import Importer from 'src/components/importer/importer.vue'
import { useOAuthTokensStore } from 'src/stores/oauth_tokens.js'
const DataImportExportTab = {
data () {
data() {
return {
activeTab: 'profile',
newDomainToMute: '',
listBackupsError: false,
addBackupError: false,
addedBackup: false,
backups: []
backups: [],
}
},
created () {
created() {
useOAuthTokensStore().fetchTokens()
this.fetchBackups()
},
components: {
Importer,
Exporter,
Checkbox
Checkbox,
},
computed: {
...mapState({
backendInteractor: (state) => state.api.backendInteractor,
user: (state) => state.users.currentUser
})
user: (state) => state.users.currentUser,
}),
},
methods: {
getFollowsContent () {
return this.backendInteractor.exportFriends({ id: this.user.id })
getFollowsContent() {
return this.backendInteractor
.exportFriends({ id: this.user.id })
.then(this.generateExportableUsersContent)
},
getBlocksContent () {
return this.backendInteractor.fetchBlocks()
getBlocksContent() {
return this.backendInteractor
.fetchBlocks()
.then(this.generateExportableUsersContent)
},
getMutesContent () {
return this.backendInteractor.fetchMutes()
getMutesContent() {
return this.backendInteractor
.fetchMutes()
.then(this.generateExportableUsersContent)
},
importFollows (file) {
return this.backendInteractor.importFollows({ file })
.then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
importBlocks (file) {
return this.backendInteractor.importBlocks({ file })
.then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
importMutes (file) {
return this.backendInteractor.importMutes({ file })
.then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
generateExportableUsersContent (users) {
// Get addresses
return users.map((user) => {
// check is it's a local user
if (user && user.is_local) {
// append the instance address
return user.screen_name + '@' + location.hostname
importFollows(file) {
return this.backendInteractor.importFollows({ file }).then((status) => {
if (!status) {
throw new Error('failed')
}
return user.screen_name
}).join('\n')
})
},
addBackup () {
this.$store.state.api.backendInteractor.addBackup()
importBlocks(file) {
return this.backendInteractor.importBlocks({ file }).then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
importMutes(file) {
return this.backendInteractor.importMutes({ file }).then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
generateExportableUsersContent(users) {
// Get addresses
return users
.map((user) => {
// check is it's a local user
if (user && user.is_local) {
// append the instance address
return user.screen_name + '@' + location.hostname
}
return user.screen_name
})
.join('\n')
},
addBackup() {
this.$store.state.api.backendInteractor
.addBackup()
.then(() => {
this.addedBackup = true
this.addBackupError = false
@ -90,8 +95,9 @@ const DataImportExportTab = {
})
.then(() => this.fetchBackups())
},
fetchBackups () {
this.$store.state.api.backendInteractor.listBackups()
fetchBackups() {
this.$store.state.api.backendInteractor
.listBackups()
.then((res) => {
this.backups = res
this.listBackupsError = false
@ -99,8 +105,8 @@ const DataImportExportTab = {
.catch((error) => {
this.listBackupsError = error.error
})
}
}
},
},
}
export default DataImportExportTab

View file

@ -0,0 +1,21 @@
.data-import-export-tab {
.importer-exporter {
display: inline-flex;
flex-direction: column;
gap: 0.5em;
}
table {
td, th {
line-height: 1.5;
}
th {
padding: 0 0.5em;
}
td {
padding: 0.5em;
}
}
}

View file

@ -1,62 +1,77 @@
<template>
<div
class="data-import-export-tab"
:label="$t('settings.data_import_export_tab')"
>
<div class="setting-item">
<h2>{{ $t('settings.follow_import') }}</h2>
<p>{{ $t('settings.import_followers_from_a_csv_file') }}</p>
<Importer
:submit-handler="importFollows"
:success-message="$t('settings.follows_imported')"
:error-message="$t('settings.follow_import_error')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.follow_export') }}</h2>
<Exporter
:get-content="getFollowsContent"
filename="friends.csv"
:export-button-label="$t('settings.follow_export_button')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.block_import') }}</h2>
<p>{{ $t('settings.import_blocks_from_a_csv_file') }}</p>
<Importer
:submit-handler="importBlocks"
:success-message="$t('settings.blocks_imported')"
:error-message="$t('settings.block_import_error')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.block_export') }}</h2>
<Exporter
:get-content="getBlocksContent"
filename="blocks.csv"
:export-button-label="$t('settings.block_export_button')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.mute_import') }}</h2>
<p>{{ $t('settings.import_mutes_from_a_csv_file') }}</p>
<Importer
:submit-handler="importMutes"
:success-message="$t('settings.mutes_imported')"
:error-message="$t('settings.mute_import_error')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.mute_export') }}</h2>
<Exporter
:get-content="getMutesContent"
filename="mutes.csv"
:export-button-label="$t('settings.mute_export_button')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.account_backup') }}</h2>
<p>{{ $t('settings.account_backup_description') }}</p>
<table>
<div class="setting-section">
<h3>{{ $t('settings.import_export.title') }}</h3>
<ul class="setting-list">
<li>
<h4>{{ $t('settings.import_export.follows') }}</h4>
<p>{{ $t('settings.import_followers_from_a_csv_file') }}</p>
<div class="importer-exporter">
<Importer
:submit-handler="importFollows"
:success-message="$t('settings.follows_imported')"
:error-message="$t('settings.follow_import_error')"
/>
<Exporter
:get-content="getFollowsContent"
filename="friends.csv"
:export-button-label="$t('settings.follow_export_button')"
/>
</div>
</li>
<li>
<h4>{{ $t('settings.import_export.mutes') }}</h4>
<p>{{ $t('settings.import_mutes_from_a_csv_file') }}</p>
<div class="importer-exporter">
<Importer
:submit-handler="importMutes"
:success-message="$t('settings.mutes_imported')"
:error-message="$t('settings.mute_import_error')"
/>
<Exporter
:get-content="getMutesContent"
filename="friends.csv"
:export-button-label="$t('settings.mute_export_button')"
/>
</div>
</li>
<li>
<h4>{{ $t('settings.import_export.blocks') }}</h4>
<p>{{ $t('settings.import_blocks_from_a_csv_file') }}</p>
<div class="importer-exporter">
<Importer
:submit-handler="importBlocks"
:success-message="$t('settings.blocks_imported')"
:error-message="$t('settings.block_import_error')"
/>
<Exporter
:get-content="getBlocksContent"
filename="friends.csv"
:export-button-label="$t('settings.block_export_button')"
/>
</div>
</li>
</ul>
<h3>{{ $t('settings.account_backup') }}</h3>
<div class="setting-list">
<p>{{ $t('settings.account_backup_description') }}</p>
<button
class="btn button-default"
@click="addBackup"
>
{{ $t('settings.add_backup') }}
</button>
<p v-if="addedBackup">
{{ $t('settings.added_backup') }}
</p>
<template v-if="addBackupError !== false">
<p>{{ $t('settings.add_backup_error', { error: addBackupError }) }}</p>
</template>
</div>
<table class="setting-list">
<thead>
<tr>
<th>{{ $t('settings.account_backup_table_head') }}</th>
@ -111,21 +126,9 @@
/>
</button>
</div>
<button
class="btn button-default"
@click="addBackup"
>
{{ $t('settings.add_backup') }}
</button>
<p v-if="addedBackup">
{{ $t('settings.added_backup') }}
</p>
<template v-if="addBackupError !== false">
<p>{{ $t('settings.add_backup_error', { error: addBackupError }) }}</p>
</template>
</div>
</div>
</template>
<script src="./data_import_export_tab.js"></script>
<!-- <style lang="scss" src="./profile.scss"></style> -->
<style lang="scss" src="./data_import_export_tab.scss"></style>

View file

@ -0,0 +1,47 @@
import { mapState } from 'pinia'
import BooleanSetting from '../helpers/boolean_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { cacheKey, clearCache, emojiCacheKey } from 'src/services/sw/sw.js'
const pleromaFeCommitUrl =
'https://git.pleroma.social/pleroma/pleroma-fe/commit/'
const VersionTab = {
components: {
BooleanSetting,
},
computed: {
frontendVersionLink() {
return pleromaFeCommitUrl + this.frontendVersion
},
...mapState(useInstanceStore, [
'backendVersion',
'backendRepository',
'frontendVersion',
]),
...SharedComputedObject(),
},
methods: {
clearAssetCache() {
this.clearCache(cacheKey)
},
clearEmojiCache() {
this.clearCache(emojiCacheKey)
},
clearCache(key) {
clearCache(key)
.then(() => {
this.$store.dispatch('settingsSaved', { success: true })
})
.catch((error) => {
this.$store.dispatch('settingsSaved', { error })
})
},
},
}
export default VersionTab

Some files were not shown because too many files have changed in this diff Show more