aboutsummaryrefslogtreecommitdiff
path: root/src/components/settings_modal
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/settings_modal')
-rw-r--r--src/components/settings_modal/helpers/shared_computed_object.js58
-rw-r--r--src/components/settings_modal/settings_modal.js42
-rw-r--r--src/components/settings_modal/settings_modal.scss44
-rw-r--r--src/components/settings_modal/settings_modal.vue54
-rw-r--r--src/components/settings_modal/settings_modal_content.js34
-rw-r--r--src/components/settings_modal/settings_modal_content.scss43
-rw-r--r--src/components/settings_modal/settings_modal_content.vue73
-rw-r--r--src/components/settings_modal/tabs/data_import_export_tab.js65
-rw-r--r--src/components/settings_modal/tabs/data_import_export_tab.vue43
-rw-r--r--src/components/settings_modal/tabs/filtering_tab.js44
-rw-r--r--src/components/settings_modal/tabs/filtering_tab.vue86
-rw-r--r--src/components/settings_modal/tabs/general_tab.js31
-rw-r--r--src/components/settings_modal/tabs/general_tab.vue272
-rw-r--r--src/components/settings_modal/tabs/mutes_and_blocks_tab.js136
-rw-r--r--src/components/settings_modal/tabs/mutes_and_blocks_tab.scss29
-rw-r--r--src/components/settings_modal/tabs/mutes_and_blocks_tab.vue171
-rw-r--r--src/components/settings_modal/tabs/notifications_tab.js27
-rw-r--r--src/components/settings_modal/tabs/notifications_tab.vue54
-rw-r--r--src/components/settings_modal/tabs/profile_tab.js179
-rw-r--r--src/components/settings_modal/tabs/profile_tab.scss82
-rw-r--r--src/components/settings_modal/tabs/profile_tab.vue213
-rw-r--r--src/components/settings_modal/tabs/security_tab/confirm.js9
-rw-r--r--src/components/settings_modal/tabs/security_tab/confirm.vue22
-rw-r--r--src/components/settings_modal/tabs/security_tab/mfa.js155
-rw-r--r--src/components/settings_modal/tabs/security_tab/mfa.vue173
-rw-r--r--src/components/settings_modal/tabs/security_tab/mfa_backup_codes.js17
-rw-r--r--src/components/settings_modal/tabs/security_tab/mfa_backup_codes.vue35
-rw-r--r--src/components/settings_modal/tabs/security_tab/mfa_totp.js49
-rw-r--r--src/components/settings_modal/tabs/security_tab/mfa_totp.vue43
-rw-r--r--src/components/settings_modal/tabs/security_tab/security_tab.js106
-rw-r--r--src/components/settings_modal/tabs/security_tab/security_tab.vue143
-rw-r--r--src/components/settings_modal/tabs/theme_tab/preview.vue117
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.js759
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.scss345
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.vue965
-rw-r--r--src/components/settings_modal/tabs/version_tab.js24
-rw-r--r--src/components/settings_modal/tabs/version_tab.vue31
37 files changed, 4773 insertions, 0 deletions
diff --git a/src/components/settings_modal/helpers/shared_computed_object.js b/src/components/settings_modal/helpers/shared_computed_object.js
new file mode 100644
index 00000000..86703697
--- /dev/null
+++ b/src/components/settings_modal/helpers/shared_computed_object.js
@@ -0,0 +1,58 @@
+import {
+ instanceDefaultProperties,
+ multiChoiceProperties,
+ defaultState as configDefaultState
+} from 'src/modules/config.js'
+
+const SharedComputedObject = () => ({
+ user () {
+ return this.$store.state.users.currentUser
+ },
+ // Getting localized values for instance-default properties
+ ...instanceDefaultProperties
+ .filter(key => multiChoiceProperties.includes(key))
+ .map(key => [
+ key + 'DefaultValue',
+ function () {
+ return this.$store.getters.instanceDefaultConfig[key]
+ }
+ ])
+ .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
+ ...instanceDefaultProperties
+ .filter(key => !multiChoiceProperties.includes(key))
+ .map(key => [
+ key + 'LocalizedValue',
+ function () {
+ return this.$t('settings.values.' + this.$store.getters.instanceDefaultConfig[key])
+ }
+ ])
+ .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
+ // Generating computed values for vuex properties
+ ...Object.keys(configDefaultState)
+ .map(key => [key, {
+ get () { return this.$store.getters.mergedConfig[key] },
+ set (value) {
+ this.$store.dispatch('setOption', { name: key, value })
+ }
+ }])
+ .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
+ // Special cases (need to transform values or perform actions first)
+ useStreamingApi: {
+ get () { return this.$store.getters.mergedConfig.useStreamingApi },
+ set (value) {
+ const promise = value
+ ? this.$store.dispatch('enableMastoSockets')
+ : this.$store.dispatch('disableMastoSockets')
+
+ promise.then(() => {
+ this.$store.dispatch('setOption', { name: 'useStreamingApi', value })
+ }).catch((e) => {
+ console.error('Failed starting MastoAPI Streaming socket', e)
+ this.$store.dispatch('disableMastoSockets')
+ this.$store.dispatch('setOption', { name: 'useStreamingApi', value: false })
+ })
+ }
+ }
+})
+
+export default SharedComputedObject
diff --git a/src/components/settings_modal/settings_modal.js b/src/components/settings_modal/settings_modal.js
new file mode 100644
index 00000000..f0d49c91
--- /dev/null
+++ b/src/components/settings_modal/settings_modal.js
@@ -0,0 +1,42 @@
+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'
+
+const SettingsModal = {
+ components: {
+ Modal,
+ SettingsModalContent: getResettableAsyncComponent(
+ () => import('./settings_modal_content.vue'),
+ {
+ loading: PanelLoading,
+ error: AsyncComponentError,
+ delay: 0
+ }
+ )
+ },
+ methods: {
+ closeModal () {
+ this.$store.dispatch('closeSettingsModal')
+ },
+ peekModal () {
+ this.$store.dispatch('togglePeekSettingsModal')
+ }
+ },
+ computed: {
+ currentSaveStateNotice () {
+ return this.$store.state.interface.settings.currentSaveStateNotice
+ },
+ modalActivated () {
+ return this.$store.state.interface.settingsModalState !== 'hidden'
+ },
+ modalOpenedOnce () {
+ return this.$store.state.interface.settingsModalLoaded
+ },
+ modalPeeked () {
+ return this.$store.state.interface.settingsModalState === 'minimized'
+ }
+ }
+}
+
+export default SettingsModal
diff --git a/src/components/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss
new file mode 100644
index 00000000..833ff89a
--- /dev/null
+++ b/src/components/settings_modal/settings_modal.scss
@@ -0,0 +1,44 @@
+@import 'src/_variables.scss';
+.settings-modal {
+ overflow: hidden;
+
+ &.peek {
+ .settings-modal-panel {
+ /* Explanation:
+ * Modal is positioned vertically centered.
+ * 100vh - 100% = Distance between modal's top+bottom boundaries and screen
+ * (100vh - 100%) / 2 = Distance between bottom (or top) boundary and screen
+ * + 100% - we move modal completely off-screen, it's top boundary touches
+ * bottom of the screen
+ * - 50px - leaving tiny amount of space so that titlebar + tiny amount of modal is visible
+ */
+ transform: translateY(calc(((100vh - 100%) / 2 + 100%) - 50px));
+ }
+ }
+
+ .settings-modal-panel {
+ overflow: hidden;
+ transition: transform;
+ transition-timing-function: ease-in-out;
+ transition-duration: 300ms;
+ width: 1000px;
+ max-width: 90vw;
+ height: 90vh;
+
+ @media all and (max-width: 800px) {
+ max-width: 100vw;
+ height: 100vh;
+ }
+
+ .panel-body {
+ height: 100%;
+ overflow-y: hidden;
+
+ .btn {
+ min-height: 28px;
+ min-width: 10em;
+ padding: 0 2em;
+ }
+ }
+ }
+}
diff --git a/src/components/settings_modal/settings_modal.vue b/src/components/settings_modal/settings_modal.vue
new file mode 100644
index 00000000..6bc64ed0
--- /dev/null
+++ b/src/components/settings_modal/settings_modal.vue
@@ -0,0 +1,54 @@
+<template>
+ <Modal
+ :is-open="modalActivated"
+ class="settings-modal"
+ :class="{ peek: modalPeeked }"
+ :no-background="modalPeeked"
+ >
+ <div class="settings-modal-panel panel">
+ <div class="panel-heading">
+ <span class="title">
+ {{ $t('settings.settings') }}
+ </span>
+ <transition name="fade">
+ <template v-if="currentSaveStateNotice">
+ <div
+ v-if="currentSaveStateNotice.error"
+ class="alert error"
+ @click.prevent
+ >
+ {{ $t('settings.saving_err') }}
+ </div>
+
+ <div
+ v-if="!currentSaveStateNotice.error"
+ class="alert transparent"
+ @click.prevent
+ >
+ {{ $t('settings.saving_ok') }}
+ </div>
+ </template>
+ </transition>
+ <button
+ class="btn"
+ @click="peekModal"
+ >
+ {{ $t('general.peek') }}
+ </button>
+ <button
+ class="btn"
+ @click="closeModal"
+ >
+ {{ $t('general.close') }}
+ </button>
+ </div>
+ <div class="panel-body">
+ <SettingsModalContent v-if="modalOpenedOnce" />
+ </div>
+ </div>
+ </Modal>
+</template>
+
+<script src="./settings_modal.js"></script>
+
+<style src="./settings_modal.scss" lang="scss"></style>
diff --git a/src/components/settings_modal/settings_modal_content.js b/src/components/settings_modal/settings_modal_content.js
new file mode 100644
index 00000000..48101a90
--- /dev/null
+++ b/src/components/settings_modal/settings_modal_content.js
@@ -0,0 +1,34 @@
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
+
+import DataImportExportTab from './tabs/data_import_export_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 ProfileTab from './tabs/profile_tab.vue'
+import GeneralTab from './tabs/general_tab.vue'
+import VersionTab from './tabs/version_tab.vue'
+import ThemeTab from './tabs/theme_tab/theme_tab.vue'
+
+const SettingsModalContent = {
+ components: {
+ TabSwitcher,
+
+ DataImportExportTab,
+ MutesAndBlocksTab,
+ NotificationsTab,
+ FilteringTab,
+ SecurityTab,
+ ProfileTab,
+ GeneralTab,
+ VersionTab,
+ ThemeTab
+ },
+ computed: {
+ isLoggedIn () {
+ return !!this.$store.state.users.currentUser
+ }
+ }
+}
+
+export default SettingsModalContent
diff --git a/src/components/settings_modal/settings_modal_content.scss b/src/components/settings_modal/settings_modal_content.scss
new file mode 100644
index 00000000..a3fef1cf
--- /dev/null
+++ b/src/components/settings_modal/settings_modal_content.scss
@@ -0,0 +1,43 @@
+@import 'src/_variables.scss';
+.settings_tab-switcher {
+ height: 100%;
+
+ .setting-item {
+ border-bottom: 2px solid var(--fg, $fallback--fg);
+ margin: 1em 1em 1.4em;
+ padding-bottom: 1.4em;
+
+ > div {
+ margin-bottom: .5em;
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ &:last-child {
+ border-bottom: none;
+ padding-bottom: 0;
+ margin-bottom: 1em;
+ }
+
+ select {
+ min-width: 10em;
+ }
+
+ textarea {
+ width: 100%;
+ max-width: 100%;
+ height: 100px;
+ }
+
+ .unavailable,
+ .unavailable i {
+ color: var(--cRed, $fallback--cRed);
+ color: $fallback--cRed;
+ }
+
+ .number-input {
+ max-width: 6em;
+ }
+ }
+}
diff --git a/src/components/settings_modal/settings_modal_content.vue b/src/components/settings_modal/settings_modal_content.vue
new file mode 100644
index 00000000..2156844f
--- /dev/null
+++ b/src/components/settings_modal/settings_modal_content.vue
@@ -0,0 +1,73 @@
+<template>
+ <tab-switcher
+ ref="tabSwitcher"
+ class="settings_tab-switcher"
+ :side-tab-bar="true"
+ :scrollable-tabs="true"
+ >
+ <div
+ :label="$t('settings.general')"
+ icon="wrench"
+ >
+ <GeneralTab />
+ </div>
+ <div
+ v-if="isLoggedIn"
+ :label="$t('settings.profile_tab')"
+ icon="user"
+ >
+ <ProfileTab />
+ </div>
+ <div
+ v-if="isLoggedIn"
+ :label="$t('settings.security_tab')"
+ icon="lock"
+ >
+ <SecurityTab />
+ </div>
+ <div
+ :label="$t('settings.filtering')"
+ icon="filter"
+ >
+ <FilteringTab />
+ </div>
+ <div
+ :label="$t('settings.theme')"
+ icon="brush"
+ >
+ <ThemeTab />
+ </div>
+ <div
+ v-if="isLoggedIn"
+ :label="$t('settings.notifications')"
+ icon="bell-ringing-o"
+ >
+ <NotificationsTab />
+ </div>
+ <div
+ v-if="isLoggedIn"
+ :label="$t('settings.data_import_export_tab')"
+ icon="download"
+ >
+ <DataImportExportTab />
+ </div>
+ <div
+ v-if="isLoggedIn"
+ :label="$t('settings.mutes_and_blocks')"
+ :fullHeight="true"
+ icon="eye-off"
+ >
+ <MutesAndBlocksTab />
+ </div>
+ <div
+ :label="$t('settings.version.title')"
+ icon="info-circled"
+ >
+ <VersionTab />
+ </div>
+ </tab-switcher>
+</template>
+
+<script src="./settings_modal_content.js"></script>
+
+<style src="./settings_modal_content.scss" lang="scss"></style>
diff --git a/src/components/settings_modal/tabs/data_import_export_tab.js b/src/components/settings_modal/tabs/data_import_export_tab.js
new file mode 100644
index 00000000..168f89e1
--- /dev/null
+++ b/src/components/settings_modal/tabs/data_import_export_tab.js
@@ -0,0 +1,65 @@
+import Importer from 'src/components/importer/importer.vue'
+import Exporter from 'src/components/exporter/exporter.vue'
+import Checkbox from 'src/components/checkbox/checkbox.vue'
+
+const DataImportExportTab = {
+ data () {
+ return {
+ activeTab: 'profile',
+ newDomainToMute: ''
+ }
+ },
+ created () {
+ this.$store.dispatch('fetchTokens')
+ },
+ components: {
+ Importer,
+ Exporter,
+ Checkbox
+ },
+ computed: {
+ user () {
+ return this.$store.state.users.currentUser
+ }
+ },
+ methods: {
+ getFollowsContent () {
+ return this.$store.state.api.backendInteractor.exportFriends({ id: this.$store.state.users.currentUser.id })
+ .then(this.generateExportableUsersContent)
+ },
+ getBlocksContent () {
+ return this.$store.state.api.backendInteractor.fetchBlocks()
+ .then(this.generateExportableUsersContent)
+ },
+ importFollows (file) {
+ return this.$store.state.api.backendInteractor.importFollows({ file })
+ .then((status) => {
+ if (!status) {
+ throw new Error('failed')
+ }
+ })
+ },
+ importBlocks (file) {
+ return this.$store.state.api.backendInteractor.importBlocks({ 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
+ // eslint-disable-next-line no-undef
+ return user.screen_name + '@' + location.hostname
+ }
+ return user.screen_name
+ }).join('\n')
+ }
+ }
+}
+
+export default DataImportExportTab
diff --git a/src/components/settings_modal/tabs/data_import_export_tab.vue b/src/components/settings_modal/tabs/data_import_export_tab.vue
new file mode 100644
index 00000000..b5d0f5ed
--- /dev/null
+++ b/src/components/settings_modal/tabs/data_import_export_tab.vue
@@ -0,0 +1,43 @@
+<template>
+ <div
+ :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>
+</template>
+
+<script src="./data_import_export_tab.js"></script>
+<!-- <style lang="scss" src="./profile.scss"></style> -->
diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js
new file mode 100644
index 00000000..224a7f47
--- /dev/null
+++ b/src/components/settings_modal/tabs/filtering_tab.js
@@ -0,0 +1,44 @@
+import { filter, trim } from 'lodash'
+import Checkbox from 'src/components/checkbox/checkbox.vue'
+
+import SharedComputedObject from '../helpers/shared_computed_object.js'
+
+const FilteringTab = {
+ data () {
+ return {
+ muteWordsStringLocal: this.$store.getters.mergedConfig.muteWords.join('\n')
+ }
+ },
+ components: {
+ Checkbox
+ },
+ computed: {
+ ...SharedComputedObject(),
+ muteWordsString: {
+ get () {
+ return this.muteWordsStringLocal
+ },
+ set (value) {
+ this.muteWordsStringLocal = value
+ this.$store.dispatch('setOption', {
+ name: 'muteWords',
+ value: filter(value.split('\n'), (word) => trim(word).length > 0)
+ })
+ }
+ }
+ },
+ // Updating nested properties
+ watch: {
+ notificationVisibility: {
+ handler (value) {
+ this.$store.dispatch('setOption', {
+ name: 'notificationVisibility',
+ value: this.$store.getters.mergedConfig.notificationVisibility
+ })
+ },
+ deep: true
+ }
+ }
+}
+
+export default FilteringTab
diff --git a/src/components/settings_modal/tabs/filtering_tab.vue b/src/components/settings_modal/tabs/filtering_tab.vue
new file mode 100644
index 00000000..eea41514
--- /dev/null
+++ b/src/components/settings_modal/tabs/filtering_tab.vue
@@ -0,0 +1,86 @@
+<template>
+ <div :label="$t('settings.filtering')">
+ <div class="setting-item">
+ <div class="select-multiple">
+ <span class="label">{{ $t('settings.notification_visibility') }}</span>
+ <ul class="option-list">
+ <li>
+ <Checkbox v-model="notificationVisibility.likes">
+ {{ $t('settings.notification_visibility_likes') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="notificationVisibility.repeats">
+ {{ $t('settings.notification_visibility_repeats') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="notificationVisibility.follows">
+ {{ $t('settings.notification_visibility_follows') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="notificationVisibility.mentions">
+ {{ $t('settings.notification_visibility_mentions') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="notificationVisibility.moves">
+ {{ $t('settings.notification_visibility_moves') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="notificationVisibility.emojiReactions">
+ {{ $t('settings.notification_visibility_emoji_reactions') }}
+ </Checkbox>
+ </li>
+ </ul>
+ </div>
+ <div>
+ {{ $t('settings.replies_in_timeline') }}
+ <label
+ for="replyVisibility"
+ class="select"
+ >
+ <select
+ id="replyVisibility"
+ v-model="replyVisibility"
+ >
+ <option
+ value="all"
+ selected
+ >{{ $t('settings.reply_visibility_all') }}</option>
+ <option value="following">{{ $t('settings.reply_visibility_following') }}</option>
+ <option value="self">{{ $t('settings.reply_visibility_self') }}</option>
+ </select>
+ <i class="icon-down-open" />
+ </label>
+ </div>
+ <div>
+ <Checkbox v-model="hidePostStats">
+ {{ $t('settings.hide_post_stats') }} {{ $t('settings.instance_default', { value: hidePostStatsLocalizedValue }) }}
+ </Checkbox>
+ </div>
+ <div>
+ <Checkbox v-model="hideUserStats">
+ {{ $t('settings.hide_user_stats') }} {{ $t('settings.instance_default', { value: hideUserStatsLocalizedValue }) }}
+ </Checkbox>
+ </div>
+ </div>
+ <div class="setting-item">
+ <div>
+ <p>{{ $t('settings.filtering_explanation') }}</p>
+ <textarea
+ id="muteWords"
+ v-model="muteWordsString"
+ />
+ </div>
+ <div>
+ <Checkbox v-model="hideFilteredStatuses">
+ {{ $t('settings.hide_filtered_statuses') }} {{ $t('settings.instance_default', { value: hideFilteredStatusesLocalizedValue }) }}
+ </Checkbox>
+ </div>
+ </div>
+ </div>
+</template>
+<script src="./filtering_tab.js"></script>
diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js
new file mode 100644
index 00000000..0eb37e44
--- /dev/null
+++ b/src/components/settings_modal/tabs/general_tab.js
@@ -0,0 +1,31 @@
+import Checkbox from 'src/components/checkbox/checkbox.vue'
+import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
+
+import SharedComputedObject from '../helpers/shared_computed_object.js'
+
+const GeneralTab = {
+ data () {
+ return {
+ 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')
+ }
+ },
+ components: {
+ Checkbox,
+ InterfaceLanguageSwitcher
+ },
+ computed: {
+ postFormats () {
+ return this.$store.state.instance.postFormats || []
+ },
+ instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
+ ...SharedComputedObject()
+ }
+}
+
+export default GeneralTab
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
new file mode 100644
index 00000000..f89c0480
--- /dev/null
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -0,0 +1,272 @@
+<template>
+ <div :label="$t('settings.general')">
+ <div class="setting-item">
+ <h2>{{ $t('settings.interface') }}</h2>
+ <ul class="setting-list">
+ <li>
+ <interface-language-switcher />
+ </li>
+ <li v-if="instanceSpecificPanelPresent">
+ <Checkbox v-model="hideISP">
+ {{ $t('settings.hide_isp') }}
+ </Checkbox>
+ </li>
+ </ul>
+ </div>
+ <div class="setting-item">
+ <h2>{{ $t('nav.timeline') }}</h2>
+ <ul class="setting-list">
+ <li>
+ <Checkbox v-model="hideMutedPosts">
+ {{ $t('settings.hide_muted_posts') }} {{ $t('settings.instance_default', { value: hideMutedPostsLocalizedValue }) }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="collapseMessageWithSubject">
+ {{ $t('settings.collapse_subject') }} {{ $t('settings.instance_default', { value: collapseMessageWithSubjectLocalizedValue }) }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="streaming">
+ {{ $t('settings.streaming') }}
+ </Checkbox>
+ <ul
+ class="setting-list suboptions"
+ :class="[{disabled: !streaming}]"
+ >
+ <li>
+ <Checkbox
+ v-model="pauseOnUnfocused"
+ :disabled="!streaming"
+ >
+ {{ $t('settings.pause_on_unfocused') }}
+ </Checkbox>
+ </li>
+ </ul>
+ </li>
+ <li>
+ <Checkbox v-model="useStreamingApi">
+ {{ $t('settings.useStreamingApi') }}
+ <br>
+ <small>
+ {{ $t('settings.useStreamingApiWarning') }}
+ </small>
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="autoLoad">
+ {{ $t('settings.autoload') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="hoverPreview">
+ {{ $t('settings.reply_link_preview') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="emojiReactionsOnTimeline">
+ {{ $t('settings.emoji_reactions_on_timeline') }}
+ </Checkbox>
+ </li>
+ </ul>
+ </div>
+
+ <div class="setting-item">
+ <h2>{{ $t('settings.composing') }}</h2>
+ <ul class="setting-list">
+ <li>
+ <Checkbox v-model="scopeCopy">
+ {{ $t('settings.scope_copy') }} {{ $t('settings.instance_default', { value: scopeCopyLocalizedValue }) }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="alwaysShowSubjectInput">
+ {{ $t('settings.subject_input_always_show') }} {{ $t('settings.instance_default', { value: alwaysShowSubjectInputLocalizedValue }) }}
+ </Checkbox>
+ </li>
+ <li>
+ <div>
+ {{ $t('settings.subject_line_behavior') }}
+ <label
+ for="subjectLineBehavior"
+ class="select"
+ >
+ <select
+ id="subjectLineBehavior"
+ v-model="subjectLineBehavior"
+ >
+ <option value="email">
+ {{ $t('settings.subject_line_email') }}
+ {{ subjectLineBehaviorDefaultValue == 'email' ? $t('settings.instance_default_simple') : '' }}
+ </option>
+ <option value="masto">
+ {{ $t('settings.subject_line_mastodon') }}
+ {{ subjectLineBehaviorDefaultValue == 'mastodon' ? $t('settings.instance_default_simple') : '' }}
+ </option>
+ <option value="noop">
+ {{ $t('settings.subject_line_noop') }}
+ {{ subjectLineBehaviorDefaultValue == 'noop' ? $t('settings.instance_default_simple') : '' }}
+ </option>
+ </select>
+ <i class="icon-down-open" />
+ </label>
+ </div>
+ </li>
+ <li v-if="postFormats.length > 0">
+ <div>
+ {{ $t('settings.post_status_content_type') }}
+ <label
+ for="postContentType"
+ class="select"
+ >
+ <select
+ id="postContentType"
+ v-model="postContentType"
+ >
+ <option
+ v-for="postFormat in postFormats"
+ :key="postFormat"
+ :value="postFormat"
+ >
+ {{ $t(`post_status.content_type["${postFormat}"]`) }}
+ {{ postContentTypeDefaultValue === postFormat ? $t('settings.instance_default_simple') : '' }}
+ </option>
+ </select>
+ <i class="icon-down-open" />
+ </label>
+ </div>
+ </li>
+ <li>
+ <Checkbox v-model="minimalScopesMode">
+ {{ $t('settings.minimal_scopes_mode') }} {{ $t('settings.instance_default', { value: minimalScopesModeLocalizedValue }) }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="autohideFloatingPostButton">
+ {{ $t('settings.autohide_floating_post_button') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="padEmoji">
+ {{ $t('settings.pad_emoji') }}
+ </Checkbox>
+ </li>
+ </ul>
+ </div>
+
+ <div class="setting-item">
+ <h2>{{ $t('settings.attachments') }}</h2>
+ <ul class="setting-list">
+ <li>
+ <Checkbox v-model="hideAttachments">
+ {{ $t('settings.hide_attachments_in_tl') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="hideAttachmentsInConv">
+ {{ $t('settings.hide_attachments_in_convo') }}
+ </Checkbox>
+ </li>
+ <li>
+ <label for="maxThumbnails">
+ {{ $t('settings.max_thumbnails') }}
+ </label>
+ <input
+ id="maxThumbnails"
+ v-model.number="maxThumbnails"
+ class="number-input"
+ type="number"
+ min="0"
+ step="1"
+ >
+ </li>
+ <li>
+ <Checkbox v-model="hideNsfw">
+ {{ $t('settings.nsfw_clickthrough') }}
+ </Checkbox>
+ </li>
+ <ul class="setting-list suboptions">
+ <li>
+ <Checkbox
+ v-model="preloadImage"
+ :disabled="!hideNsfw"
+ >
+ {{ $t('settings.preload_images') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox
+ v-model="useOneClickNsfw"
+ :disabled="!hideNsfw"
+ >
+ {{ $t('settings.use_one_click_nsfw') }}
+ </Checkbox>
+ </li>
+ </ul>
+ <li>
+ <Checkbox v-model="stopGifs">
+ {{ $t('settings.stop_gifs') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="loopVideo">
+ {{ $t('settings.loop_video') }}
+ </Checkbox>
+ <ul
+ class="setting-list suboptions"
+ :class="[{disabled: !streaming}]"
+ >
+ <li>
+ <Checkbox
+ v-model="loopVideoSilentOnly"
+ :disabled="!loopVideo || !loopSilentAvailable"
+ >
+ {{ $t('settings.loop_video_silent_only') }}
+ </Checkbox>
+ <div
+ v-if="!loopSilentAvailable"
+ class="unavailable"
+ >
+ <i class="icon-globe" />! {{ $t('settings.limited_availability') }}
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li>
+ <Checkbox v-model="playVideosInModal">
+ {{ $t('settings.play_videos_in_modal') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="useContainFit">
+ {{ $t('settings.use_contain_fit') }}
+ </Checkbox>
+ </li>
+ </ul>
+ </div>
+
+ <div class="setting-item">
+ <h2>{{ $t('settings.notifications') }}</h2>
+ <ul class="setting-list">
+ <li>
+ <Checkbox v-model="webPushNotifications">
+ {{ $t('settings.enable_web_push_notifications') }}
+ </Checkbox>
+ </li>
+ </ul>
+ </div>
+
+ <div class="setting-item">
+ <h2>{{ $t('settings.fun') }}</h2>
+ <ul class="setting-list">
+ <li>
+ <Checkbox v-model="greentext">
+ {{ $t('settings.greentext') }} {{ $t('settings.instance_default', { value: greentextLocalizedValue }) }}
+ </Checkbox>
+ </li>
+ </ul>
+ </div>
+ </div>
+</template>
+
+<script src="./general_tab.js"></script>
diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.js b/src/components/settings_modal/tabs/mutes_and_blocks_tab.js
new file mode 100644
index 00000000..40a87b81
--- /dev/null
+++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.js
@@ -0,0 +1,136 @@
+import get from 'lodash/get'
+import map from 'lodash/map'
+import reject from 'lodash/reject'
+import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
+import BlockCard from 'src/components/block_card/block_card.vue'
+import MuteCard from 'src/components/mute_card/mute_card.vue'
+import DomainMuteCard from 'src/components/domain_mute_card/domain_mute_card.vue'
+import SelectableList from 'src/components/selectable_list/selectable_list.vue'
+import ProgressButton from 'src/components/progress_button/progress_button.vue'
+import withSubscription from 'src/components/../hocs/with_subscription/with_subscription'
+import Checkbox from 'src/components/checkbox/checkbox.vue'
+
+const BlockList = withSubscription({
+ fetch: (props, $store) => $store.dispatch('fetchBlocks'),
+ select: (props, $store) => get($store.state.users.currentUser, 'blockIds', []),
+ childPropName: 'items'
+})(SelectableList)
+
+const MuteList = withSubscription({
+ fetch: (props, $store) => $store.dispatch('fetchMutes'),
+ select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []),
+ childPropName: 'items'
+})(SelectableList)
+
+const DomainMuteList = withSubscription({
+ fetch: (props, $store) => $store.dispatch('fetchDomainMutes'),
+ select: (props, $store) => get($store.state.users.currentUser, 'domainMutes', []),
+ childPropName: 'items'
+})(SelectableList)
+
+const MutesAndBlocks = {
+ data () {
+ return {
+ activeTab: 'profile'
+ }
+ },
+ created () {
+ this.$store.dispatch('fetchTokens')
+ this.$store.dispatch('getKnownDomains')
+ },
+ components: {
+ TabSwitcher,
+ BlockList,
+ MuteList,
+ DomainMuteList,
+ BlockCard,
+ MuteCard,
+ DomainMuteCard,
+ ProgressButton,
+ Autosuggest,
+ Checkbox
+ },
+ computed: {
+ knownDomains () {
+ return this.$store.state.instance.knownDomains
+ },
+ user () {
+ return this.$store.state.users.currentUser
+ }
+ },
+ methods: {
+ importFollows (file) {
+ return this.$store.state.api.backendInteractor.importFollows({ file })
+ .then((status) => {
+ if (!status) {
+ throw new Error('failed')
+ }
+ })
+ },
+ importBlocks (file) {
+ return this.$store.state.api.backendInteractor.importBlocks({ 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
+ // eslint-disable-next-line no-undef
+ return user.screen_name + '@' + location.hostname
+ }
+ return user.screen_name
+ }).join('\n')
+ },
+ activateTab (tabName) {
+ this.activeTab = tabName
+ },
+ filterUnblockedUsers (userIds) {
+ return reject(userIds, (userId) => {
+ const relationship = this.$store.getters.relationship(this.userId)
+ return relationship.blocking || userId === this.user.id
+ })
+ },
+ filterUnMutedUsers (userIds) {
+ return reject(userIds, (userId) => {
+ const relationship = this.$store.getters.relationship(this.userId)
+ return relationship.muting || userId === this.user.id
+ })
+ },
+ queryUserIds (query) {
+ return this.$store.dispatch('searchUsers', { query })
+ .then((users) => map(users, 'id'))
+ },
+ blockUsers (ids) {
+ return this.$store.dispatch('blockUsers', ids)
+ },
+ unblockUsers (ids) {
+ return this.$store.dispatch('unblockUsers', ids)
+ },
+ muteUsers (ids) {
+ return this.$store.dispatch('muteUsers', ids)
+ },
+ unmuteUsers (ids) {
+ return this.$store.dispatch('unmuteUsers', ids)
+ },
+ filterUnMutedDomains (urls) {
+ return urls.filter(url => !this.user.domainMutes.includes(url))
+ },
+ queryKnownDomains (query) {
+ return new Promise((resolve, reject) => {
+ resolve(this.knownDomains.filter(url => url.toLowerCase().includes(query)))
+ })
+ },
+ unmuteDomains (domains) {
+ return this.$store.dispatch('unmuteDomains', domains)
+ }
+ }
+}
+
+export default MutesAndBlocks
diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss b/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss
new file mode 100644
index 00000000..ceb64efb
--- /dev/null
+++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss
@@ -0,0 +1,29 @@
+.mutes-and-blocks-tab {
+ height: 100%;
+
+ .usersearch-wrapper {
+ padding: 1em;
+ }
+
+ .bulk-actions {
+ text-align: right;
+ padding: 0 1em;
+ min-height: 28px;
+ }
+
+ .bulk-action-button {
+ width: 10em
+ }
+
+ .domain-mute-form {
+ padding: 1em;
+ display: flex;
+ flex-direction: column
+ }
+
+ .domain-mute-button {
+ align-self: flex-end;
+ margin-top: 1em;
+ width: 10em
+ }
+}
diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue
new file mode 100644
index 00000000..5a1cf2c0
--- /dev/null
+++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue
@@ -0,0 +1,171 @@
+<template>
+ <tab-switcher
+ :scrollable-tabs="true"
+ class="mutes-and-blocks-tab"
+ >
+ <div :label="$t('settings.blocks_tab')">
+ <div class="usersearch-wrapper">
+ <Autosuggest
+ :filter="filterUnblockedUsers"
+ :query="queryUserIds"
+ :placeholder="$t('settings.search_user_to_block')"
+ >
+ <BlockCard
+ slot-scope="row"
+ :user-id="row.item"
+ />
+ </Autosuggest>
+ </div>
+ <BlockList
+ :refresh="true"
+ :get-key="i => i"
+ >
+ <template
+ slot="header"
+ slot-scope="{selected}"
+ >
+ <div class="bulk-actions">
+ <ProgressButton
+ v-if="selected.length > 0"
+ class="btn btn-default bulk-action-button"
+ :click="() => blockUsers(selected)"
+ >
+ {{ $t('user_card.block') }}
+ <template slot="progress">
+ {{ $t('user_card.block_progress') }}
+ </template>
+ </ProgressButton>
+ <ProgressButton
+ v-if="selected.length > 0"
+ class="btn btn-default"
+ :click="() => unblockUsers(selected)"
+ >
+ {{ $t('user_card.unblock') }}
+ <template slot="progress">
+ {{ $t('user_card.unblock_progress') }}
+ </template>
+ </ProgressButton>
+ </div>
+ </template>
+ <template
+ slot="item"
+ slot-scope="{item}"
+ >
+ <BlockCard :user-id="item" />
+ </template>
+ <template slot="empty">
+ {{ $t('settings.no_blocks') }}
+ </template>
+ </BlockList>
+ </div>
+
+ <div :label="$t('settings.mutes_tab')">
+ <tab-switcher>
+ <div label="Users">
+ <div class="usersearch-wrapper">
+ <Autosuggest
+ :filter="filterUnMutedUsers"
+ :query="queryUserIds"
+ :placeholder="$t('settings.search_user_to_mute')"
+ >
+ <MuteCard
+ slot-scope="row"
+ :user-id="row.item"
+ />
+ </Autosuggest>
+ </div>
+ <MuteList
+ :refresh="true"
+ :get-key="i => i"
+ >
+ <template
+ slot="header"
+ slot-scope="{selected}"
+ >
+ <div class="bulk-actions">
+ <ProgressButton
+ v-if="selected.length > 0"
+ class="btn btn-default"
+ :click="() => muteUsers(selected)"
+ >
+ {{ $t('user_card.mute') }}
+ <template slot="progress">
+ {{ $t('user_card.mute_progress') }}
+ </template>
+ </ProgressButton>
+ <ProgressButton
+ v-if="selected.length > 0"
+ class="btn btn-default"
+ :click="() => unmuteUsers(selected)"
+ >
+ {{ $t('user_card.unmute') }}
+ <template slot="progress">
+ {{ $t('user_card.unmute_progress') }}
+ </template>
+ </ProgressButton>
+ </div>
+ </template>
+ <template
+ slot="item"
+ slot-scope="{item}"
+ >
+ <MuteCard :user-id="item" />
+ </template>
+ <template slot="empty">
+ {{ $t('settings.no_mutes') }}
+ </template>
+ </MuteList>
+ </div>
+
+ <div :label="$t('settings.domain_mutes')">
+ <div class="domain-mute-form">
+ <Autosuggest
+ :filter="filterUnMutedDomains"
+ :query="queryKnownDomains"
+ :placeholder="$t('settings.type_domains_to_mute')"
+ >
+ <DomainMuteCard
+ slot-scope="row"
+ :domain="row.item"
+ />
+ </Autosuggest>
+ </div>
+ <DomainMuteList
+ :refresh="true"
+ :get-key="i => i"
+ >
+ <template
+ slot="header"
+ slot-scope="{selected}"
+ >
+ <div class="bulk-actions">
+ <ProgressButton
+ v-if="selected.length > 0"
+ class="btn btn-default"
+ :click="() => unmuteDomains(selected)"
+ >
+ {{ $t('domain_mute_card.unmute') }}
+ <template slot="progress">
+ {{ $t('domain_mute_card.unmute_progress') }}
+ </template>
+ </ProgressButton>
+ </div>
+ </template>
+ <template
+ slot="item"
+ slot-scope="{item}"
+ >
+ <DomainMuteCard :domain="item" />
+ </template>
+ <template slot="empty">
+ {{ $t('settings.no_mutes') }}
+ </template>
+ </DomainMuteList>
+ </div>
+ </tab-switcher>
+ </div>
+ </tab-switcher>
+</template>
+
+<script src="./mutes_and_blocks_tab.js"></script>
+<style lang="scss" src="./mutes_and_blocks_tab.scss"></style>
diff --git a/src/components/settings_modal/tabs/notifications_tab.js b/src/components/settings_modal/tabs/notifications_tab.js
new file mode 100644
index 00000000..3e44c95d
--- /dev/null
+++ b/src/components/settings_modal/tabs/notifications_tab.js
@@ -0,0 +1,27 @@
+import Checkbox from 'src/components/checkbox/checkbox.vue'
+
+const NotificationsTab = {
+ data () {
+ return {
+ activeTab: 'profile',
+ notificationSettings: this.$store.state.users.currentUser.notification_settings,
+ newDomainToMute: ''
+ }
+ },
+ components: {
+ Checkbox
+ },
+ computed: {
+ user () {
+ return this.$store.state.users.currentUser
+ }
+ },
+ methods: {
+ updateNotificationSettings () {
+ this.$store.state.api.backendInteractor
+ .updateNotificationSettings({ settings: this.notificationSettings })
+ }
+ }
+}
+
+export default NotificationsTab
diff --git a/src/components/settings_modal/tabs/notifications_tab.vue b/src/components/settings_modal/tabs/notifications_tab.vue
new file mode 100644
index 00000000..b7a3cb37
--- /dev/null
+++ b/src/components/settings_modal/tabs/notifications_tab.vue
@@ -0,0 +1,54 @@
+<template>
+ <div :label="$t('settings.notifications')">
+ <div class="setting-item">
+ <h2>{{ $t('settings.notification_setting_filters') }}</h2>
+ <div class="select-multiple">
+ <span class="label">{{ $t('settings.notification_setting') }}</span>
+ <ul class="option-list">
+ <li>
+ <Checkbox v-model="notificationSettings.follows">
+ {{ $t('settings.notification_setting_follows') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="notificationSettings.followers">
+ {{ $t('settings.notification_setting_followers') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="notificationSettings.non_follows">
+ {{ $t('settings.notification_setting_non_follows') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="notificationSettings.non_followers">
+ {{ $t('settings.notification_setting_non_followers') }}
+ </Checkbox>
+ </li>
+ </ul>
+ </div>
+ </div>
+
+ <div class="setting-item">
+ <h2>{{ $t('settings.notification_setting_privacy') }}</h2>
+ <p>
+ <Checkbox v-model="notificationSettings.privacy_option">
+ {{ $t('settings.notification_setting_privacy_option') }}
+ </Checkbox>
+ </p>
+ </div>
+ <div class="setting-item">
+ <p>{{ $t('settings.notification_mutes') }}</p>
+ <p>{{ $t('settings.notification_blocks') }}</p>
+ <button
+ class="btn btn-default"
+ @click="updateNotificationSettings"
+ >
+ {{ $t('general.submit') }}
+ </button>
+ </div>
+ </div>
+</template>
+
+<script src="./notifications_tab.js"></script>
+<!-- <style lang="scss" src="./profile.scss"></style> -->
diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js
new file mode 100644
index 00000000..8658b097
--- /dev/null
+++ b/src/components/settings_modal/tabs/profile_tab.js
@@ -0,0 +1,179 @@
+import unescape from 'lodash/unescape'
+import ImageCropper from 'src/components/image_cropper/image_cropper.vue'
+import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
+import fileSizeFormatService from 'src/components/../services/file_size_format/file_size_format.js'
+import ProgressButton from 'src/components/progress_button/progress_button.vue'
+import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
+import suggestor from 'src/components/emoji_input/suggestor.js'
+import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
+import Checkbox from 'src/components/checkbox/checkbox.vue'
+
+const ProfileTab = {
+ data () {
+ return {
+ newName: this.$store.state.users.currentUser.name,
+ newBio: unescape(this.$store.state.users.currentUser.description),
+ newLocked: this.$store.state.users.currentUser.locked,
+ newNoRichText: this.$store.state.users.currentUser.no_rich_text,
+ newDefaultScope: this.$store.state.users.currentUser.default_scope,
+ hideFollows: this.$store.state.users.currentUser.hide_follows,
+ hideFollowers: this.$store.state.users.currentUser.hide_followers,
+ hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count,
+ hideFollowersCount: this.$store.state.users.currentUser.hide_followers_count,
+ showRole: this.$store.state.users.currentUser.show_role,
+ role: this.$store.state.users.currentUser.role,
+ discoverable: this.$store.state.users.currentUser.discoverable,
+ allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
+ pickAvatarBtnVisible: true,
+ bannerUploading: false,
+ backgroundUploading: false,
+ banner: null,
+ bannerPreview: null,
+ background: null,
+ backgroundPreview: null,
+ bannerUploadError: null,
+ backgroundUploadError: null
+ }
+ },
+ components: {
+ ScopeSelector,
+ ImageCropper,
+ EmojiInput,
+ Autosuggest,
+ ProgressButton,
+ Checkbox
+ },
+ computed: {
+ user () {
+ return this.$store.state.users.currentUser
+ },
+ emojiUserSuggestor () {
+ return suggestor({
+ emoji: [
+ ...this.$store.state.instance.emoji,
+ ...this.$store.state.instance.customEmoji
+ ],
+ users: this.$store.state.users.users,
+ updateUsersList: (query) => this.$store.dispatch('searchUsers', { query })
+ })
+ },
+ emojiSuggestor () {
+ return suggestor({ emoji: [
+ ...this.$store.state.instance.emoji,
+ ...this.$store.state.instance.customEmoji
+ ] })
+ }
+ },
+ methods: {
+ updateProfile () {
+ this.$store.state.api.backendInteractor
+ .updateProfile({
+ params: {
+ note: this.newBio,
+ locked: this.newLocked,
+ // Backend notation.
+ /* eslint-disable camelcase */
+ display_name: this.newName,
+ default_scope: this.newDefaultScope,
+ no_rich_text: this.newNoRichText,
+ hide_follows: this.hideFollows,
+ hide_followers: this.hideFollowers,
+ discoverable: this.discoverable,
+ allow_following_move: this.allowFollowingMove,
+ hide_follows_count: this.hideFollowsCount,
+ hide_followers_count: this.hideFollowersCount,
+ show_role: this.showRole
+ /* eslint-enable camelcase */
+ } }).then((user) => {
+ this.$store.commit('addNewUsers', [user])
+ this.$store.commit('setCurrentUser', user)
+ })
+ },
+ changeVis (visibility) {
+ this.newDefaultScope = visibility
+ },
+ uploadFile (slot, e) {
+ const file = e.target.files[0]
+ if (!file) { return }
+ if (file.size > this.$store.state.instance[slot + 'limit']) {
+ const filesize = fileSizeFormatService.fileSizeFormat(file.size)
+ const allowedsize = fileSizeFormatService.fileSizeFormat(this.$store.state.instance[slot + 'limit'])
+ this[slot + 'UploadError'] = [
+ this.$t('upload.error.base'),
+ this.$t(
+ 'upload.error.file_too_big',
+ {
+ filesize: filesize.num,
+ filesizeunit: filesize.unit,
+ allowedsize: allowedsize.num,
+ allowedsizeunit: allowedsize.unit
+ }
+ )
+ ].join(' ')
+ return
+ }
+ // eslint-disable-next-line no-undef
+ const reader = new FileReader()
+ reader.onload = ({ target }) => {
+ const img = target.result
+ this[slot + 'Preview'] = img
+ this[slot] = file
+ }
+ reader.readAsDataURL(file)
+ },
+ submitAvatar (cropper, file) {
+ const that = this
+ return new Promise((resolve, reject) => {
+ function updateAvatar (avatar) {
+ that.$store.state.api.backendInteractor.updateAvatar({ avatar })
+ .then((user) => {
+ that.$store.commit('addNewUsers', [user])
+ that.$store.commit('setCurrentUser', user)
+ resolve()
+ })
+ .catch((err) => {
+ reject(new Error(that.$t('upload.error.base') + ' ' + err.message))
+ })
+ }
+
+ if (cropper) {
+ cropper.getCroppedCanvas().toBlob(updateAvatar, file.type)
+ } else {
+ updateAvatar(file)
+ }
+ })
+ },
+ submitBanner () {
+ if (!this.bannerPreview) { return }
+
+ this.bannerUploading = true
+ this.$store.state.api.backendInteractor.updateBanner({ banner: this.banner })
+ .then((user) => {
+ this.$store.commit('addNewUsers', [user])
+ this.$store.commit('setCurrentUser', user)
+ this.bannerPreview = null
+ })
+ .catch((err) => {
+ this.bannerUploadError = this.$t('upload.error.base') + ' ' + err.message
+ })
+ .then(() => { this.bannerUploading = false })
+ },
+ submitBg () {
+ if (!this.backgroundPreview) { return }
+ let background = this.background
+ this.backgroundUploading = true
+ this.$store.state.api.backendInteractor.updateBg({ background }).then((data) => {
+ if (!data.error) {
+ this.$store.commit('addNewUsers', [data])
+ this.$store.commit('setCurrentUser', data)
+ this.backgroundPreview = null
+ } else {
+ this.backgroundUploadError = this.$t('upload.error.base') + data.error
+ }
+ this.backgroundUploading = false
+ })
+ }
+ }
+}
+
+export default ProfileTab
diff --git a/src/components/settings_modal/tabs/profile_tab.scss b/src/components/settings_modal/tabs/profile_tab.scss
new file mode 100644
index 00000000..4aab81eb
--- /dev/null
+++ b/src/components/settings_modal/tabs/profile_tab.scss
@@ -0,0 +1,82 @@
+@import '../../../_variables.scss';
+.profile-tab {
+ .bio {
+ margin: 0;
+ }
+
+ .visibility-tray {
+ padding-top: 5px;
+ }
+
+ input[type=file] {
+ padding: 5px;
+ height: auto;
+ }
+
+ .banner {
+ max-width: 100%;
+ }
+
+ .uploading {
+ font-size: 1.5em;
+ margin: 0.25em;
+ }
+
+ .name-changer {
+ width: 100%;
+ }
+
+ .bg {
+ max-width: 100%;
+ }
+
+ .current-avatar {
+ display: block;
+ width: 150px;
+ height: 150px;
+ border-radius: $fallback--avatarRadius;
+ border-radius: var(--avatarRadius, $fallback--avatarRadius);
+ }
+
+ .oauth-tokens {
+ width: 100%;
+
+ th {
+ text-align: left;
+ }
+
+ .actions {
+ text-align: right;
+ }
+ }
+
+ &-usersearch-wrapper {
+ padding: 1em;
+ }
+
+ &-bulk-actions {
+ text-align: right;
+ padding: 0 1em;
+ min-height: 28px;
+
+ button {
+ width: 10em;
+ }
+ }
+
+ &-domain-mute-form {
+ padding: 1em;
+ display: flex;
+ flex-direction: column;
+
+ button {
+ align-self: flex-end;
+ margin-top: 1em;
+ width: 10em;
+ }
+ }
+
+ .setting-subitem {
+ margin-left: 1.75em;
+ }
+}
diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue
new file mode 100644
index 00000000..fff4f970
--- /dev/null
+++ b/src/components/settings_modal/tabs/profile_tab.vue
@@ -0,0 +1,213 @@
+<template>
+ <div class="profile-tab">
+ <div class="setting-item">
+ <h2>{{ $t('settings.name_bio') }}</h2>
+ <p>{{ $t('settings.name') }}</p>
+ <EmojiInput
+ v-model="newName"
+ enable-emoji-picker
+ :suggest="emojiSuggestor"
+ >
+ <input
+ id="username"
+ v-model="newName"
+ classname="name-changer"
+ >
+ </EmojiInput>
+ <p>{{ $t('settings.bio') }}</p>
+ <EmojiInput
+ v-model="newBio"
+ enable-emoji-picker
+ :suggest="emojiUserSuggestor"
+ >
+ <textarea
+ v-model="newBio"
+ classname="bio"
+ />
+ </EmojiInput>
+ <p>
+ <Checkbox v-model="newLocked">
+ {{ $t('settings.lock_account_description') }}
+ </Checkbox>
+ </p>
+ <div>
+ <label for="default-vis">{{ $t('settings.default_vis') }}</label>
+ <div
+ id="default-vis"
+ class="visibility-tray"
+ >
+ <scope-selector
+ :show-all="true"
+ :user-default="newDefaultScope"
+ :initial-scope="newDefaultScope"
+ :on-scope-change="changeVis"
+ />
+ </div>
+ </div>
+ <p>
+ <Checkbox v-model="newNoRichText">
+ {{ $t('settings.no_rich_text_description') }}
+ </Checkbox>
+ </p>
+ <p>
+ <Checkbox v-model="hideFollows">
+ {{ $t('settings.hide_follows_description') }}
+ </Checkbox>
+ </p>
+ <p class="setting-subitem">
+ <Checkbox
+ v-model="hideFollowsCount"
+ :disabled="!hideFollows"
+ >
+ {{ $t('settings.hide_follows_count_description') }}
+ </Checkbox>
+ </p>
+ <p>
+ <Checkbox v-model="hideFollowers">
+ {{ $t('settings.hide_followers_description') }}
+ </Checkbox>
+ </p>
+ <p class="setting-subitem">
+ <Checkbox
+ v-model="hideFollowersCount"
+ :disabled="!hideFollowers"
+ >
+ {{ $t('settings.hide_followers_count_description') }}
+ </Checkbox>
+ </p>
+ <p>
+ <Checkbox v-model="allowFollowingMove">
+ {{ $t('settings.allow_following_move') }}
+ </Checkbox>
+ </p>
+ <p v-if="role === 'admin' || role === 'moderator'">
+ <Checkbox v-model="showRole">
+ <template v-if="role === 'admin'">
+ {{ $t('settings.show_admin_badge') }}
+ </template>
+ <template v-if="role === 'moderator'">
+ {{ $t('settings.show_moderator_badge') }}
+ </template>
+ </Checkbox>
+ </p>
+ <p>
+ <Checkbox v-model="discoverable">
+ {{ $t('settings.discoverable') }}
+ </Checkbox>
+ </p>
+ <button
+ :disabled="newName && newName.length === 0"
+ class="btn btn-default"
+ @click="updateProfile"
+ >
+ {{ $t('general.submit') }}
+ </button>
+ </div>
+ <div class="setting-item">
+ <h2>{{ $t('settings.avatar') }}</h2>
+ <p class="visibility-notice">
+ {{ $t('settings.avatar_size_instruction') }}
+ </p>
+ <p>{{ $t('settings.current_avatar') }}</p>
+ <img
+ :src="user.profile_image_url_original"
+ class="current-avatar"
+ >
+ <p>{{ $t('settings.set_new_avatar') }}</p>
+ <button
+ v-show="pickAvatarBtnVisible"
+ id="pick-avatar"
+ class="btn"
+ type="button"
+ >
+ {{ $t('settings.upload_a_photo') }}
+ </button>
+ <image-cropper
+ trigger="#pick-avatar"
+ :submit-handler="submitAvatar"
+ @open="pickAvatarBtnVisible=false"
+ @close="pickAvatarBtnVisible=true"
+ />
+ </div>
+ <div class="setting-item">
+ <h2>{{ $t('settings.profile_banner') }}</h2>
+ <p>{{ $t('settings.current_profile_banner') }}</p>
+ <img
+ :src="user.cover_photo"
+ class="banner"
+ >
+ <p>{{ $t('settings.set_new_profile_banner') }}</p>
+ <img
+ v-if="bannerPreview"
+ class="banner"
+ :src="bannerPreview"
+ >
+ <div>
+ <input
+ type="file"
+ @change="uploadFile('banner', $event)"
+ >
+ </div>
+ <i
+ v-if="bannerUploading"
+ class=" icon-spin4 animate-spin uploading"
+ />
+ <button
+ v-else-if="bannerPreview"
+ class="btn btn-default"
+ @click="submitBanner"
+ >
+ {{ $t('general.submit') }}
+ </button>
+ <div
+ v-if="bannerUploadError"
+ class="alert error"
+ >
+ Error: {{ bannerUploadError }}
+ <i
+ class="button-icon icon-cancel"
+ @click="clearUploadError('banner')"
+ />
+ </div>
+ </div>
+ <div class="setting-item">
+ <h2>{{ $t('settings.profile_background') }}</h2>
+ <p>{{ $t('settings.set_new_profile_background') }}</p>
+ <img
+ v-if="backgroundPreview"
+ class="bg"
+ :src="backgroundPreview"
+ >
+ <div>
+ <input
+ type="file"
+ @change="uploadFile('background', $event)"
+ >
+ </div>
+ <i
+ v-if="backgroundUploading"
+ class=" icon-spin4 animate-spin uploading"
+ />
+ <button
+ v-else-if="backgroundPreview"
+ class="btn btn-default"
+ @click="submitBg"
+ >
+ {{ $t('general.submit') }}
+ </button>
+ <div
+ v-if="backgroundUploadError"
+ class="alert error"
+ >
+ Error: {{ backgroundUploadError }}
+ <i
+ class="button-icon icon-cancel"
+ @click="clearUploadError('background')"
+ />
+ </div>
+ </div>
+ </div>
+</template>
+
+<script src="./profile_tab.js"></script>
+<style lang="scss" src="./profile_tab.scss"></style>
diff --git a/src/components/settings_modal/tabs/security_tab/confirm.js b/src/components/settings_modal/tabs/security_tab/confirm.js
new file mode 100644
index 00000000..0f4ddfc9
--- /dev/null
+++ b/src/components/settings_modal/tabs/security_tab/confirm.js
@@ -0,0 +1,9 @@
+const Confirm = {
+ props: ['disabled'],
+ data: () => ({}),
+ methods: {
+ confirm () { this.$emit('confirm') },
+ cancel () { this.$emit('cancel') }
+ }
+}
+export default Confirm
diff --git a/src/components/settings_modal/tabs/security_tab/confirm.vue b/src/components/settings_modal/tabs/security_tab/confirm.vue
new file mode 100644
index 00000000..69b3811b
--- /dev/null
+++ b/src/components/settings_modal/tabs/security_tab/confirm.vue
@@ -0,0 +1,22 @@
+<template>
+ <div>
+ <slot />
+ <button
+ class="btn btn-default"
+ :disabled="disabled"
+ @click="confirm"
+ >
+ {{ $t('general.confirm') }}
+ </button>
+ <button
+ class="btn btn-default"
+ :disabled="disabled"
+ @click="cancel"
+ >
+ {{ $t('general.cancel') }}
+ </button>
+ </div>
+</template>
+
+<script src="./confirm.js">
+</script>
diff --git a/src/components/settings_modal/tabs/security_tab/mfa.js b/src/components/settings_modal/tabs/security_tab/mfa.js
new file mode 100644
index 00000000..abf37062
--- /dev/null
+++ b/src/components/settings_modal/tabs/security_tab/mfa.js
@@ -0,0 +1,155 @@
+import RecoveryCodes from './mfa_backup_codes.vue'
+import TOTP from './mfa_totp.vue'
+import Confirm from './confirm.vue'
+import VueQrcode from '@chenfengyuan/vue-qrcode'
+import { mapState } from 'vuex'
+
+const Mfa = {
+ data: () => ({
+ settings: { // current settings of MFA
+ available: false,
+ enabled: false,
+ totp: false
+ },
+ setupState: { // setup mfa
+ state: '', // state of setup. '' -> 'getBackupCodes' -> 'setupOTP' -> 'complete'
+ setupOTPState: '' // state of setup otp. '' -> 'prepare' -> 'confirm' -> 'complete'
+ },
+ backupCodes: {
+ getNewCodes: false,
+ inProgress: false, // progress of fetch codes
+ codes: []
+ },
+ otpSettings: { // pre-setup setting of OTP. secret key, qrcode url.
+ provisioning_uri: '',
+ key: ''
+ },
+ currentPassword: null,
+ otpConfirmToken: null,
+ error: null,
+ readyInit: false
+ }),
+ components: {
+ 'recovery-codes': RecoveryCodes,
+ 'totp-item': TOTP,
+ 'qrcode': VueQrcode,
+ 'confirm': Confirm
+ },
+ computed: {
+ canSetupOTP () {
+ return (
+ (this.setupInProgress && this.backupCodesPrepared) ||
+ this.settings.enabled
+ ) && !this.settings.totp && !this.setupOTPInProgress
+ },
+ setupInProgress () {
+ return this.setupState.state !== '' && this.setupState.state !== 'complete'
+ },
+ setupOTPInProgress () {
+ return this.setupState.state === 'setupOTP' && !this.completedOTP
+ },
+ prepareOTP () {
+ return this.setupState.setupOTPState === 'prepare'
+ },
+ confirmOTP () {
+ return this.setupState.setupOTPState === 'confirm'
+ },
+ completedOTP () {
+ return this.setupState.setupOTPState === 'completed'
+ },
+ backupCodesPrepared () {
+ return !this.backupCodes.inProgress && this.backupCodes.codes.length > 0
+ },
+ confirmNewBackupCodes () {
+ return this.backupCodes.getNewCodes
+ },
+ ...mapState({
+ backendInteractor: (state) => state.api.backendInteractor
+ })
+ },
+
+ methods: {
+ activateOTP () {
+ if (!this.settings.enabled) {
+ this.setupState.state = 'getBackupcodes'
+ this.fetchBackupCodes()
+ }
+ },
+ fetchBackupCodes () {
+ this.backupCodes.inProgress = true
+ this.backupCodes.codes = []
+
+ return this.backendInteractor.generateMfaBackupCodes()
+ .then((res) => {
+ this.backupCodes.codes = res.codes
+ this.backupCodes.inProgress = false
+ })
+ },
+ getBackupCodes () { // get a new backup codes
+ this.backupCodes.getNewCodes = true
+ },
+ confirmBackupCodes () { // confirm getting new backup codes
+ this.fetchBackupCodes().then((res) => {
+ this.backupCodes.getNewCodes = false
+ })
+ },
+ cancelBackupCodes () { // cancel confirm form of new backup codes
+ this.backupCodes.getNewCodes = false
+ },
+
+ // Setup OTP
+ setupOTP () { // prepare setup OTP
+ this.setupState.state = 'setupOTP'
+ this.setupState.setupOTPState = 'prepare'
+ this.backendInteractor.mfaSetupOTP()
+ .then((res) => {
+ this.otpSettings = res
+ this.setupState.setupOTPState = 'confirm'
+ })
+ },
+ doConfirmOTP () { // handler confirm enable OTP
+ this.error = null
+ this.backendInteractor.mfaConfirmOTP({
+ token: this.otpConfirmToken,
+ password: this.currentPassword
+ })
+ .then((res) => {
+ if (res.error) {
+ this.error = res.error
+ return
+ }
+ this.completeSetup()
+ })
+ },
+
+ completeSetup () {
+ this.setupState.setupOTPState = 'complete'
+ this.setupState.state = 'complete'
+ this.currentPassword = null
+ this.error = null
+ this.fetchSettings()
+ },
+ cancelSetup () { // cancel setup
+ this.setupState.setupOTPState = ''
+ this.setupState.state = ''
+ this.currentPassword = null
+ this.error = null
+ },
+ // end Setup OTP
+
+ // fetch settings from server
+ async fetchSettings () {
+ let result = await this.backendInteractor.settingsMFA()
+ if (result.error) return
+ this.settings = result.settings
+ this.settings.available = true
+ return result
+ }
+ },
+ mounted () {
+ this.fetchSettings().then(() => {
+ this.readyInit = true
+ })
+ }
+}
+export default Mfa
diff --git a/src/components/settings_modal/tabs/security_tab/mfa.vue b/src/components/settings_modal/tabs/security_tab/mfa.vue
new file mode 100644
index 00000000..7aca3c8d
--- /dev/null
+++ b/src/components/settings_modal/tabs/security_tab/mfa.vue
@@ -0,0 +1,173 @@
+<template>
+ <div
+ v-if="readyInit && settings.available"
+ class="setting-item mfa-settings"
+ >
+ <div class="mfa-heading">
+ <h2>{{ $t('settings.mfa.title') }}</h2>
+ </div>
+
+ <div>
+ <div
+ v-if="!setupInProgress"
+ class="setting-item"
+ >
+ <!-- Enabled methods -->
+ <h3>{{ $t('settings.mfa.authentication_methods') }}</h3>
+ <totp-item
+ :settings="settings"
+ @deactivate="fetchSettings"
+ @activate="activateOTP"
+ />
+ <br>
+
+ <div v-if="settings.enabled">
+ <!-- backup codes block-->
+ <recovery-codes
+ v-if="!confirmNewBackupCodes"
+ :backup-codes="backupCodes"
+ />
+ <button
+ v-if="!confirmNewBackupCodes"
+ class="btn btn-default"
+ @click="getBackupCodes"
+ >
+ {{ $t('settings.mfa.generate_new_recovery_codes') }}
+ </button>
+
+ <div v-if="confirmNewBackupCodes">
+ <confirm
+ :disabled="backupCodes.inProgress"
+ @confirm="confirmBackupCodes"
+ @cancel="cancelBackupCodes"
+ >
+ <p class="warning">
+ {{ $t('settings.mfa.warning_of_generate_new_codes') }}
+ </p>
+ </confirm>
+ </div>
+ </div>
+ </div>
+
+ <div v-if="setupInProgress">
+ <!-- setup block-->
+
+ <h3>{{ $t('settings.mfa.setup_otp') }}</h3>
+
+ <recovery-codes
+ v-if="!setupOTPInProgress"
+ :backup-codes="backupCodes"
+ />
+
+ <button
+ v-if="canSetupOTP"
+ class="btn btn-default"
+ @click="cancelSetup"
+ >
+ {{ $t('general.cancel') }}
+ </button>
+
+ <button
+ v-if="canSetupOTP"
+ class="btn btn-default"
+ @click="setupOTP"
+ >
+ {{ $t('settings.mfa.setup_otp') }}
+ </button>
+
+ <template v-if="setupOTPInProgress">
+ <i v-if="prepareOTP">{{ $t('settings.mfa.wait_pre_setup_otp') }}</i>
+
+ <div v-if="confirmOTP">
+ <div class="setup-otp">
+ <div class="qr-code">
+ <h4>{{ $t('settings.mfa.scan.title') }}</h4>
+ <p>{{ $t('settings.mfa.scan.desc') }}</p>
+ <qrcode
+ :value="otpSettings.provisioning_uri"
+ :options="{ width: 200 }"
+ />
+ <p>
+ {{ $t('settings.mfa.scan.secret_code') }}:
+ {{ otpSettings.key }}
+ </p>
+ </div>
+
+ <div class="verify">
+ <h4>{{ $t('general.verify') }}</h4>
+ <p>{{ $t('settings.mfa.verify.desc') }}</p>
+ <input
+ v-model="otpConfirmToken"
+ type="text"
+ >
+
+ <p>{{ $t('settings.enter_current_password_to_confirm') }}:</p>
+ <input
+ v-model="currentPassword"
+ type="password"
+ >
+ <div class="confirm-otp-actions">
+ <button
+ class="btn btn-default"
+ @click="doConfirmOTP"
+ >
+ {{ $t('settings.mfa.confirm_and_enable') }}
+ </button>
+ <button
+ class="btn btn-default"
+ @click="cancelSetup"
+ >
+ {{ $t('general.cancel') }}
+ </button>
+ </div>
+ <div
+ v-if="error"
+ class="alert error"
+ >
+ {{ error }}
+ </div>
+ </div>
+ </div>
+ </div>
+ </template>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script src="./mfa.js"></script>
+<style lang="scss">
+@import '../../../../_variables.scss';
+.mfa-settings {
+ .mfa-heading, .method-item {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: baseline;
+ }
+
+ .warning {
+ color: $fallback--cOrange;
+ color: var(--cOrange, $fallback--cOrange);
+ }
+
+ .setup-otp {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+ .qr-code {
+ flex: 1;
+ padding-right: 10px;
+ }
+ .verify { flex: 1; }
+ .error { margin: 4px 0 0 0; }
+ .confirm-otp-actions {
+ button {
+ width: 15em;
+ margin-top: 5px;
+ }
+
+ }
+ }
+}
+</style>
diff --git a/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.js b/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.js
new file mode 100644
index 00000000..f0a984ec
--- /dev/null
+++ b/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.js
@@ -0,0 +1,17 @@
+export default {
+ props: {
+ backupCodes: {
+ type: Object,
+ default: () => ({
+ inProgress: false,
+ codes: []
+ })
+ }
+ },
+ data: () => ({}),
+ computed: {
+ inProgress () { return this.backupCodes.inProgress },
+ ready () { return this.backupCodes.codes.length > 0 },
+ displayTitle () { return this.inProgress || this.ready }
+ }
+}
diff --git a/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.vue b/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.vue
new file mode 100644
index 00000000..d7e98b3c
--- /dev/null
+++ b/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.vue
@@ -0,0 +1,35 @@
+<template>
+ <div class="mfa-backup-codes">
+ <h4 v-if="displayTitle">
+ {{ $t('settings.mfa.recovery_codes') }}
+ </h4>
+ <i v-if="inProgress">{{ $t('settings.mfa.waiting_a_recovery_codes') }}</i>
+ <template v-if="ready">
+ <p class="alert warning">
+ {{ $t('settings.mfa.recovery_codes_warning') }}
+ </p>
+ <ul class="backup-codes">
+ <li
+ v-for="code in backupCodes.codes"
+ :key="code"
+ >
+ {{ code }}
+ </li>
+ </ul>
+ </template>
+ </div>
+</template>
+<script src="./mfa_backup_codes.js"></script>
+<style lang="scss">
+@import '../../../../_variables.scss';
+
+.mfa-backup-codes {
+ .warning {
+ color: $fallback--cOrange;
+ color: var(--cOrange, $fallback--cOrange);
+ }
+ .backup-codes {
+ font-family: var(--postCodeFont, monospace);
+ }
+}
+</style>
diff --git a/src/components/settings_modal/tabs/security_tab/mfa_totp.js b/src/components/settings_modal/tabs/security_tab/mfa_totp.js
new file mode 100644
index 00000000..8408d8e9
--- /dev/null
+++ b/src/components/settings_modal/tabs/security_tab/mfa_totp.js
@@ -0,0 +1,49 @@
+import Confirm from './confirm.vue'
+import { mapState } from 'vuex'
+
+export default {
+ props: ['settings'],
+ data: () => ({
+ error: false,
+ currentPassword: '',
+ deactivate: false,
+ inProgress: false // progress peform request to disable otp method
+ }),
+ components: {
+ 'confirm': Confirm
+ },
+ computed: {
+ isActivated () {
+ return this.settings.totp
+ },
+ ...mapState({
+ backendInteractor: (state) => state.api.backendInteractor
+ })
+ },
+ methods: {
+ doActivate () {
+ this.$emit('activate')
+ },
+ cancelDeactivate () { this.deactivate = false },
+ doDeactivate () {
+ this.error = null
+ this.deactivate = true
+ },
+ confirmDeactivate () { // confirm deactivate TOTP method
+ this.error = null
+ this.inProgress = true
+ this.backendInteractor.mfaDisableOTP({
+ password: this.currentPassword
+ })
+ .then((res) => {
+ this.inProgress = false
+ if (res.error) {
+ this.error = res.error
+ return
+ }
+ this.deactivate = false
+ this.$emit('deactivate')
+ })
+ }
+ }
+}
diff --git a/src/components/settings_modal/tabs/security_tab/mfa_totp.vue b/src/components/settings_modal/tabs/security_tab/mfa_totp.vue
new file mode 100644
index 00000000..c6f2cc7b
--- /dev/null
+++ b/src/components/settings_modal/tabs/security_tab/mfa_totp.vue
@@ -0,0 +1,43 @@
+<template>
+ <div>
+ <div class="method-item">
+ <strong>{{ $t('settings.mfa.otp') }}</strong>
+ <button
+ v-if="!isActivated"
+ class="btn btn-default"
+ @click="doActivate"
+ >
+ {{ $t('general.enable') }}
+ </button>
+
+ <button
+ v-if="isActivated"
+ class="btn btn-default"
+ :disabled="deactivate"
+ @click="doDeactivate"
+ >
+ {{ $t('general.disable') }}
+ </button>
+ </div>
+
+ <confirm
+ v-if="deactivate"
+ :disabled="inProgress"
+ @confirm="confirmDeactivate"
+ @cancel="cancelDeactivate"
+ >
+ {{ $t('settings.enter_current_password_to_confirm') }}:
+ <input
+ v-model="currentPassword"
+ type="password"
+ >
+ </confirm>
+ <div
+ v-if="error"
+ class="alert error"
+ >
+ {{ error }}
+ </div>
+ </div>
+</template>
+<script src="./mfa_totp.js"></script>
diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.js b/src/components/settings_modal/tabs/security_tab/security_tab.js
new file mode 100644
index 00000000..811161a5
--- /dev/null
+++ b/src/components/settings_modal/tabs/security_tab/security_tab.js
@@ -0,0 +1,106 @@
+import ProgressButton from 'src/components/progress_button/progress_button.vue'
+import Checkbox from 'src/components/checkbox/checkbox.vue'
+import Mfa from './mfa.vue'
+
+const SecurityTab = {
+ data () {
+ return {
+ newEmail: '',
+ changeEmailError: false,
+ changeEmailPassword: '',
+ changedEmail: false,
+ deletingAccount: false,
+ deleteAccountConfirmPasswordInput: '',
+ deleteAccountError: false,
+ changePasswordInputs: [ '', '', '' ],
+ changedPassword: false,
+ changePasswordError: false
+ }
+ },
+ created () {
+ this.$store.dispatch('fetchTokens')
+ },
+ components: {
+ ProgressButton,
+ Mfa,
+ Checkbox
+ },
+ computed: {
+ user () {
+ return this.$store.state.users.currentUser
+ },
+ pleromaBackend () {
+ return this.$store.state.instance.pleromaBackend
+ },
+ oauthTokens () {
+ return this.$store.state.oauthTokens.tokens.map(oauthToken => {
+ return {
+ id: oauthToken.id,
+ appName: oauthToken.app_name,
+ validUntil: new Date(oauthToken.valid_until).toLocaleDateString()
+ }
+ })
+ }
+ },
+ methods: {
+ confirmDelete () {
+ this.deletingAccount = true
+ },
+ deleteAccount () {
+ this.$store.state.api.backendInteractor.deleteAccount({ password: this.deleteAccountConfirmPasswordInput })
+ .then((res) => {
+ if (res.status === 'success') {
+ this.$store.dispatch('logout')
+ this.$router.push({ name: 'root' })
+ } else {
+ this.deleteAccountError = res.error
+ }
+ })
+ },
+ changePassword () {
+ const params = {
+ password: this.changePasswordInputs[0],
+ newPassword: this.changePasswordInputs[1],
+ newPasswordConfirmation: this.changePasswordInputs[2]
+ }
+ this.$store.state.api.backendInteractor.changePassword(params)
+ .then((res) => {
+ if (res.status === 'success') {
+ this.changedPassword = true
+ this.changePasswordError = false
+ this.logout()
+ } else {
+ this.changedPassword = false
+ this.changePasswordError = res.error
+ }
+ })
+ },
+ changeEmail () {
+ const params = {
+ email: this.newEmail,
+ password: this.changeEmailPassword
+ }
+ this.$store.state.api.backendInteractor.changeEmail(params)
+ .then((res) => {
+ if (res.status === 'success') {
+ this.changedEmail = true
+ this.changeEmailError = false
+ } else {
+ this.changedEmail = false
+ this.changeEmailError = res.error
+ }
+ })
+ },
+ logout () {
+ this.$store.dispatch('logout')
+ this.$router.replace('/')
+ },
+ revokeToken (id) {
+ if (window.confirm(`${this.$i18n.t('settings.revoke_token')}?`)) {
+ this.$store.dispatch('revokeToken', id)
+ }
+ }
+ }
+}
+
+export default SecurityTab
diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.vue b/src/components/settings_modal/tabs/security_tab/security_tab.vue
new file mode 100644
index 00000000..3d32d73d
--- /dev/null
+++ b/src/components/settings_modal/tabs/security_tab/security_tab.vue
@@ -0,0 +1,143 @@
+<template>
+ <div :label="$t('settings.security_tab')">
+ <div class="setting-item">
+ <h2>{{ $t('settings.change_email') }}</h2>
+ <div>
+ <p>{{ $t('settings.new_email') }}</p>
+ <input
+ v-model="newEmail"
+ type="email"
+ autocomplete="email"
+ >
+ </div>
+ <div>
+ <p>{{ $t('settings.current_password') }}</p>
+ <input
+ v-model="changeEmailPassword"
+ type="password"
+ autocomplete="current-password"
+ >
+ </div>
+ <button
+ class="btn btn-default"
+ @click="changeEmail"
+ >
+ {{ $t('general.submit') }}
+ </button>
+ <p v-if="changedEmail">
+ {{ $t('settings.changed_email') }}
+ </p>
+ <template v-if="changeEmailError !== false">
+ <p>{{ $t('settings.change_email_error') }}</p>
+ <p>{{ changeEmailError }}</p>
+ </template>
+ </div>
+
+ <div class="setting-item">
+ <h2>{{ $t('settings.change_password') }}</h2>
+ <div>
+ <p>{{ $t('settings.current_password') }}</p>
+ <input
+ v-model="changePasswordInputs[0]"
+ type="password"
+ >
+ </div>
+ <div>
+ <p>{{ $t('settings.new_password') }}</p>
+ <input
+ v-model="changePasswordInputs[1]"
+ type="password"
+ >
+ </div>
+ <div>
+ <p>{{ $t('settings.confirm_new_password') }}</p>
+ <input
+ v-model="changePasswordInputs[2]"
+ type="password"
+ >
+ </div>
+ <button
+ class="btn btn-default"
+ @click="changePassword"
+ >
+ {{ $t('general.submit') }}
+ </button>
+ <p v-if="changedPassword">
+ {{ $t('settings.changed_password') }}
+ </p>
+ <p v-else-if="changePasswordError !== false">
+ {{ $t('settings.change_password_error') }}
+ </p>
+ <p v-if="changePasswordError">
+ {{ changePasswordError }}
+ </p>
+ </div>
+
+ <div class="setting-item">
+ <h2>{{ $t('settings.oauth_tokens') }}</h2>
+ <table class="oauth-tokens">
+ <thead>
+ <tr>
+ <th>{{ $t('settings.app_name') }}</th>
+ <th>{{ $t('settings.valid_until') }}</th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ <tr
+ v-for="oauthToken in oauthTokens"
+ :key="oauthToken.id"
+ >
+ <td>{{ oauthToken.appName }}</td>
+ <td>{{ oauthToken.validUntil }}</td>
+ <td class="actions">
+ <button
+ class="btn btn-default"
+ @click="revokeToken(oauthToken.id)"
+ >
+ {{ $t('settings.revoke_token') }}
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ <mfa />
+ <div class="setting-item">
+ <h2>{{ $t('settings.delete_account') }}</h2>
+ <p v-if="!deletingAccount">
+ {{ $t('settings.delete_account_description') }}
+ </p>
+ <div v-if="deletingAccount">
+ <p>{{ $t('settings.delete_account_instructions') }}</p>
+ <p>{{ $t('login.password') }}</p>
+ <input
+ v-model="deleteAccountConfirmPasswordInput"
+ type="password"
+ >
+ <button
+ class="btn btn-default"
+ @click="deleteAccount"
+ >
+ {{ $t('settings.delete_account') }}
+ </button>
+ </div>
+ <p v-if="deleteAccountError !== false">
+ {{ $t('settings.delete_account_error') }}
+ </p>
+ <p v-if="deleteAccountError">
+ {{ deleteAccountError }}
+ </p>
+ <button
+ v-if="!deletingAccount"
+ class="btn btn-default"
+ @click="confirmDelete"
+ >
+ {{ $t('general.submit') }}
+ </button>
+ </div>
+ </div>
+</template>
+
+<script src="./security_tab.js"></script>
+<!-- <style lang="scss" src="./profile.scss"></style> -->
diff --git a/src/components/settings_modal/tabs/theme_tab/preview.vue b/src/components/settings_modal/tabs/theme_tab/preview.vue
new file mode 100644
index 00000000..9d984659
--- /dev/null
+++ b/src/components/settings_modal/tabs/theme_tab/preview.vue
@@ -0,0 +1,117 @@
+<template>
+ <div class="preview-container">
+ <div class="underlay underlay-preview" />
+ <div class="panel dummy">
+ <div class="panel-heading">
+ <div class="title">
+ {{ $t('settings.style.preview.header') }}
+ <span class="badge badge-notification">
+ 99
+ </span>
+ </div>
+ <span class="faint">
+ {{ $t('settings.style.preview.header_faint') }}
+ </span>
+ <span class="alert error">
+ {{ $t('settings.style.preview.error') }}
+ </span>
+ <button class="btn">
+ {{ $t('settings.style.preview.button') }}
+ </button>
+ </div>
+ <div class="panel-body theme-preview-content">
+ <div class="post">
+ <div class="avatar still-image">
+ ( ͡° ͜ʖ ͡°)
+ </div>
+ <div class="content">
+ <h4>
+ {{ $t('settings.style.preview.content') }}
+ </h4>
+
+ <i18n path="settings.style.preview.text">
+ <code style="font-family: var(--postCodeFont)">
+ {{ $t('settings.style.preview.mono') }}
+ </code>
+ <a style="color: var(--link)">
+ {{ $t('settings.style.preview.link') }}
+ </a>
+ </i18n>
+
+ <div class="icons">
+ <i
+ style="color: var(--cBlue)"
+ class="button-icon icon-reply"
+ />
+ <i
+ style="color: var(--cGreen)"
+ class="button-icon icon-retweet"
+ />
+ <i
+ style="color: var(--cOrange)"
+ class="button-icon icon-star"
+ />
+ <i
+ style="color: var(--cRed)"
+ class="button-icon icon-cancel"
+ />
+ </div>
+ </div>
+ </div>
+
+ <div class="after-post">
+ <div class="avatar-alt">
+ :^)
+ </div>
+ <div class="content">
+ <i18n
+ path="settings.style.preview.fine_print"
+ tag="span"
+ class="faint"
+ >
+ <a style="color: var(--faintLink)">
+ {{ $t('settings.style.preview.faint_link') }}
+ </a>
+ </i18n>
+ </div>
+ </div>
+ <div class="separator" />
+
+ <span class="alert error">
+ {{ $t('settings.style.preview.error') }}
+ </span>
+ <input
+ :value="$t('settings.style.preview.input')"
+ type="text"
+ >
+
+ <div class="actions">
+ <span class="checkbox">
+ <input
+ id="preview_checkbox"
+ checked="very yes"
+ type="checkbox"
+ >
+ <label for="preview_checkbox">{{ $t('settings.style.preview.checkbox') }}</label>
+ </span>
+ <button class="btn">
+ {{ $t('settings.style.preview.button') }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<style lang="scss">
+.preview-container {
+ position: relative;
+}
+.underlay-preview {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 10px;
+ right: 10px;
+}
+</style>
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
new file mode 100644
index 00000000..9d61b0c4
--- /dev/null
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
@@ -0,0 +1,759 @@
+import { set, delete as del } from 'vue'
+import {
+ rgb2hex,
+ hex2rgb,
+ getContrastRatioLayers
+} from 'src/services/color_convert/color_convert.js'
+import {
+ DEFAULT_SHADOWS,
+ generateColors,
+ generateShadows,
+ generateRadii,
+ generateFonts,
+ composePreset,
+ getThemes,
+ shadows2to3,
+ colors2to3
+} from 'src/services/style_setter/style_setter.js'
+import {
+ SLOT_INHERITANCE
+} from 'src/services/theme_data/pleromafe.js'
+import {
+ CURRENT_VERSION,
+ OPACITIES,
+ getLayers,
+ getOpacitySlot
+} from 'src/services/theme_data/theme_data.service.js'
+import ColorInput from 'src/components/color_input/color_input.vue'
+import RangeInput from 'src/components/range_input/range_input.vue'
+import OpacityInput from 'src/components/opacity_input/opacity_input.vue'
+import ShadowControl from 'src/components/shadow_control/shadow_control.vue'
+import FontControl from 'src/components/font_control/font_control.vue'
+import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
+import ExportImport from 'src/components/export_import/export_import.vue'
+import Checkbox from 'src/components/checkbox/checkbox.vue'
+
+import Preview from './preview.vue'
+
+// List of color values used in v1
+const v1OnlyNames = [
+ 'bg',
+ 'fg',
+ 'text',
+ 'link',
+ 'cRed',
+ 'cGreen',
+ 'cBlue',
+ 'cOrange'
+].map(_ => _ + 'ColorLocal')
+
+const colorConvert = (color) => {
+ if (color.startsWith('--') || color === 'transparent') {
+ return color
+ } else {
+ return hex2rgb(color)
+ }
+}
+
+export default {
+ data () {
+ return {
+ availableStyles: [],
+ selected: this.$store.getters.mergedConfig.theme,
+ themeWarning: undefined,
+ tempImportFile: undefined,
+ engineVersion: 0,
+
+ previewShadows: {},
+ previewColors: {},
+ previewRadii: {},
+ previewFonts: {},
+
+ shadowsInvalid: true,
+ colorsInvalid: true,
+ radiiInvalid: true,
+
+ keepColor: false,
+ keepShadows: false,
+ keepOpacity: false,
+ keepRoundness: false,
+ keepFonts: false,
+
+ ...Object.keys(SLOT_INHERITANCE)
+ .map(key => [key, ''])
+ .reduce((acc, [key, val]) => ({ ...acc, [ key + 'ColorLocal' ]: val }), {}),
+
+ ...Object.keys(OPACITIES)
+ .map(key => [key, ''])
+ .reduce((acc, [key, val]) => ({ ...acc, [ key + 'OpacityLocal' ]: val }), {}),
+
+ shadowSelected: undefined,
+ shadowsLocal: {},
+ fontsLocal: {},
+
+ btnRadiusLocal: '',
+ inputRadiusLocal: '',
+ checkboxRadiusLocal: '',
+ panelRadiusLocal: '',
+ avatarRadiusLocal: '',
+ avatarAltRadiusLocal: '',
+ attachmentRadiusLocal: '',
+ tooltipRadiusLocal: ''
+ }
+ },
+ created () {
+ const self = this
+
+ getThemes()
+ .then((promises) => {
+ return Promise.all(
+ Object.entries(promises)
+ .map(([k, v]) => v.then(res => [k, res]))
+ )
+ })
+ .then(themes => themes.reduce((acc, [k, v]) => {
+ if (v) {
+ return {
+ ...acc,
+ [k]: v
+ }
+ } else {
+ return acc
+ }
+ }, {}))
+ .then((themesComplete) => {
+ self.availableStyles = themesComplete
+ })
+ },
+ mounted () {
+ this.loadThemeFromLocalStorage()
+ if (typeof this.shadowSelected === 'undefined') {
+ this.shadowSelected = this.shadowsAvailable[0]
+ }
+ },
+ computed: {
+ themeWarningHelp () {
+ if (!this.themeWarning) return
+ const t = this.$t
+ const pre = 'settings.style.switcher.help.'
+ const {
+ origin,
+ themeEngineVersion,
+ type,
+ noActionsPossible
+ } = this.themeWarning
+ if (origin === 'file') {
+ // Loaded v2 theme from file
+ if (themeEngineVersion === 2 && type === 'wrong_version') {
+ return t(pre + 'v2_imported')
+ }
+ if (themeEngineVersion > CURRENT_VERSION) {
+ return t(pre + 'future_version_imported') + ' ' +
+ (
+ noActionsPossible
+ ? t(pre + 'snapshot_missing')
+ : t(pre + 'snapshot_present')
+ )
+ }
+ if (themeEngineVersion < CURRENT_VERSION) {
+ return t(pre + 'future_version_imported') + ' ' +
+ (
+ noActionsPossible
+ ? t(pre + 'snapshot_missing')
+ : t(pre + 'snapshot_present')
+ )
+ }
+ } else if (origin === 'localStorage') {
+ if (type === 'snapshot_source_mismatch') {
+ return t(pre + 'snapshot_source_mismatch')
+ }
+ // FE upgraded from v2
+ if (themeEngineVersion === 2) {
+ return t(pre + 'upgraded_from_v2')
+ }
+ // Admin downgraded FE
+ if (themeEngineVersion > CURRENT_VERSION) {
+ return t(pre + 'fe_downgraded') + ' ' +
+ (
+ noActionsPossible
+ ? t(pre + 'migration_snapshot_ok')
+ : t(pre + 'migration_snapshot_gone')
+ )
+ }
+ // Admin upgraded FE
+ if (themeEngineVersion < CURRENT_VERSION) {
+ return t(pre + 'fe_upgraded') + ' ' +
+ (
+ noActionsPossible
+ ? t(pre + 'migration_snapshot_ok')
+ : t(pre + 'migration_snapshot_gone')
+ )
+ }
+ }
+ },
+ selectedVersion () {
+ return Array.isArray(this.selected) ? 1 : 2
+ },
+ currentColors () {
+ return Object.keys(SLOT_INHERITANCE)
+ .map(key => [key, this[key + 'ColorLocal']])
+ .reduce((acc, [key, val]) => ({ ...acc, [ key ]: val }), {})
+ },
+ currentOpacity () {
+ return Object.keys(OPACITIES)
+ .map(key => [key, this[key + 'OpacityLocal']])
+ .reduce((acc, [key, val]) => ({ ...acc, [ key ]: val }), {})
+ },
+ currentRadii () {
+ return {
+ btn: this.btnRadiusLocal,
+ input: this.inputRadiusLocal,
+ checkbox: this.checkboxRadiusLocal,
+ panel: this.panelRadiusLocal,
+ avatar: this.avatarRadiusLocal,
+ avatarAlt: this.avatarAltRadiusLocal,
+ tooltip: this.tooltipRadiusLocal,
+ attachment: this.attachmentRadiusLocal
+ }
+ },
+ preview () {
+ return composePreset(this.previewColors, this.previewRadii, this.previewShadows, this.previewFonts)
+ },
+ previewTheme () {
+ if (!this.preview.theme.colors) return { colors: {}, opacity: {}, radii: {}, shadows: {}, fonts: {} }
+ return this.preview.theme
+ },
+ // This needs optimization maybe
+ previewContrast () {
+ try {
+ if (!this.previewTheme.colors.bg) return {}
+ const colors = this.previewTheme.colors
+ const opacity = this.previewTheme.opacity
+ if (!colors.bg) return {}
+ const hints = (ratio) => ({
+ text: ratio.toPrecision(3) + ':1',
+ // AA level, AAA level
+ aa: ratio >= 4.5,
+ aaa: ratio >= 7,
+ // same but for 18pt+ texts
+ laa: ratio >= 3,
+ laaa: ratio >= 4.5
+ })
+ const colorsConverted = Object.entries(colors).reduce((acc, [key, value]) => ({ ...acc, [key]: colorConvert(value) }), {})
+
+ const ratios = Object.entries(SLOT_INHERITANCE).reduce((acc, [key, value]) => {
+ const slotIsBaseText = key === 'text' || key === 'link'
+ const slotIsText = slotIsBaseText || (
+ typeof value === 'object' && value !== null && value.textColor
+ )
+ if (!slotIsText) return acc
+ const { layer, variant } = slotIsBaseText ? { layer: 'bg' } : value
+ const background = variant || layer
+ const opacitySlot = getOpacitySlot(background)
+ const textColors = [
+ key,
+ ...(background === 'bg' ? ['cRed', 'cGreen', 'cBlue', 'cOrange'] : [])
+ ]
+
+ const layers = getLayers(
+ layer,
+ variant || layer,
+ opacitySlot,
+ colorsConverted,
+ opacity
+ )
+
+ return {
+ ...acc,
+ ...textColors.reduce((acc, textColorKey) => {
+ const newKey = slotIsBaseText
+ ? 'bg' + textColorKey[0].toUpperCase() + textColorKey.slice(1)
+ : textColorKey
+ return {
+ ...acc,
+ [newKey]: getContrastRatioLayers(
+ colorsConverted[textColorKey],
+ layers,
+ colorsConverted[textColorKey]
+ )
+ }
+ }, {})
+ }
+ }, {})
+
+ return Object.entries(ratios).reduce((acc, [k, v]) => { acc[k] = hints(v); return acc }, {})
+ } catch (e) {
+ console.warn('Failure computing contrasts', e)
+ }
+ },
+ previewRules () {
+ if (!this.preview.rules) return ''
+ return [
+ ...Object.values(this.preview.rules),
+ 'color: var(--text)',
+ 'font-family: var(--interfaceFont, sans-serif)'
+ ].join(';')
+ },
+ shadowsAvailable () {
+ return Object.keys(DEFAULT_SHADOWS).sort()
+ },
+ currentShadowOverriden: {
+ get () {
+ return !!this.currentShadow
+ },
+ set (val) {
+ if (val) {
+ set(this.shadowsLocal, this.shadowSelected, this.currentShadowFallback.map(_ => Object.assign({}, _)))
+ } else {
+ del(this.shadowsLocal, this.shadowSelected)
+ }
+ }
+ },
+ currentShadowFallback () {
+ return (this.previewTheme.shadows || {})[this.shadowSelected]
+ },
+ currentShadow: {
+ get () {
+ return this.shadowsLocal[this.shadowSelected]
+ },
+ set (v) {
+ set(this.shadowsLocal, this.shadowSelected, v)
+ }
+ },
+ themeValid () {
+ return !this.shadowsInvalid && !this.colorsInvalid && !this.radiiInvalid
+ },
+ exportedTheme () {
+ const saveEverything = (
+ !this.keepFonts &&
+ !this.keepShadows &&
+ !this.keepOpacity &&
+ !this.keepRoundness &&
+ !this.keepColor
+ )
+
+ const source = {
+ themeEngineVersion: CURRENT_VERSION
+ }
+
+ if (this.keepFonts || saveEverything) {
+ source.fonts = this.fontsLocal
+ }
+ if (this.keepShadows || saveEverything) {
+ source.shadows = this.shadowsLocal
+ }
+ if (this.keepOpacity || saveEverything) {
+ source.opacity = this.currentOpacity
+ }
+ if (this.keepColor || saveEverything) {
+ source.colors = this.currentColors
+ }
+ if (this.keepRoundness || saveEverything) {
+ source.radii = this.currentRadii
+ }
+
+ const theme = {
+ themeEngineVersion: CURRENT_VERSION,
+ ...this.previewTheme
+ }
+
+ return {
+ // To separate from other random JSON files and possible future source formats
+ _pleroma_theme_version: 2, theme, source
+ }
+ }
+ },
+ components: {
+ ColorInput,
+ OpacityInput,
+ RangeInput,
+ ContrastRatio,
+ ShadowControl,
+ FontControl,
+ TabSwitcher,
+ Preview,
+ ExportImport,
+ Checkbox
+ },
+ methods: {
+ loadTheme (
+ {
+ theme,
+ source,
+ _pleroma_theme_version: fileVersion
+ },
+ origin,
+ forceUseSource = false
+ ) {
+ this.dismissWarning()
+ if (!source && !theme) {
+ throw new Error('Can\'t load theme: empty')
+ }
+ const version = (origin === 'localStorage' && !theme.colors)
+ ? 'l1'
+ : fileVersion
+ const snapshotEngineVersion = (theme || {}).themeEngineVersion
+ const themeEngineVersion = (source || {}).themeEngineVersion || 2
+ const versionsMatch = themeEngineVersion === CURRENT_VERSION
+ const sourceSnapshotMismatch = (
+ theme !== undefined &&
+ source !== undefined &&
+ themeEngineVersion !== snapshotEngineVersion
+ )
+ // Force loading of source if user requested it or if snapshot
+ // is unavailable
+ const forcedSourceLoad = (source && forceUseSource) || !theme
+ if (!(versionsMatch && !sourceSnapshotMismatch) &&
+ !forcedSourceLoad &&
+ version !== 'l1' &&
+ origin !== 'defaults'
+ ) {
+ if (sourceSnapshotMismatch && origin === 'localStorage') {
+ this.themeWarning = {
+ origin,
+ themeEngineVersion,
+ type: 'snapshot_source_mismatch'
+ }
+ } else if (!theme) {
+ this.themeWarning = {
+ origin,
+ noActionsPossible: true,
+ themeEngineVersion,
+ type: 'no_snapshot_old_version'
+ }
+ } else if (!versionsMatch) {
+ this.themeWarning = {
+ origin,
+ noActionsPossible: !source,
+ themeEngineVersion,
+ type: 'wrong_version'
+ }
+ }
+ }
+ this.normalizeLocalState(theme, version, source, forcedSourceLoad)
+ },
+ forceLoadLocalStorage () {
+ this.loadThemeFromLocalStorage(true)
+ },
+ dismissWarning () {
+ this.themeWarning = undefined
+ this.tempImportFile = undefined
+ },
+ forceLoad () {
+ const { origin } = this.themeWarning
+ switch (origin) {
+ case 'localStorage':
+ this.loadThemeFromLocalStorage(true)
+ break
+ case 'file':
+ this.onImport(this.tempImportFile, true)
+ break
+ }
+ this.dismissWarning()
+ },
+ forceSnapshot () {
+ const { origin } = this.themeWarning
+ switch (origin) {
+ case 'localStorage':
+ this.loadThemeFromLocalStorage(false, true)
+ break
+ case 'file':
+ console.err('Forcing snapshout from file is not supported yet')
+ break
+ }
+ this.dismissWarning()
+ },
+ loadThemeFromLocalStorage (confirmLoadSource = false, forceSnapshot = false) {
+ const {
+ customTheme: theme,
+ customThemeSource: source
+ } = this.$store.getters.mergedConfig
+ if (!theme && !source) {
+ // Anon user or never touched themes
+ this.loadTheme(
+ this.$store.state.instance.themeData,
+ 'defaults',
+ confirmLoadSource
+ )
+ } else {
+ this.loadTheme(
+ {
+ theme,
+ source: forceSnapshot ? theme : source
+ },
+ 'localStorage',
+ confirmLoadSource
+ )
+ }
+ },
+ setCustomTheme () {
+ this.$store.dispatch('setOption', {
+ name: 'customTheme',
+ value: {
+ themeEngineVersion: CURRENT_VERSION,
+ ...this.previewTheme
+ }
+ })
+ this.$store.dispatch('setOption', {
+ name: 'customThemeSource',
+ value: {
+ themeEngineVersion: CURRENT_VERSION,
+ shadows: this.shadowsLocal,
+ fonts: this.fontsLocal,
+ opacity: this.currentOpacity,
+ colors: this.currentColors,
+ radii: this.currentRadii
+ }
+ })
+ },
+ updatePreviewColorsAndShadows () {
+ this.previewColors = generateColors({
+ opacity: this.currentOpacity,
+ colors: this.currentColors
+ })
+ this.previewShadows = generateShadows(
+ { shadows: this.shadowsLocal, opacity: this.previewTheme.opacity, themeEngineVersion: this.engineVersion },
+ this.previewColors.theme.colors,
+ this.previewColors.mod
+ )
+ },
+ onImport (parsed, forceSource = false) {
+ this.tempImportFile = parsed
+ this.loadTheme(parsed, 'file', forceSource)
+ },
+ importValidator (parsed) {
+ const version = parsed._pleroma_theme_version
+ return version >= 1 || version <= 2
+ },
+ clearAll () {
+ this.loadThemeFromLocalStorage()
+ },
+
+ // Clears all the extra stuff when loading V1 theme
+ clearV1 () {
+ Object.keys(this.$data)
+ .filter(_ => _.endsWith('ColorLocal') || _.endsWith('OpacityLocal'))
+ .filter(_ => !v1OnlyNames.includes(_))
+ .forEach(key => {
+ set(this.$data, key, undefined)
+ })
+ },
+
+ clearRoundness () {
+ Object.keys(this.$data)
+ .filter(_ => _.endsWith('RadiusLocal'))
+ .forEach(key => {
+ set(this.$data, key, undefined)
+ })
+ },
+
+ clearOpacity () {
+ Object.keys(this.$data)
+ .filter(_ => _.endsWith('OpacityLocal'))
+ .forEach(key => {
+ set(this.$data, key, undefined)
+ })
+ },
+
+ clearShadows () {
+ this.shadowsLocal = {}
+ },
+
+ clearFonts () {
+ this.fontsLocal = {}
+ },
+
+ /**
+ * This applies stored theme data onto form. Supports three versions of data:
+ * v3 (version >= 3) - newest version of themes which supports snapshots for better compatiblity
+ * v2 (version = 2) - newer version of themes.
+ * v1 (version = 1) - older version of themes (import from file)
+ * v1l (version = l1) - older version of theme (load from local storage)
+ * v1 and v1l differ because of way themes were stored/exported.
+ * @param {Object} theme - theme data (snapshot)
+ * @param {Number} version - version of data. 0 means try to guess based on data. "l1" means v1, locastorage type
+ * @param {Object} source - theme source - this will be used if compatible
+ * @param {Boolean} source - by default source won't be used if version doesn't match since it might render differently
+ * this allows importing source anyway
+ */
+ normalizeLocalState (theme, version = 0, source, forceSource = false) {
+ let input
+ if (typeof source !== 'undefined') {
+ if (forceSource || source.themeEngineVersion === CURRENT_VERSION) {
+ input = source
+ version = source.themeEngineVersion
+ } else {
+ input = theme
+ }
+ } else {
+ input = theme
+ }
+
+ const radii = input.radii || input
+ const opacity = input.opacity
+ const shadows = input.shadows || {}
+ const fonts = input.fonts || {}
+ const colors = !input.themeEngineVersion
+ ? colors2to3(input.colors || input)
+ : input.colors || input
+
+ if (version === 0) {
+ if (input.version) version = input.version
+ // Old v1 naming: fg is text, btn is foreground
+ if (typeof colors.text === 'undefined' && typeof colors.fg !== 'undefined') {
+ version = 1
+ }
+ // New v2 naming: text is text, fg is foreground
+ if (typeof colors.text !== 'undefined' && typeof colors.fg !== 'undefined') {
+ version = 2
+ }
+ }
+
+ this.engineVersion = version
+
+ // Stuff that differs between V1 and V2
+ if (version === 1) {
+ this.fgColorLocal = rgb2hex(colors.btn)
+ this.textColorLocal = rgb2hex(colors.fg)
+ }
+
+ if (!this.keepColor) {
+ this.clearV1()
+ const keys = new Set(version !== 1 ? Object.keys(SLOT_INHERITANCE) : [])
+ if (version === 1 || version === 'l1') {
+ keys
+ .add('bg')
+ .add('link')
+ .add('cRed')
+ .add('cBlue')
+ .add('cGreen')
+ .add('cOrange')
+ }
+
+ keys.forEach(key => {
+ const color = colors[key]
+ const hex = rgb2hex(colors[key])
+ this[key + 'ColorLocal'] = hex === '#aN' ? color : hex
+ })
+ }
+
+ if (opacity && !this.keepOpacity) {
+ this.clearOpacity()
+ Object.entries(opacity).forEach(([k, v]) => {
+ if (typeof v === 'undefined' || v === null || Number.isNaN(v)) return
+ this[k + 'OpacityLocal'] = v
+ })
+ }
+
+ if (!this.keepRoundness) {
+ this.clearRoundness()
+ Object.entries(radii).forEach(([k, v]) => {
+ // 'Radius' is kept mostly for v1->v2 localstorage transition
+ const key = k.endsWith('Radius') ? k.split('Radius')[0] : k
+ this[key + 'RadiusLocal'] = v
+ })
+ }
+
+ if (!this.keepShadows) {
+ this.clearShadows()
+ if (version === 2) {
+ this.shadowsLocal = shadows2to3(shadows, this.previewTheme.opacity)
+ } else {
+ this.shadowsLocal = shadows
+ }
+ this.shadowSelected = this.shadowsAvailable[0]
+ }
+
+ if (!this.keepFonts) {
+ this.clearFonts()
+ this.fontsLocal = fonts
+ }
+ }
+ },
+ watch: {
+ currentRadii () {
+ try {
+ this.previewRadii = generateRadii({ radii: this.currentRadii })
+ this.radiiInvalid = false
+ } catch (e) {
+ this.radiiInvalid = true
+ console.warn(e)
+ }
+ },
+ shadowsLocal: {
+ handler () {
+ if (Object.getOwnPropertyNames(this.previewColors).length === 1) return
+ try {
+ this.updatePreviewColorsAndShadows()
+ this.shadowsInvalid = false
+ } catch (e) {
+ this.shadowsInvalid = true
+ console.warn(e)
+ }
+ },
+ deep: true
+ },
+ fontsLocal: {
+ handler () {
+ try {
+ this.previewFonts = generateFonts({ fonts: this.fontsLocal })
+ this.fontsInvalid = false
+ } catch (e) {
+ this.fontsInvalid = true
+ console.warn(e)
+ }
+ },
+ deep: true
+ },
+ currentColors () {
+ try {
+ this.updatePreviewColorsAndShadows()
+ this.colorsInvalid = false
+ this.shadowsInvalid = false
+ } catch (e) {
+ this.colorsInvalid = true
+ this.shadowsInvalid = true
+ console.warn(e)
+ }
+ },
+ currentOpacity () {
+ try {
+ this.updatePreviewColorsAndShadows()
+ } catch (e) {
+ console.warn(e)
+ }
+ },
+ selected () {
+ this.dismissWarning()
+ if (this.selectedVersion === 1) {
+ if (!this.keepRoundness) {
+ this.clearRoundness()
+ }
+
+ if (!this.keepShadows) {
+ this.clearShadows()
+ }
+
+ if (!this.keepOpacity) {
+ this.clearOpacity()
+ }
+
+ if (!this.keepColor) {
+ this.clearV1()
+
+ this.bgColorLocal = this.selected[1]
+ this.fgColorLocal = this.selected[2]
+ this.textColorLocal = this.selected[3]
+ this.linkColorLocal = this.selected[4]
+ this.cRedColorLocal = this.selected[5]
+ this.cGreenColorLocal = this.selected[6]
+ this.cBlueColorLocal = this.selected[7]
+ this.cOrangeColorLocal = this.selected[8]
+ }
+ } else if (this.selectedVersion >= 2) {
+ this.normalizeLocalState(this.selected.theme, 2, this.selected.source)
+ }
+ }
+ }
+}
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.scss b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss
new file mode 100644
index 00000000..926eceff
--- /dev/null
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss
@@ -0,0 +1,345 @@
+@import 'src/_variables.scss';
+.theme-tab {
+ padding-bottom: 2em;
+ .theme-warning {
+ display: flex;
+ align-items: baseline;
+ margin-bottom: .5em;
+ .buttons {
+ .btn {
+ margin-bottom: .5em;
+ }
+ }
+ }
+ .preset-switcher {
+ margin-right: 1em;
+ }
+
+ .style-control {
+ display: flex;
+ align-items: baseline;
+ margin-bottom: 5px;
+
+ .label {
+ flex: 1;
+ }
+
+ &.disabled {
+ input, select {
+ opacity: .5
+ }
+ }
+
+ .opt {
+ margin: .5em;
+ }
+
+ .color-input {
+ flex: 0 0 0;
+ }
+
+ input, select {
+ min-width: 3em;
+ margin: 0;
+ flex: 0;
+
+ &[type=number] {
+ min-width: 5em;
+ }
+
+ &[type=range] {
+ flex: 1;
+ min-width: 3em;
+ align-self: flex-start;
+ }
+ }
+ }
+
+ .reset-container {
+ flex-wrap: wrap;
+ }
+
+ .fonts-container,
+ .reset-container,
+ .apply-container,
+ .radius-container,
+ .color-container,
+ {
+ display: flex;
+ }
+
+ .fonts-container,
+ .radius-container {
+ flex-direction: column;
+ }
+
+ .color-container{
+ > h4 {
+ width: 99%;
+ }
+ flex-wrap: wrap;
+ justify-content: space-between;
+ }
+
+ .fonts-container,
+ .color-container,
+ .shadow-container,
+ .radius-container,
+ .presets-container {
+ margin: 1em 1em 0;
+ }
+
+ .tab-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ width: 100%;
+ min-height: 30px;
+ margin-bottom: 1em;
+
+ p {
+ flex: 1;
+ margin: 0;
+ margin-right: .5em;
+ }
+ }
+
+ .tab-header-buttons {
+ display: flex;
+ flex-direction: column;
+
+ .btn {
+ min-width: 1px;
+ flex: 0 auto;
+ padding: 0 1em;
+ margin-bottom: .5em;
+ }
+ }
+
+ .shadow-selector {
+ .override {
+ flex: 1;
+ margin-left: .5em;
+ }
+ .select-container {
+ margin-top: -4px;
+ margin-bottom: -3px;
+ }
+ }
+
+ .save-load,
+ .save-load-options {
+ display: flex;
+ justify-content: center;
+ align-items: baseline;
+ flex-wrap: wrap;
+
+ .presets,
+ .import-export {
+ margin-bottom: .5em;
+ }
+
+ .import-export {
+ display: flex;
+ }
+
+ .override {
+ margin-left: .5em;
+ }
+ }
+
+ .save-load-options {
+ flex-wrap: wrap;
+ margin-top: .5em;
+ justify-content: center;
+ .keep-option {
+ margin: 0 .5em .5em;
+ min-width: 25%;
+ }
+ }
+
+ .preview-container {
+ border-top: 1px dashed;
+ border-bottom: 1px dashed;
+ border-color: $fallback--border;
+ border-color: var(--border, $fallback--border);
+ margin: 1em 0;
+ padding: 1em;
+ background: var(--body-background-image);
+ background-size: cover;
+ background-position: 50% 50%;
+
+ .dummy {
+ .post {
+ font-family: var(--postFont);
+ display: flex;
+
+ .content {
+ flex: 1;
+
+ h4 {
+ margin-bottom: .25em;
+ }
+
+ .icons {
+ margin-top: .5em;
+ display: flex;
+
+ i {
+ margin-right: 1em;
+ }
+ }
+ }
+ }
+
+ .after-post {
+ margin-top: 1em;
+ display: flex;
+ align-items: center;
+ }
+
+ .avatar, .avatar-alt{
+ background: linear-gradient(135deg, #b8e1fc 0%,#a9d2f3 10%,#90bae4 25%,#90bcea 37%,#90bff0 50%,#6ba8e5 51%,#a2daf5 83%,#bdf3fd 100%);
+ color: black;
+ font-family: sans-serif;
+ text-align: center;
+ margin-right: 1em;
+ }
+
+ .avatar-alt {
+ flex: 0 auto;
+ margin-left: 28px;
+ font-size: 12px;
+ min-width: 20px;
+ min-height: 20px;
+ line-height: 20px;
+ border-radius: $fallback--avatarAltRadius;
+ border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+ }
+
+ .avatar {
+ flex: 0 auto;
+ width: 48px;
+ height: 48px;
+ font-size: 14px;
+ line-height: 48px;
+ }
+
+ .actions {
+ display: flex;
+ align-items: baseline;
+
+ .checkbox {
+ display: inline-flex;
+ align-items: baseline;
+ margin-right: 1em;
+ flex: 1;
+ }
+ }
+
+ .separator {
+ margin: 1em;
+ border-bottom: 1px solid;
+ border-color: $fallback--border;
+ border-color: var(--border, $fallback--border);
+ }
+
+ .panel-heading {
+ .badge, .alert, .btn, .faint {
+ margin-left: 1em;
+ white-space: nowrap;
+ }
+ .faint {
+ text-overflow: ellipsis;
+ min-width: 2em;
+ overflow-x: hidden;
+ }
+ .flex-spacer {
+ flex: 1;
+ }
+ }
+ .btn {
+ margin-left: 0;
+ padding: 0 1em;
+ min-width: 3em;
+ min-height: 30px;
+ }
+ }
+ }
+
+ .apply-container {
+ justify-content: center;
+ }
+
+ .radius-item,
+ .color-item {
+ min-width: 20em;
+ margin: 5px 6px 0 0;
+ display:flex;
+ flex-direction: column;
+ flex: 1 1 0;
+
+ &.wide {
+ min-width: 60%
+ }
+
+ &:not(.wide):nth-child(2n+1) {
+ margin-right: 7px;
+
+ }
+
+ .color, .opacity {
+ display:flex;
+ align-items: baseline;
+ }
+ }
+
+ .radius-item {
+ flex-basis: auto;
+ }
+
+ .theme-radius-rn,
+ .theme-color-cl {
+ border: 0;
+ box-shadow: none;
+ background: transparent;
+ color: var(--faint, $fallback--faint);
+ align-self: stretch;
+ }
+
+ .theme-color-cl,
+ .theme-radius-in,
+ .theme-color-in {
+ margin-left: 4px;
+ }
+
+ .theme-radius-in {
+ min-width: 1em;
+ }
+
+ .theme-radius-in {
+ max-width: 7em;
+ flex: 1;
+ }
+
+ .theme-radius-lb{
+ max-width: 50em;
+ }
+
+ .theme-preview-content {
+ padding: 20px;
+ }
+
+ .apply-container {
+ .btn {
+ min-height: 28px;
+ min-width: 10em;
+ padding: 0 2em;
+ }
+ }
+
+ .btn {
+ margin-left: .25em;
+ margin-right: .25em;
+ }
+}
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
new file mode 100644
index 00000000..d14f854c
--- /dev/null
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
@@ -0,0 +1,965 @@
+<template>
+ <div class="theme-tab">
+ <div class="presets-container">
+ <div class="save-load">
+ <div
+ v-if="themeWarning"
+ class="theme-warning"
+ >
+ <div class="alert warning">
+ {{ themeWarningHelp }}
+ </div>
+ <div class="buttons">
+ <template v-if="themeWarning.type === 'snapshot_source_mismatch'">
+ <button
+ class="btn"
+ @click="forceLoad"
+ >
+ {{ $t('settings.style.switcher.use_source') }}
+ </button>
+ <button
+ class="btn"
+ @click="forceSnapshot"
+ >
+ {{ $t('settings.style.switcher.use_snapshot') }}
+ </button>
+ </template>
+ <template v-else-if="themeWarning.noActionsPossible">
+ <button
+ class="btn"
+ @click="dismissWarning"
+ >
+ {{ $t('general.dismiss') }}
+ </button>
+ </template>
+ <template v-else>
+ <button
+ class="btn"
+ @click="forceLoad"
+ >
+ {{ $t('settings.style.switcher.load_theme') }}
+ </button>
+ <button
+ class="btn"
+ @click="dismissWarning"
+ >
+ {{ $t('settings.style.switcher.keep_as_is') }}
+ </button>
+ </template>
+ </div>
+ </div>
+ <ExportImport
+ :export-object="exportedTheme"
+ :export-label="$t(&quot;settings.export_theme&quot;)"
+ :import-label="$t(&quot;settings.import_theme&quot;)"
+ :import-failed-text="$t(&quot;settings.invalid_theme_imported&quot;)"
+ :on-import="onImport"
+ :validator="importValidator"
+ >
+ <template slot="before">
+ <div class="presets">
+ {{ $t('settings.presets') }}
+ <label
+ for="preset-switcher"
+ class="select"
+ >
+ <select
+ id="preset-switcher"
+ v-model="selected"
+ class="preset-switcher"
+ >
+ <option
+ v-for="style in availableStyles"
+ :key="style.name"
+ :value="style"
+ :style="{
+ backgroundColor: style[1] || (style.theme || style.source).colors.bg,
+ color: style[3] || (style.theme || style.source).colors.text
+ }"
+ >
+ {{ style[0] || style.name }}
+ </option>
+ </select>
+ <i class="icon-down-open" />
+ </label>
+ </div>
+ </template>
+ </ExportImport>
+ </div>
+ <div class="save-load-options">
+ <span class="keep-option">
+ <Checkbox v-model="keepColor">
+ {{ $t('settings.style.switcher.keep_color') }}
+ </Checkbox>
+ </span>
+ <span class="keep-option">
+ <Checkbox v-model="keepShadows">
+ {{ $t('settings.style.switcher.keep_shadows') }}
+ </Checkbox>
+ </span>
+ <span class="keep-option">
+ <Checkbox v-model="keepOpacity">
+ {{ $t('settings.style.switcher.keep_opacity') }}
+ </Checkbox>
+ </span>
+ <span class="keep-option">
+ <Checkbox v-model="keepRoundness">
+ {{ $t('settings.style.switcher.keep_roundness') }}
+ </Checkbox>
+ </span>
+ <span class="keep-option">
+ <Checkbox v-model="keepFonts">
+ {{ $t('settings.style.switcher.keep_fonts') }}
+ </Checkbox>
+ </span>
+ <p>{{ $t('settings.style.switcher.save_load_hint') }}</p>
+ </div>
+ </div>
+
+ <preview :style="previewRules" />
+
+ <keep-alive>
+ <tab-switcher key="style-tweak">
+ <div
+ :label="$t('settings.style.common_colors._tab_label')"
+ class="color-container"
+ >
+ <div class="tab-header">
+ <p>{{ $t('settings.theme_help') }}</p>
+ <div class="tab-header-buttons">
+ <button
+ class="btn"
+ @click="clearOpacity"
+ >
+ {{ $t('settings.style.switcher.clear_opacity') }}
+ </button>
+ <button
+ class="btn"
+ @click="clearV1"
+ >
+ {{ $t('settings.style.switcher.clear_all') }}
+ </button>
+ </div>
+ </div>
+ <p>{{ $t('settings.theme_help_v2_1') }}</p>
+ <h4>{{ $t('settings.style.common_colors.main') }}</h4>
+ <div class="color-item">
+ <ColorInput
+ v-model="bgColorLocal"
+ name="bgColor"
+ :label="$t('settings.background')"
+ />
+ <OpacityInput
+ v-model="bgOpacityLocal"
+ name="bgOpacity"
+ :fallback="previewTheme.opacity.bg"
+ />
+ <ColorInput
+ v-model="textColorLocal"
+ name="textColor"
+ :label="$t('settings.text')"
+ />
+ <ContrastRatio :contrast="previewContrast.bgText" />
+ <ColorInput
+ v-model="accentColorLocal"
+ name="accentColor"
+ :fallback="previewTheme.colors.link"
+ :label="$t('settings.accent')"
+ :show-optional-tickbox="typeof linkColorLocal !== 'undefined'"
+ />
+ <ColorInput
+ v-model="linkColorLocal"
+ name="linkColor"
+ :fallback="previewTheme.colors.accent"
+ :label="$t('settings.links')"
+ :show-optional-tickbox="typeof accentColorLocal !== 'undefined'"
+ />
+ <ContrastRatio :contrast="previewContrast.bgLink" />
+ </div>
+ <div class="color-item">
+ <ColorInput
+ v-model="fgColorLocal"
+ name="fgColor"
+ :label="$t('settings.foreground')"
+ />
+ <ColorInput
+ v-model="fgTextColorLocal"
+ name="fgTextColor"
+ :label="$t('settings.text')"
+ :fallback="previewTheme.colors.fgText"
+ />
+ <ColorInput
+ v-model="fgLinkColorLocal"
+ name="fgLinkColor"
+ :label="$t('settings.links')"
+ :fallback="previewTheme.colors.fgLink"
+ />
+ <p>{{ $t('settings.style.common_colors.foreground_hint') }}</p>
+ </div>
+ <h4>{{ $t('settings.style.common_colors.rgbo') }}</h4>
+ <div class="color-item">
+ <ColorInput
+ v-model="cRedColorLocal"
+ name="cRedColor"
+ :label="$t('settings.cRed')"
+ />
+ <ContrastRatio :contrast="previewContrast.bgCRed" />
+ <ColorInput
+ v-model="cBlueColorLocal"
+ name="cBlueColor"
+ :label="$t('settings.cBlue')"
+ />
+ <ContrastRatio :contrast="previewContrast.bgCBlue" />
+ </div>
+ <div class="color-item">
+ <ColorInput
+ v-model="cGreenColorLocal"
+ name="cGreenColor"
+ :label="$t('settings.cGreen')"
+ />
+ <ContrastRatio :contrast="previewContrast.bgCGreen" />
+ <ColorInput
+ v-model="cOrangeColorLocal"
+ name="cOrangeColor"
+ :label="$t('settings.cOrange')"
+ />
+ <ContrastRatio :contrast="previewContrast.bgCOrange" />
+ </div>
+ <p>{{ $t('settings.theme_help_v2_2') }}</p>
+ </div>
+
+ <div
+ :label="$t('settings.style.advanced_colors._tab_label')"
+ class="color-container"
+ >
+ <div class="tab-header">
+ <p>{{ $t('settings.theme_help') }}</p>
+ <button
+ class="btn"
+ @click="clearOpacity"
+ >
+ {{ $t('settings.style.switcher.clear_opacity') }}
+ </button>
+ <button
+ class="btn"
+ @click="clearV1"
+ >
+ {{ $t('settings.style.switcher.clear_all') }}
+ </button>
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.post') }}</h4>
+ <ColorInput
+ v-model="postLinkColorLocal"
+ name="postLinkColor"
+ :fallback="previewTheme.colors.accent"
+ :label="$t('settings.links')"
+ />
+ <ContrastRatio :contrast="previewContrast.postLink" />
+ <ColorInput
+ v-model="postGreentextColorLocal"
+ name="postGreentextColor"
+ :fallback="previewTheme.colors.cGreen"
+ :label="$t('settings.greentext')"
+ />
+ <ContrastRatio :contrast="previewContrast.postGreentext" />
+ <h4>{{ $t('settings.style.advanced_colors.alert') }}</h4>
+ <ColorInput
+ v-model="alertErrorColorLocal"
+ name="alertError"
+ :label="$t('settings.style.advanced_colors.alert_error')"
+ :fallback="previewTheme.colors.alertError"
+ />
+ <ColorInput
+ v-model="alertErrorTextColorLocal"
+ name="alertErrorText"
+ :label="$t('settings.text')"
+ :fallback="previewTheme.colors.alertErrorText"
+ />
+ <ContrastRatio
+ :contrast="previewContrast.alertErrorText"
+ large="true"
+ />
+ <ColorInput
+ v-model="alertWarningColorLocal"
+ name="alertWarning"
+ :label="$t('settings.style.advanced_colors.alert_warning')"
+ :fallback="previewTheme.colors.alertWarning"
+ />
+ <ColorInput
+ v-model="alertWarningTextColorLocal"
+ name="alertWarningText"
+ :label="$t('settings.text')"
+ :fallback="previewTheme.colors.alertWarningText"
+ />
+ <ContrastRatio
+ :contrast="previewContrast.alertWarningText"
+ large="true"
+ />
+ <ColorInput
+ v-model="alertNeutralColorLocal"
+ name="alertNeutral"
+ :label="$t('settings.style.advanced_colors.alert_neutral')"
+ :fallback="previewTheme.colors.alertNeutral"
+ />
+ <ColorInput
+ v-model="alertNeutralTextColorLocal"
+ name="alertNeutralText"
+ :label="$t('settings.text')"
+ :fallback="previewTheme.colors.alertNeutralText"
+ />
+ <ContrastRatio
+ :contrast="previewContrast.alertNeutralText"
+ large="true"
+ />
+ <OpacityInput
+ v-model="alertOpacityLocal"
+ name="alertOpacity"
+ :fallback="previewTheme.opacity.alert"
+ />
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.badge') }}</h4>
+ <ColorInput
+ v-model="badgeNotificationColorLocal"
+ name="badgeNotification"
+ :label="$t('settings.style.advanced_colors.badge_notification')"
+ :fallback="previewTheme.colors.badgeNotification"
+ />
+ <ColorInput
+ v-model="badgeNotificationTextColorLocal"
+ name="badgeNotificationText"
+ :label="$t('settings.text')"
+ :fallback="previewTheme.colors.badgeNotificationText"
+ />
+ <ContrastRatio
+ :contrast="previewContrast.badgeNotificationText"
+ large="true"
+ />
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.panel_header') }}</h4>
+ <ColorInput
+ v-model="panelColorLocal"
+ name="panelColor"
+ :fallback="previewTheme.colors.panel"
+ :label="$t('settings.background')"
+ />
+ <OpacityInput
+ v-model="panelOpacityLocal"
+ name="panelOpacity"
+ :fallback="previewTheme.opacity.panel"
+ :disabled="panelColorLocal === 'transparent'"
+ />
+ <ColorInput
+ v-model="panelTextColorLocal"
+ name="panelTextColor"
+ :fallback="previewTheme.colors.panelText"
+ :label="$t('settings.text')"
+ />
+ <ContrastRatio
+ :contrast="previewContrast.panelText"
+ large="true"
+ />
+ <ColorInput
+ v-model="panelLinkColorLocal"
+ name="panelLinkColor"
+ :fallback="previewTheme.colors.panelLink"
+ :label="$t('settings.links')"
+ />
+ <ContrastRatio
+ :contrast="previewContrast.panelLink"
+ large="true"
+ />
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.top_bar') }}</h4>
+ <ColorInput
+ v-model="topBarColorLocal"
+ name="topBarColor"
+ :fallback="previewTheme.colors.topBar"
+ :label="$t('settings.background')"
+ />
+ <ColorInput
+ v-model="topBarTextColorLocal"
+ name="topBarTextColor"
+ :fallback="previewTheme.colors.topBarText"
+ :label="$t('settings.text')"
+ />
+ <ContrastRatio :contrast="previewContrast.topBarText" />
+ <ColorInput
+ v-model="topBarLinkColorLocal"
+ name="topBarLinkColor"
+ :fallback="previewTheme.colors.topBarLink"
+ :label="$t('settings.links')"
+ />
+ <ContrastRatio :contrast="previewContrast.topBarLink" />
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.inputs') }}</h4>
+ <ColorInput
+ v-model="inputColorLocal"
+ name="inputColor"
+ :fallback="previewTheme.colors.input"
+ :label="$t('settings.background')"
+ />
+ <OpacityInput
+ v-model="inputOpacityLocal"
+ name="inputOpacity"
+ :fallback="previewTheme.opacity.input"
+ :disabled="inputColorLocal === 'transparent'"
+ />
+ <ColorInput
+ v-model="inputTextColorLocal"
+ name="inputTextColor"
+ :fallback="previewTheme.colors.inputText"
+ :label="$t('settings.text')"
+ />
+ <ContrastRatio :contrast="previewContrast.inputText" />
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.buttons') }}</h4>
+ <ColorInput
+ v-model="btnColorLocal"
+ name="btnColor"
+ :fallback="previewTheme.colors.btn"
+ :label="$t('settings.background')"
+ />
+ <OpacityInput
+ v-model="btnOpacityLocal"
+ name="btnOpacity"
+ :fallback="previewTheme.opacity.btn"
+ :disabled="btnColorLocal === 'transparent'"
+ />
+ <ColorInput
+ v-model="btnTextColorLocal"
+ name="btnTextColor"
+ :fallback="previewTheme.colors.btnText"
+ :label="$t('settings.text')"
+ />
+ <ContrastRatio :contrast="previewContrast.btnText" />
+ <ColorInput
+ v-model="btnPanelTextColorLocal"
+ name="btnPanelTextColor"
+ :fallback="previewTheme.colors.btnPanelText"
+ :label="$t('settings.style.advanced_colors.panel_header')"
+ />
+ <ContrastRatio :contrast="previewContrast.btnPanelText" />
+ <ColorInput
+ v-model="btnTopBarTextColorLocal"
+ name="btnTopBarTextColor"
+ :fallback="previewTheme.colors.btnTopBarText"
+ :label="$t('settings.style.advanced_colors.top_bar')"
+ />
+ <ContrastRatio :contrast="previewContrast.btnTopBarText" />
+ <h5>{{ $t('settings.style.advanced_colors.pressed') }}</h5>
+ <ColorInput
+ v-model="btnPressedColorLocal"
+ name="btnPressedColor"
+ :fallback="previewTheme.colors.btnPressed"
+ :label="$t('settings.background')"
+ />
+ <ColorInput
+ v-model="btnPressedTextColorLocal"
+ name="btnPressedTextColor"
+ :fallback="previewTheme.colors.btnPressedText"
+ :label="$t('settings.text')"
+ />
+ <ContrastRatio :contrast="previewContrast.btnPressedText" />
+ <ColorInput
+ v-model="btnPressedPanelTextColorLocal"
+ name="btnPressedPanelTextColor"
+ :fallback="previewTheme.colors.btnPressedPanelText"
+ :label="$t('settings.style.advanced_colors.panel_header')"
+ />
+ <ContrastRatio :contrast="previewContrast.btnPressedPanelText" />
+ <ColorInput
+ v-model="btnPressedTopBarTextColorLocal"
+ name="btnPressedTopBarTextColor"
+ :fallback="previewTheme.colors.btnPressedTopBarText"
+ :label="$t('settings.style.advanced_colors.top_bar')"
+ />
+ <ContrastRatio :contrast="previewContrast.btnPressedTopBarText" />
+ <h5>{{ $t('settings.style.advanced_colors.disabled') }}</h5>
+ <ColorInput
+ v-model="btnDisabledColorLocal"
+ name="btnDisabledColor"
+ :fallback="previewTheme.colors.btnDisabled"
+ :label="$t('settings.background')"
+ />
+ <ColorInput
+ v-model="btnDisabledTextColorLocal"
+ name="btnDisabledTextColor"
+ :fallback="previewTheme.colors.btnDisabledText"
+ :label="$t('settings.text')"
+ />
+ <ColorInput
+ v-model="btnDisabledPanelTextColorLocal"
+ name="btnDisabledPanelTextColor"
+ :fallback="previewTheme.colors.btnDisabledPanelText"
+ :label="$t('settings.style.advanced_colors.panel_header')"
+ />
+ <ColorInput
+ v-model="btnDisabledTopBarTextColorLocal"
+ name="btnDisabledTopBarTextColor"
+ :fallback="previewTheme.colors.btnDisabledTopBarText"
+ :label="$t('settings.style.advanced_colors.top_bar')"
+ />
+ <h5>{{ $t('settings.style.advanced_colors.toggled') }}</h5>
+ <ColorInput
+ v-model="btnToggledColorLocal"
+ name="btnToggledColor"
+ :fallback="previewTheme.colors.btnToggled"
+ :label="$t('settings.background')"
+ />
+ <ColorInput
+ v-model="btnToggledTextColorLocal"
+ name="btnToggledTextColor"
+ :fallback="previewTheme.colors.btnToggledText"
+ :label="$t('settings.text')"
+ />
+ <ContrastRatio :contrast="previewContrast.btnToggledText" />
+ <ColorInput
+ v-model="btnToggledPanelTextColorLocal"
+ name="btnToggledPanelTextColor"
+ :fallback="previewTheme.colors.btnToggledPanelText"
+ :label="$t('settings.style.advanced_colors.panel_header')"
+ />
+ <ContrastRatio :contrast="previewContrast.btnToggledPanelText" />
+ <ColorInput
+ v-model="btnToggledTopBarTextColorLocal"
+ name="btnToggledTopBarTextColor"
+ :fallback="previewTheme.colors.btnToggledTopBarText"
+ :label="$t('settings.style.advanced_colors.top_bar')"
+ />
+ <ContrastRatio :contrast="previewContrast.btnToggledTopBarText" />
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.tabs') }}</h4>
+ <ColorInput
+ v-model="tabColorLocal"
+ name="tabColor"
+ :fallback="previewTheme.colors.tab"
+ :label="$t('settings.background')"
+ />
+ <ColorInput
+ v-model="tabTextColorLocal"
+ name="tabTextColor"
+ :fallback="previewTheme.colors.tabText"
+ :label="$t('settings.text')"
+ />
+ <ContrastRatio :contrast="previewContrast.tabText" />
+ <ColorInput
+ v-model="tabActiveTextColorLocal"
+ name="tabActiveTextColor"
+ :fallback="previewTheme.colors.tabActiveText"
+ :label="$t('settings.text')"
+ />
+ <ContrastRatio :contrast="previewContrast.tabActiveText" />
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.borders') }}</h4>
+ <ColorInput
+ v-model="borderColorLocal"
+ name="borderColor"
+ :fallback="previewTheme.colors.border"
+ :label="$t('settings.style.common.color')"
+ />
+ <OpacityInput
+ v-model="borderOpacityLocal"
+ name="borderOpacity"
+ :fallback="previewTheme.opacity.border"
+ :disabled="borderColorLocal === 'transparent'"
+ />
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.faint_text') }}</h4>
+ <ColorInput
+ v-model="faintColorLocal"
+ name="faintColor"
+ :fallback="previewTheme.colors.faint"
+ :label="$t('settings.text')"
+ />
+ <ColorInput
+ v-model="faintLinkColorLocal"
+ name="faintLinkColor"
+ :fallback="previewTheme.colors.faintLink"
+ :label="$t('settings.links')"
+ />
+ <ColorInput
+ v-model="panelFaintColorLocal"
+ name="panelFaintColor"
+ :fallback="previewTheme.colors.panelFaint"
+ :label="$t('settings.style.advanced_colors.panel_header')"
+ />
+ <OpacityInput
+ v-model="faintOpacityLocal"
+ name="faintOpacity"
+ :fallback="previewTheme.opacity.faint"
+ />
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.underlay') }}</h4>
+ <ColorInput
+ v-model="underlayColorLocal"
+ name="underlay"
+ :label="$t('settings.style.advanced_colors.underlay')"
+ :fallback="previewTheme.colors.underlay"
+ />
+ <OpacityInput
+ v-model="underlayOpacityLocal"
+ name="underlayOpacity"
+ :fallback="previewTheme.opacity.underlay"
+ :disabled="underlayOpacityLocal === 'transparent'"
+ />
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.poll') }}</h4>
+ <ColorInput
+ v-model="pollColorLocal"
+ name="poll"
+ :label="$t('settings.background')"
+ :fallback="previewTheme.colors.poll"
+ />
+ <ColorInput
+ v-model="pollTextColorLocal"
+ name="pollText"
+ :label="$t('settings.text')"
+ :fallback="previewTheme.colors.pollText"
+ />
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.icons') }}</h4>
+ <ColorInput
+ v-model="iconColorLocal"
+ name="icon"
+ :label="$t('settings.style.advanced_colors.icons')"
+ :fallback="previewTheme.colors.icon"
+ />
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.highlight') }}</h4>
+ <ColorInput
+ v-model="highlightColorLocal"
+ name="highlight"
+ :label="$t('settings.background')"
+ :fallback="previewTheme.colors.highlight"
+ />
+ <ColorInput
+ v-model="highlightTextColorLocal"
+ name="highlightText"
+ :label="$t('settings.text')"
+ :fallback="previewTheme.colors.highlightText"
+ />
+ <ContrastRatio :contrast="previewContrast.highlightText" />
+ <ColorInput
+ v-model="highlightLinkColorLocal"
+ name="highlightLink"
+ :label="$t('settings.links')"
+ :fallback="previewTheme.colors.highlightLink"
+ />
+ <ContrastRatio :contrast="previewContrast.highlightLink" />
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.popover') }}</h4>
+ <ColorInput
+ v-model="popoverColorLocal"
+ name="popover"
+ :label="$t('settings.background')"
+ :fallback="previewTheme.colors.popover"
+ />
+ <OpacityInput
+ v-model="popoverOpacityLocal"
+ name="popoverOpacity"
+ :fallback="previewTheme.opacity.popover"
+ :disabled="popoverOpacityLocal === 'transparent'"
+ />
+ <ColorInput
+ v-model="popoverTextColorLocal"
+ name="popoverText"
+ :label="$t('settings.text')"
+ :fallback="previewTheme.colors.popoverText"
+ />
+ <ContrastRatio :contrast="previewContrast.popoverText" />
+ <ColorInput
+ v-model="popoverLinkColorLocal"
+ name="popoverLink"
+ :label="$t('settings.links')"
+ :fallback="previewTheme.colors.popoverLink"
+ />
+ <ContrastRatio :contrast="previewContrast.popoverLink" />
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.selectedPost') }}</h4>
+ <ColorInput
+ v-model="selectedPostColorLocal"
+ name="selectedPost"
+ :label="$t('settings.background')"
+ :fallback="previewTheme.colors.selectedPost"
+ />
+ <ColorInput
+ v-model="selectedPostTextColorLocal"
+ name="selectedPostText"
+ :label="$t('settings.text')"
+ :fallback="previewTheme.colors.selectedPostText"
+ />
+ <ContrastRatio :contrast="previewContrast.selectedPostText" />
+ <ColorInput
+ v-model="selectedPostLinkColorLocal"
+ name="selectedPostLink"
+ :label="$t('settings.links')"
+ :fallback="previewTheme.colors.selectedPostLink"
+ />
+ <ContrastRatio :contrast="previewContrast.selectedPostLink" />
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.selectedMenu') }}</h4>
+ <ColorInput
+ v-model="selectedMenuColorLocal"
+ name="selectedMenu"
+ :label="$t('settings.background')"
+ :fallback="previewTheme.colors.selectedMenu"
+ />
+ <ColorInput
+ v-model="selectedMenuTextColorLocal"
+ name="selectedMenuText"
+ :label="$t('settings.text')"
+ :fallback="previewTheme.colors.selectedMenuText"
+ />
+ <ContrastRatio :contrast="previewContrast.selectedMenuText" />
+ <ColorInput
+ v-model="selectedMenuLinkColorLocal"
+ name="selectedMenuLink"
+ :label="$t('settings.links')"
+ :fallback="previewTheme.colors.selectedMenuLink"
+ />
+ <ContrastRatio :contrast="previewContrast.selectedMenuLink" />
+ </div>
+ </div>
+
+ <div
+ :label="$t('settings.style.radii._tab_label')"
+ class="radius-container"
+ >
+ <div class="tab-header">
+ <p>{{ $t('settings.radii_help') }}</p>
+ <button
+ class="btn"
+ @click="clearRoundness"
+ >
+ {{ $t('settings.style.switcher.clear_all') }}
+ </button>
+ </div>
+ <RangeInput
+ v-model="btnRadiusLocal"
+ name="btnRadius"
+ :label="$t('settings.btnRadius')"
+ :fallback="previewTheme.radii.btn"
+ max="16"
+ hard-min="0"
+ />
+ <RangeInput
+ v-model="inputRadiusLocal"
+ name="inputRadius"
+ :label="$t('settings.inputRadius')"
+ :fallback="previewTheme.radii.input"
+ max="9"
+ hard-min="0"
+ />
+ <RangeInput
+ v-model="checkboxRadiusLocal"
+ name="checkboxRadius"
+ :label="$t('settings.checkboxRadius')"
+ :fallback="previewTheme.radii.checkbox"
+ max="16"
+ hard-min="0"
+ />
+ <RangeInput
+ v-model="panelRadiusLocal"
+ name="panelRadius"
+ :label="$t('settings.panelRadius')"
+ :fallback="previewTheme.radii.panel"
+ max="50"
+ hard-min="0"
+ />
+ <RangeInput
+ v-model="avatarRadiusLocal"
+ name="avatarRadius"
+ :label="$t('settings.avatarRadius')"
+ :fallback="previewTheme.radii.avatar"
+ max="28"
+ hard-min="0"
+ />
+ <RangeInput
+ v-model="avatarAltRadiusLocal"
+ name="avatarAltRadius"
+ :label="$t('settings.avatarAltRadius')"
+ :fallback="previewTheme.radii.avatarAlt"
+ max="28"
+ hard-min="0"
+ />
+ <RangeInput
+ v-model="attachmentRadiusLocal"
+ name="attachmentRadius"
+ :label="$t('settings.attachmentRadius')"
+ :fallback="previewTheme.radii.attachment"
+ max="50"
+ hard-min="0"
+ />
+ <RangeInput
+ v-model="tooltipRadiusLocal"
+ name="tooltipRadius"
+ :label="$t('settings.tooltipRadius')"
+ :fallback="previewTheme.radii.tooltip"
+ max="50"
+ hard-min="0"
+ />
+ </div>
+
+ <div
+ :label="$t('settings.style.shadows._tab_label')"
+ class="shadow-container"
+ >
+ <div class="tab-header shadow-selector">
+ <div class="select-container">
+ {{ $t('settings.style.shadows.component') }}
+ <label
+ for="shadow-switcher"
+ class="select"
+ >
+ <select
+ id="shadow-switcher"
+ v-model="shadowSelected"
+ class="shadow-switcher"
+ >
+ <option
+ v-for="shadow in shadowsAvailable"
+ :key="shadow"
+ :value="shadow"
+ >
+ {{ $t('settings.style.shadows.components.' + shadow) }}
+ </option>
+ </select>
+ <i class="icon-down-open" />
+ </label>
+ </div>
+ <div class="override">
+ <label
+ for="override"
+ class="label"
+ >
+ {{ $t('settings.style.shadows.override') }}
+ </label>
+ <input
+ id="override"
+ v-model="currentShadowOverriden"
+ name="override"
+ class="input-override"
+ type="checkbox"
+ >
+ <label
+ class="checkbox-label"
+ for="override"
+ />
+ </div>
+ <button
+ class="btn"
+ @click="clearShadows"
+ >
+ {{ $t('settings.style.switcher.clear_all') }}
+ </button>
+ </div>
+ <ShadowControl
+ v-model="currentShadow"
+ :ready="!!currentShadowFallback"
+ :fallback="currentShadowFallback"
+ />
+ <div v-if="shadowSelected === 'avatar' || shadowSelected === 'avatarStatus'">
+ <i18n
+ path="settings.style.shadows.filter_hint.always_drop_shadow"
+ tag="p"
+ >
+ <code>filter: drop-shadow()</code>
+ </i18n>
+ <p>{{ $t('settings.style.shadows.filter_hint.avatar_inset') }}</p>
+ <i18n
+ path="settings.style.shadows.filter_hint.drop_shadow_syntax"
+ tag="p"
+ >
+ <code>drop-shadow</code>
+ <code>spread-radius</code>
+ <code>inset</code>
+ </i18n>
+ <i18n
+ path="settings.style.shadows.filter_hint.inset_classic"
+ tag="p"
+ >
+ <code>box-shadow</code>
+ </i18n>
+ <p>{{ $t('settings.style.shadows.filter_hint.spread_zero') }}</p>
+ </div>
+ </div>
+
+ <div
+ :label="$t('settings.style.fonts._tab_label')"
+ class="fonts-container"
+ >
+ <div class="tab-header">
+ <p>{{ $t('settings.style.fonts.help') }}</p>
+ <button
+ class="btn"
+ @click="clearFonts"
+ >
+ {{ $t('settings.style.switcher.clear_all') }}
+ </button>
+ </div>
+ <FontControl
+ v-model="fontsLocal.interface"
+ name="ui"
+ :label="$t('settings.style.fonts.components.interface')"
+ :fallback="previewTheme.fonts.interface"
+ no-inherit="1"
+ />
+ <FontControl
+ v-model="fontsLocal.input"
+ name="input"
+ :label="$t('settings.style.fonts.components.input')"
+ :fallback="previewTheme.fonts.input"
+ />
+ <FontControl
+ v-model="fontsLocal.post"
+ name="post"
+ :label="$t('settings.style.fonts.components.post')"
+ :fallback="previewTheme.fonts.post"
+ />
+ <FontControl
+ v-model="fontsLocal.postCode"
+ name="postCode"
+ :label="$t('settings.style.fonts.components.postCode')"
+ :fallback="previewTheme.fonts.postCode"
+ />
+ </div>
+ </tab-switcher>
+ </keep-alive>
+
+ <div class="apply-container">
+ <button
+ class="btn submit"
+ :disabled="!themeValid"
+ @click="setCustomTheme"
+ >
+ {{ $t('general.apply') }}
+ </button>
+ <button
+ class="btn"
+ @click="clearAll"
+ >
+ {{ $t('settings.style.switcher.reset') }}
+ </button>
+ </div>
+ </div>
+</template>
+
+<script src="./theme_tab.js"></script>
+
+<style src="./theme_tab.scss" lang="scss"></style>
diff --git a/src/components/settings_modal/tabs/version_tab.js b/src/components/settings_modal/tabs/version_tab.js
new file mode 100644
index 00000000..616bdadf
--- /dev/null
+++ b/src/components/settings_modal/tabs/version_tab.js
@@ -0,0 +1,24 @@
+import { extractCommit } from 'src/services/version/version.service'
+
+const pleromaFeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma-fe/commit/'
+const pleromaBeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma/commit/'
+
+const VersionTab = {
+ data () {
+ const instance = this.$store.state.instance
+ return {
+ backendVersion: instance.backendVersion,
+ frontendVersion: instance.frontendVersion
+ }
+ },
+ computed: {
+ frontendVersionLink () {
+ return pleromaFeCommitUrl + this.frontendVersion
+ },
+ backendVersionLink () {
+ return pleromaBeCommitUrl + extractCommit(this.backendVersion)
+ }
+ }
+}
+
+export default VersionTab
diff --git a/src/components/settings_modal/tabs/version_tab.vue b/src/components/settings_modal/tabs/version_tab.vue
new file mode 100644
index 00000000..d35ff25e
--- /dev/null
+++ b/src/components/settings_modal/tabs/version_tab.vue
@@ -0,0 +1,31 @@
+<template>
+ <div :label="$t('settings.version.title')">
+ <div class="setting-item">
+ <ul class="setting-list">
+ <li>
+ <p>{{ $t('settings.version.backend_version') }}</p>
+ <ul class="option-list">
+ <li>
+ <a
+ :href="backendVersionLink"
+ target="_blank"
+ >{{ backendVersion }}</a>
+ </li>
+ </ul>
+ </li>
+ <li>
+ <p>{{ $t('settings.version.frontend_version') }}</p>
+ <ul class="option-list">
+ <li>
+ <a
+ :href="frontendVersionLink"
+ target="_blank"
+ >{{ frontendVersion }}</a>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </div>
+ </div>
+</template>
+<script src="./version_tab.js">