commit: a5de8db579f6c50c1bbe5659e82bc29eaa588e7f
parent: e47d0f210317c6e04e15fb89eb8f1d469137b779
Author: HJ <30-hj@users.noreply.git.pleroma.social>
Date: Sat, 6 Jun 2020 21:24:47 +0000
Merge branch 'settings-modal' into 'develop'
Settings modal
See merge request pleroma/pleroma-fe!1118
Diffstat:
68 files changed, 4809 insertions(+), 4089 deletions(-)
diff --git a/package.json b/package.json
@@ -29,11 +29,11 @@
"portal-vue": "^2.1.4",
"sanitize-html": "^1.13.0",
"v-click-outside": "^2.1.1",
- "vue": "^2.5.13",
+ "vue": "^2.6.11",
"vue-chat-scroll": "^1.2.1",
"vue-i18n": "^7.3.2",
"vue-router": "^3.0.1",
- "vue-template-compiler": "^2.3.4",
+ "vue-template-compiler": "^2.6.11",
"vuelidate": "^0.7.4",
"vuex": "^3.0.1",
"whatwg-fetch": "^2.0.3"
diff --git a/src/App.js b/src/App.js
@@ -6,6 +6,7 @@ import InstanceSpecificPanel from './components/instance_specific_panel/instance
import FeaturesPanel from './components/features_panel/features_panel.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
import ChatPanel from './components/chat_panel/chat_panel.vue'
+import SettingsModal from './components/settings_modal/settings_modal.vue'
import MediaModal from './components/media_modal/media_modal.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue'
import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue'
@@ -29,6 +30,7 @@ export default {
SideDrawer,
MobilePostStatusButton,
MobileNav,
+ SettingsModal,
UserReportingModal,
PostStatusModal
},
@@ -117,6 +119,9 @@ export default {
onSearchBarToggled (hidden) {
this.searchBarHidden = hidden
},
+ openSettingsModal () {
+ this.$store.dispatch('openSettingsModal')
+ },
updateMobileState () {
const mobileLayout = windowWidth() <= 800
const changed = mobileLayout !== this.isMobileLayout
diff --git a/src/App.scss b/src/App.scss
@@ -566,7 +566,7 @@ main-router {
min-height: 0;
box-sizing: border-box;
margin: 0;
- margin-left: .25em;
+ margin-left: .5em;
min-width: 1px;
align-self: stretch;
}
@@ -860,51 +860,6 @@ nav {
}
}
-.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;
- }
-
- .btn {
- min-height: 28px;
- min-width: 10em;
- padding: 0 2em;
- }
-
- .number-input {
- max-width: 6em;
- }
-}
.select-multiple {
display: flex;
.option-list {
diff --git a/src/App.vue b/src/App.vue
@@ -46,15 +46,16 @@
@toggled="onSearchBarToggled"
@click.stop.native
/>
- <router-link
+ <a
+ href="#"
class="mobile-hidden"
- :to="{ name: 'settings'}"
+ @click.stop="openSettingsModal"
>
<i
class="button-icon icon-cog nav-icon"
:title="$t('nav.preferences')"
/>
- </router-link>
+ </a>
<a
v-if="currentUser && currentUser.role === 'admin'"
href="/pleroma/admin/#/login-pleroma"
@@ -125,6 +126,7 @@
<MobilePostStatusButton />
<UserReportingModal />
<PostStatusModal />
+ <SettingsModal />
<portal-target name="modal" />
</div>
</template>
diff --git a/src/boot/routes.js b/src/boot/routes.js
@@ -7,10 +7,8 @@ import Interactions from 'components/interactions/interactions.vue'
import DMs from 'components/dm_timeline/dm_timeline.vue'
import UserProfile from 'components/user_profile/user_profile.vue'
import Search from 'components/search/search.vue'
-import Settings from 'components/settings/settings.vue'
import Registration from 'components/registration/registration.vue'
import PasswordReset from 'components/password_reset/password_reset.vue'
-import UserSettings from 'components/user_settings/user_settings.vue'
import FollowRequests from 'components/follow_requests/follow_requests.vue'
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
import Notifications from 'components/notifications/notifications.vue'
@@ -56,12 +54,10 @@ export default (store) => {
{ name: 'external-user-profile', path: '/users/:id', component: UserProfile },
{ name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute },
{ name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute },
- { name: 'settings', path: '/settings', component: Settings },
{ name: 'registration', path: '/registration', component: Registration },
{ name: 'password-reset', path: '/password-reset', component: PasswordReset, props: true },
{ name: 'registration-token', path: '/registration/:token', component: Registration },
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute },
- { name: 'user-settings', path: '/user-settings', component: UserSettings, beforeEnter: validateAuthenticatedRoute },
{ name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute },
{ name: 'login', path: '/login', component: AuthForm },
{ name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) },
diff --git a/src/components/async_component_error/async_component_error.vue b/src/components/async_component_error/async_component_error.vue
@@ -0,0 +1,41 @@
+<template>
+ <div class="async-component-error">
+ <div>
+ <h4>
+ {{ $t('general.generic_error') }}
+ </h4>
+ <p>
+ {{ $t('general.error_retry') }}
+ </p>
+ <button
+ class="btn"
+ @click="retry"
+ >
+ {{ $t('general.retry') }}
+ </button>
+ </div>
+ </div>
+</template>
+
+<script>
+export default {
+ methods: {
+ retry () {
+ this.$emit('resetAsyncComponent')
+ }
+ }
+}
+</script>
+
+<style lang="scss">
+.async-component-error {
+ display: flex;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+ .btn {
+ margin: .5em;
+ padding: .5em 2em;
+ }
+}
+</style>
diff --git a/src/components/modal/modal.vue b/src/components/modal/modal.vue
@@ -1,8 +1,9 @@
<template>
<div
v-show="isOpen"
- v-body-scroll-lock="isOpen"
+ v-body-scroll-lock="isOpen && !noBackground"
class="modal-view"
+ :class="classes"
@click.self="$emit('backdropClicked')"
>
<slot />
@@ -15,6 +16,18 @@ export default {
isOpen: {
type: Boolean,
default: true
+ },
+ noBackground: {
+ type: Boolean,
+ default: false
+ }
+ },
+ computed: {
+ classes () {
+ return {
+ 'modal-background': !this.noBackground,
+ 'open': this.isOpen
+ }
}
}
}
@@ -32,12 +45,22 @@ export default {
justify-content: center;
align-items: center;
overflow: auto;
+ pointer-events: none;
animation-duration: 0.2s;
- background-color: rgba(0, 0, 0, 0.5);
animation-name: modal-background-fadein;
+ opacity: 0;
+
+ > * {
+ pointer-events: initial;
+ }
+
+ &.modal-background {
+ pointer-events: initial;
+ background-color: rgba(0, 0, 0, 0.5);
+ }
- body:not(.scroll-locked) & {
- opacity: 0;
+ &.open {
+ opacity: 1;
}
}
diff --git a/src/components/panel_loading/panel_loading.vue b/src/components/panel_loading/panel_loading.vue
@@ -0,0 +1,29 @@
+<template>
+ <div class="panel-loading">
+ <span class="loading-text">
+ <i class="icon-spin4 animate-spin" />
+ {{ $t('general.loading') }}
+ </span>
+ </div>
+</template>
+
+<style lang="scss">
+@import 'src/_variables.scss';
+
+.panel-loading {
+ display: flex;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+ font-size: 2em;
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ .loading-text i {
+ font-size: 3em;
+ line-height: 0;
+ vertical-align: middle;
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ }
+}
+</style>
diff --git a/src/components/post_status_modal/post_status_modal.js b/src/components/post_status_modal/post_status_modal.js
@@ -13,9 +13,6 @@ const PostStatusModal = {
}
},
computed: {
- isLoggedIn () {
- return !!this.$store.state.users.currentUser
- },
modalActivated () {
return this.$store.state.postStatus.modalActivated
},
diff --git a/src/components/post_status_modal/post_status_modal.vue b/src/components/post_status_modal/post_status_modal.vue
@@ -1,6 +1,5 @@
<template>
<Modal
- v-if="isLoggedIn && !resettingForm"
:is-open="modalActivated"
class="post-form-modal-view"
@backdropClicked="closeModal"
diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js
@@ -1,132 +0,0 @@
-/* eslint-env browser */
-import { filter, trim } from 'lodash'
-
-import TabSwitcher from '../tab_switcher/tab_switcher.js'
-import StyleSwitcher from '../style_switcher/style_switcher.vue'
-import InterfaceLanguageSwitcher from '../interface_language_switcher/interface_language_switcher.vue'
-import { extractCommit } from '../../services/version/version.service'
-import { instanceDefaultProperties, defaultState as configDefaultState } from '../../modules/config.js'
-import Checkbox from '../checkbox/checkbox.vue'
-
-const pleromaFeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma-fe/commit/'
-const pleromaBeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma/commit/'
-
-const multiChoiceProperties = [
- 'postContentType',
- 'subjectLineBehavior'
-]
-
-const settings = {
- data () {
- const instance = this.$store.state.instance
-
- 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'),
-
- backendVersion: instance.backendVersion,
- frontendVersion: instance.frontendVersion,
- muteWordsStringLocal: this.$store.getters.mergedConfig.muteWords.join('\n')
- }
- },
- components: {
- TabSwitcher,
- StyleSwitcher,
- InterfaceLanguageSwitcher,
- Checkbox
- },
- computed: {
- user () {
- return this.$store.state.users.currentUser
- },
- currentSaveStateNotice () {
- return this.$store.state.interface.settings.currentSaveStateNotice
- },
- postFormats () {
- return this.$store.state.instance.postFormats || []
- },
- instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
- frontendVersionLink () {
- return pleromaFeCommitUrl + this.frontendVersion
- },
- backendVersionLink () {
- return pleromaBeCommitUrl + extractCommit(this.backendVersion)
- },
- // 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)
- 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)
- })
- }
- },
- 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 })
- })
- }
- }
- },
- // Updating nested properties
- watch: {
- notificationVisibility: {
- handler (value) {
- this.$store.dispatch('setOption', {
- name: 'notificationVisibility',
- value: this.$store.getters.mergedConfig.notificationVisibility
- })
- },
- deep: true
- }
- }
-}
-
-export default settings
diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue
@@ -1,424 +0,0 @@
-<template>
- <div class="settings panel panel-default">
- <div class="panel-heading">
- <div class="title">
- {{ $t('settings.settings') }}
- </div>
-
- <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>
- </div>
- <div class="panel-body">
- <keep-alive>
- <tab-switcher>
- <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>
-
- <div :label="$t('settings.theme')">
- <div class="setting-item">
- <style-switcher />
- </div>
- </div>
-
- <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>
- <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>
- </tab-switcher>
- </keep-alive>
- </div>
- </div>
-</template>
-
-<script src="./settings.js">
-</script>
diff --git a/src/components/settings_modal/helpers/shared_computed_object.js 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,124 @@
+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',
+ newDomainToMute: ''
+ }
+ },
+ created () {
+ this.$store.dispatch('fetchTokens')
+ },
+ components: {
+ TabSwitcher,
+ BlockList,
+ MuteList,
+ DomainMuteList,
+ BlockCard,
+ MuteCard,
+ DomainMuteCard,
+ ProgressButton,
+ Autosuggest,
+ Checkbox
+ },
+ 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.$store.state.users.currentUser.id
+ })
+ },
+ filterUnMutedUsers (userIds) {
+ return reject(userIds, (userId) => {
+ const relationship = this.$store.getters.relationship(this.userId)
+ return relationship.muting || userId === this.$store.state.users.currentUser.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)
+ },
+ unmuteDomains (domains) {
+ return this.$store.dispatch('unmuteDomains', domains)
+ },
+ muteDomain () {
+ return this.$store.dispatch('muteDomain', this.newDomainToMute)
+ .then(() => { this.newDomainToMute = '' })
+ }
+ }
+}
+
+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
@@ -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
@@ -0,0 +1,176 @@
+<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">
+ <input
+ v-model="newDomainToMute"
+ :placeholder="$t('settings.type_domains_to_mute')"
+ type="text"
+ @keyup.enter="muteDomain"
+ >
+ <ProgressButton
+ class="btn btn-default domain-mute-button"
+ :click="muteDomain"
+ >
+ {{ $t('domain_mute_card.mute') }}
+ <template slot="progress">
+ {{ $t('domain_mute_card.mute_progress') }}
+ </template>
+ </ProgressButton>
+ </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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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/user_settings/confirm.js b/src/components/settings_modal/tabs/security_tab/confirm.js
diff --git a/src/components/user_settings/confirm.vue b/src/components/settings_modal/tabs/security_tab/confirm.vue
diff --git a/src/components/user_settings/mfa.js b/src/components/settings_modal/tabs/security_tab/mfa.js
diff --git a/src/components/settings_modal/tabs/security_tab/mfa.vue b/src/components/settings_modal/tabs/security_tab/mfa.vue
@@ -0,0 +1,174 @@
+<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 {
+ overflow: hidden;
+ 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/user_settings/mfa_backup_codes.js b/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.js
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
@@ -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/user_settings/mfa_totp.js b/src/components/settings_modal/tabs/security_tab/mfa_totp.js
diff --git a/src/components/user_settings/mfa_totp.vue b/src/components/settings_modal/tabs/security_tab/mfa_totp.vue
diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.js 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
@@ -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/style_switcher/preview.vue b/src/components/settings_modal/tabs/theme_tab/preview.vue
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js 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
@@ -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
@@ -0,0 +1,958 @@
+<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("settings.export_theme")"
+ :import-label="$t("settings.import_theme")"
+ :import-failed-text="$t("settings.invalid_theme_imported")"
+ :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" />
+ <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
@@ -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
@@ -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">
diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
@@ -62,6 +62,9 @@ const SideDrawer = {
},
touchMove (e) {
GestureService.updateSwipe(e, this.closeGesture)
+ },
+ openSettingsModal () {
+ this.$store.dispatch('openSettingsModal')
}
}
}
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
@@ -122,9 +122,12 @@
</router-link>
</li>
<li @click="toggleDrawer">
- <router-link :to="{ name: 'settings' }">
+ <a
+ href="#"
+ @click="openSettingsModal"
+ >
<i class="button-icon icon-cog" /> {{ $t("settings.settings") }}
- </router-link>
+ </a>
</li>
<li @click="toggleDrawer">
<router-link :to="{ name: 'about'}">
diff --git a/src/components/style_switcher/style_switcher.js b/src/components/style_switcher/style_switcher.js
@@ -1,758 +0,0 @@
-import { set, delete as del } from 'vue'
-import {
- rgb2hex,
- hex2rgb,
- getContrastRatioLayers
-} from '../../services/color_convert/color_convert.js'
-import {
- DEFAULT_SHADOWS,
- generateColors,
- generateShadows,
- generateRadii,
- generateFonts,
- composePreset,
- getThemes,
- shadows2to3,
- colors2to3
-} from '../../services/style_setter/style_setter.js'
-import {
- SLOT_INHERITANCE
-} from '../../services/theme_data/pleromafe.js'
-import {
- CURRENT_VERSION,
- OPACITIES,
- getLayers,
- getOpacitySlot
-} from '../../services/theme_data/theme_data.service.js'
-import ColorInput from '../color_input/color_input.vue'
-import RangeInput from '../range_input/range_input.vue'
-import OpacityInput from '../opacity_input/opacity_input.vue'
-import ShadowControl from '../shadow_control/shadow_control.vue'
-import FontControl from '../font_control/font_control.vue'
-import ContrastRatio from '../contrast_ratio/contrast_ratio.vue'
-import TabSwitcher from '../tab_switcher/tab_switcher.js'
-import Preview from './preview.vue'
-import ExportImport from '../export_import/export_import.vue'
-import Checkbox from '../checkbox/checkbox.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/style_switcher/style_switcher.scss b/src/components/style_switcher/style_switcher.scss
@@ -1,335 +0,0 @@
-@import '../../_variables.scss';
-.style-switcher {
- .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;
- }
- }
- }
-
- .tab-switcher {
- margin: 0 -1em;
- }
-
- .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;
-
- .btn {
- min-width: 1px;
- flex: 0 auto;
- padding: 0 1em;
- }
-
- p {
- flex: 1;
- margin: 0;
- margin-right: .5em;
- }
-
- margin-bottom: 1em;
- }
-
- .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 -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;
- }
-
- .btn {
- margin-left: .25em;
- margin-right: .25em;
- }
-}
diff --git a/src/components/style_switcher/style_switcher.vue b/src/components/style_switcher/style_switcher.vue
@@ -1,956 +0,0 @@
-<template>
- <div class="style-switcher">
- <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("settings.export_theme")"
- :import-label="$t("settings.import_theme")"
- :import-failed-text="$t("settings.invalid_theme_imported")"
- :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>
- <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>
- <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" />
- <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="./style_switcher.js"></script>
-
-<style src="./style_switcher.scss" lang="scss"></style>
diff --git a/src/components/tab_switcher/tab_switcher.js b/src/components/tab_switcher/tab_switcher.js
@@ -24,6 +24,11 @@ export default Vue.component('tab-switcher', {
required: false,
type: Boolean,
default: false
+ },
+ sideTabBar: {
+ required: false,
+ type: Boolean,
+ default: false
}
},
data () {
@@ -55,6 +60,9 @@ export default Vue.component('tab-switcher', {
this.onSwitch.call(null, this.$slots.default[index].key)
}
this.active = index
+ if (this.scrollableTabs) {
+ this.$refs.contents.scrollTop = 0
+ }
}
}
},
@@ -64,7 +72,6 @@ export default Vue.component('tab-switcher', {
if (!slot.tag) return
const classesTab = ['tab']
const classesWrapper = ['tab-wrapper']
-
if (this.activeIndex === index) {
classesTab.push('active')
classesWrapper.push('active')
@@ -87,8 +94,14 @@ export default Vue.component('tab-switcher', {
<button
disabled={slot.data.attrs.disabled}
onClick={this.activateTab(index)}
- class={classesTab.join(' ')}>
- {slot.data.attrs.label}</button>
+ class={classesTab.join(' ')}
+ type="button"
+ >
+ {!slot.data.attrs.icon ? '' : (<i class={'tab-icon icon-' + slot.data.attrs.icon}/>)}
+ <span class="text">
+ {slot.data.attrs.label}
+ </span>
+ </button>
</div>
)
})
@@ -96,20 +109,32 @@ export default Vue.component('tab-switcher', {
const contents = this.$slots.default.map((slot, index) => {
if (!slot.tag) return
const active = this.activeIndex === index
- if (this.renderOnlyFocused) {
- return active
- ? <div class="active">{slot}</div>
- : <div class="hidden"></div>
+ const classes = [ active ? 'active' : 'hidden' ]
+ if (slot.data.attrs.fullHeight) {
+ classes.push('full-height')
}
- return <div class={active ? 'active' : 'hidden' }>{slot}</div>
+ const renderSlot = (!this.renderOnlyFocused || active)
+ ? slot
+ : ''
+
+ return (
+ <div class={classes}>
+ {
+ this.sideTabBar
+ ? <h1 class="mobile-label">{slot.data.attrs.label}</h1>
+ : ''
+ }
+ {renderSlot}
+ </div>
+ )
})
return (
- <div class="tab-switcher">
+ <div class={'tab-switcher ' + (this.sideTabBar ? 'side-tabs' : 'top-tabs')}>
<div class="tabs">
{tabs}
</div>
- <div class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}>
+ <div ref="contents" class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}>
{contents}
</div>
</div>
diff --git a/src/components/tab_switcher/tab_switcher.scss b/src/components/tab_switcher/tab_switcher.scss
@@ -2,7 +2,144 @@
.tab-switcher {
display: flex;
- flex-direction: column;
+
+ .tab-icon {
+ font-size: 2em;
+ display: block;
+ }
+
+ &.top-tabs {
+ flex-direction: column;
+
+ > .tabs {
+ width: 100%;
+ overflow-y: hidden;
+ overflow-x: auto;
+ padding-top: 5px;
+ flex-direction: row;
+
+ &::after, &::before {
+ content: '';
+ flex: 1 1 auto;
+ border-bottom: 1px solid;
+ border-bottom-color: $fallback--border;
+ border-bottom-color: var(--border, $fallback--border);
+ }
+ .tab-wrapper {
+ height: 28px;
+
+ &:not(.active)::after {
+ left: 0;
+ right: 0;
+ bottom: 0;
+ border-bottom: 1px solid;
+ border-bottom-color: $fallback--border;
+ border-bottom-color: var(--border, $fallback--border);
+ }
+ }
+ .tab {
+ width: 100%;
+ min-width: 1px;
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ padding-bottom: 99px;
+ margin-bottom: 6px - 99px;
+ }
+ }
+ .contents.scrollable-tabs {
+ flex-basis: 0;
+ }
+ }
+
+ &.side-tabs {
+ flex-direction: row;
+
+ @media all and (max-width: 800px) {
+ overflow-x: auto;
+ }
+
+ > .contents {
+ flex: 1 1 auto;
+ }
+
+ > .tabs {
+ flex: 0 0 auto;
+ overflow-y: auto;
+ overflow-x: hidden;
+ flex-direction: column;
+
+ &::after, &::before {
+ flex-shrink: 0;
+ flex-basis: .5em;
+ content: '';
+ border-right: 1px solid;
+ border-right-color: $fallback--border;
+ border-right-color: var(--border, $fallback--border);
+ }
+
+ &::after {
+ flex-grow: 1;
+ }
+
+ &::before {
+ flex-grow: 0;
+ }
+
+ .tab-wrapper {
+ min-width: 10em;
+ display: flex;
+ flex-direction: column;
+
+ @media all and (max-width: 800px) {
+ min-width: 1em;
+ }
+
+ &:not(.active)::after {
+ top: 0;
+ right: 0;
+ bottom: 0;
+ border-right: 1px solid;
+ border-right-color: $fallback--border;
+ border-right-color: var(--border, $fallback--border);
+ }
+
+ &::before {
+ flex: 0 0 6px;
+ content: '';
+ border-right: 1px solid;
+ border-right-color: $fallback--border;
+ border-right-color: var(--border, $fallback--border);
+ }
+
+ &:last-child .tab {
+ margin-bottom: 0;
+ }
+ }
+
+ .tab {
+ flex: 1;
+ box-sizing: content-box;
+ min-width: 10em;
+ min-width: 1px;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ padding-left: 1em;
+ padding-right: calc(1em + 200px);
+ margin-right: -200px;
+ margin-left: 1em;
+
+ @media all and (max-width: 800px) {
+ padding-left: .25em;
+ padding-right: calc(.25em + 200px);
+ margin-right: calc(.25em - 200px);
+ margin-left: .25em;
+ .text {
+ display: none
+ }
+ }
+ }
+ }
+ }
.contents {
flex: 1 0 auto;
@@ -11,88 +148,89 @@
.hidden {
display: none;
}
+ .full-height:not(.hidden) {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ > *:not(.mobile-label) {
+ flex: 1;
+ }
+ }
&.scrollable-tabs {
- flex-basis: 0;
overflow-y: auto;
}
}
- .tabs {
- display: flex;
+
+ .tab {
position: relative;
- width: 100%;
- overflow-y: hidden;
- overflow-x: auto;
- padding-top: 5px;
- box-sizing: border-box;
+ white-space: nowrap;
+ padding: 6px 1em;
+ background-color: $fallback--fg;
+ background-color: var(--tab, $fallback--fg);
- &::after, &::before {
- display: block;
- content: '';
- flex: 1 1 auto;
- border-bottom: 1px solid;
- border-bottom-color: $fallback--border;
- border-bottom-color: var(--border, $fallback--border);
+ &, &:active .tab-icon {
+ color: $fallback--text;
+ color: var(--tabText, $fallback--text);
}
- .tab-wrapper {
- height: 28px;
- position: relative;
- display: flex;
- flex: 0 0 auto;
+ &:not(.active) {
+ z-index: 4;
- .tab {
- width: 100%;
- min-width: 1px;
- position: relative;
- border-bottom-left-radius: 0;
- border-bottom-right-radius: 0;
- padding: 6px 1em;
- padding-bottom: 99px;
- margin-bottom: 6px - 99px;
- white-space: nowrap;
+ &:hover {
+ z-index: 6;
+ }
+ }
- color: $fallback--text;
- color: var(--tabText, $fallback--text);
- background-color: $fallback--fg;
- background-color: var(--tab, $fallback--fg);
+ &.active {
+ background: transparent;
+ z-index: 5;
+ color: $fallback--text;
+ color: var(--tabActiveText, $fallback--text);
+ }
- &:not(.active) {
- z-index: 4;
+ img {
+ max-height: 26px;
+ vertical-align: top;
+ margin-top: -5px;
+ }
+ }
- &:hover {
- z-index: 6;
- }
- }
+ .tabs {
+ display: flex;
+ position: relative;
+ box-sizing: border-box;
- &.active {
- background: transparent;
- z-index: 5;
- color: $fallback--text;
- color: var(--tabActiveText, $fallback--text);
- }
+ &::after, &::before {
+ display: block;
+ flex: 1 1 auto;
+ }
+ }
- img {
- max-height: 26px;
- vertical-align: top;
- margin-top: -5px;
- }
- }
+ .tab-wrapper {
+ position: relative;
+ display: flex;
+ flex: 0 0 auto;
- &:not(.active) {
- &::after {
- content: '';
- position: absolute;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 7;
- border-bottom: 1px solid;
- border-bottom-color: $fallback--border;
- border-bottom-color: var(--border, $fallback--border);
- }
+ &:not(.active) {
+ &::after {
+ content: '';
+ position: absolute;
+ z-index: 7;
}
}
+ }
+ .mobile-label {
+ padding-left: .3em;
+ padding-bottom: .25em;
+ margin-top: .5em;
+ margin-left: .2em;
+ margin-bottom: .25em;
+ border-bottom: 1px solid var(--border, $fallback--border);
+
+ @media all and (min-width: 800px) {
+ display: none;
+ }
}
}
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
@@ -50,15 +50,6 @@
>
{{ user.name }}
</div>
- <router-link
- v-if="!isOtherUser"
- :to="{ name: 'user-settings' }"
- >
- <i
- class="button-icon icon-wrench usersettings"
- :title="$t('tool_tip.user_settings')"
- />
- </router-link>
<a
v-if="isOtherUser && !user.is_local"
:href="user.statusnet_profile_url"
@@ -118,7 +109,7 @@
type="color"
>
<label
- for="style-switcher"
+ for="theme_tab"
class="userHighlightSel select"
>
<select
diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
@@ -3,6 +3,7 @@ import UserCard from '../user_card/user_card.vue'
import FollowCard from '../follow_card/follow_card.vue'
import Timeline from '../timeline/timeline.vue'
import Conversation from '../conversation/conversation.vue'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
import List from '../list/list.vue'
import withLoadMore from '../../hocs/with_load_more/with_load_more'
@@ -146,6 +147,7 @@ const UserProfile = {
FollowerList,
FriendList,
FollowCard,
+ TabSwitcher,
Conversation
}
}
diff --git a/src/components/user_settings/mfa.vue b/src/components/user_settings/mfa.vue
@@ -1,173 +0,0 @@
-<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';
-.warning {
- color: $fallback--cOrange;
- color: var(--cOrange, $fallback--cOrange);
-}
-.mfa-settings {
- .mfa-heading, .method-item {
- overflow: hidden;
- display: flex;
- flex-wrap: wrap;
- justify-content: space-between;
- align-items: baseline;
- }
-
- .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/user_settings/mfa_backup_codes.vue b/src/components/user_settings/mfa_backup_codes.vue
@@ -1,33 +0,0 @@
-<template>
- <div>
- <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';
-
-.warning {
- color: $fallback--cOrange;
- color: var(--cOrange, $fallback--cOrange);
-}
-.backup-codes {
- font-family: var(--postCodeFont, monospace);
-}
-</style>
diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js
@@ -1,393 +0,0 @@
-import unescape from 'lodash/unescape'
-import get from 'lodash/get'
-import map from 'lodash/map'
-import reject from 'lodash/reject'
-import TabSwitcher from '../tab_switcher/tab_switcher.js'
-import ImageCropper from '../image_cropper/image_cropper.vue'
-import StyleSwitcher from '../style_switcher/style_switcher.vue'
-import ScopeSelector from '../scope_selector/scope_selector.vue'
-import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
-import BlockCard from '../block_card/block_card.vue'
-import MuteCard from '../mute_card/mute_card.vue'
-import DomainMuteCard from '../domain_mute_card/domain_mute_card.vue'
-import SelectableList from '../selectable_list/selectable_list.vue'
-import ProgressButton from '../progress_button/progress_button.vue'
-import EmojiInput from '../emoji_input/emoji_input.vue'
-import suggestor from '../emoji_input/suggestor.js'
-import Autosuggest from '../autosuggest/autosuggest.vue'
-import Importer from '../importer/importer.vue'
-import Exporter from '../exporter/exporter.vue'
-import withSubscription from '../../hocs/with_subscription/with_subscription'
-import Checkbox from '../checkbox/checkbox.vue'
-import Mfa from './mfa.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 UserSettings = {
- data () {
- return {
- newEmail: '',
- 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,
- changeEmailError: false,
- changeEmailPassword: '',
- changedEmail: false,
- deletingAccount: false,
- deleteAccountConfirmPasswordInput: '',
- deleteAccountError: false,
- changePasswordInputs: [ '', '', '' ],
- changedPassword: false,
- changePasswordError: false,
- activeTab: 'profile',
- notificationSettings: this.$store.state.users.currentUser.notification_settings,
- newDomainToMute: ''
- }
- },
- created () {
- this.$store.dispatch('fetchTokens')
- },
- components: {
- StyleSwitcher,
- ScopeSelector,
- TabSwitcher,
- ImageCropper,
- BlockList,
- MuteList,
- DomainMuteList,
- EmojiInput,
- Autosuggest,
- BlockCard,
- MuteCard,
- DomainMuteCard,
- ProgressButton,
- Importer,
- Exporter,
- Mfa,
- 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
- ] })
- },
- pleromaBackend () {
- return this.$store.state.instance.pleromaBackend
- },
- minimalScopesMode () {
- return this.$store.state.instance.minimalScopesMode
- },
- vis () {
- return {
- public: { selected: this.newDefaultScope === 'public' },
- unlisted: { selected: this.newDefaultScope === 'unlisted' },
- private: { selected: this.newDefaultScope === 'private' },
- direct: { selected: this.newDefaultScope === 'direct' }
- }
- },
- currentSaveStateNotice () {
- return this.$store.state.interface.settings.currentSaveStateNotice
- },
- 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: {
- 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)
- })
- },
- updateNotificationSettings () {
- this.$store.state.api.backendInteractor
- .updateNotificationSettings({ settings: this.notificationSettings })
- },
- 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 })
- 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)
- }
- })
- },
- clearUploadError (slot) {
- this[slot + 'UploadError'] = null
- },
- 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
- })
- },
- 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')
- },
- 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)
- },
- 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
- }
- })
- },
- activateTab (tabName) {
- this.activeTab = tabName
- },
- logout () {
- this.$store.dispatch('logout')
- this.$router.replace('/')
- },
- revokeToken (id) {
- if (window.confirm(`${this.$i18n.t('settings.revoke_token')}?`)) {
- this.$store.dispatch('revokeToken', id)
- }
- },
- filterUnblockedUsers (userIds) {
- return reject(userIds, (userId) => {
- const relationship = this.$store.getters.relationship(this.userId)
- return relationship.blocking || userId === this.$store.state.users.currentUser.id
- })
- },
- filterUnMutedUsers (userIds) {
- return reject(userIds, (userId) => {
- const relationship = this.$store.getters.relationship(this.userId)
- return relationship.muting || userId === this.$store.state.users.currentUser.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)
- },
- unmuteDomains (domains) {
- return this.$store.dispatch('unmuteDomains', domains)
- },
- muteDomain () {
- return this.$store.dispatch('muteDomain', this.newDomainToMute)
- .then(() => { this.newDomainToMute = '' })
- },
- identity (value) {
- return value
- }
- }
-}
-
-export default UserSettings
diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue
@@ -1,728 +0,0 @@
-<template>
- <div class="settings panel panel-default">
- <div class="panel-heading">
- <div class="title">
- {{ $t('settings.user_settings') }}
- </div>
- <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>
- </div>
- <div class="panel-body profile-edit">
- <tab-switcher>
- <div :label="$t('settings.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>
-
- <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>
-
- <div
- v-if="pleromaBackend"
- :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>
-
- <div
- v-if="pleromaBackend"
- :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>
-
- <div :label="$t('settings.blocks_tab')">
- <div class="profile-edit-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="identity"
- >
- <template
- slot="header"
- slot-scope="{selected}"
- >
- <div class="profile-edit-bulk-actions">
- <ProgressButton
- v-if="selected.length > 0"
- class="btn btn-default"
- :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="profile-edit-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="identity"
- >
- <template
- slot="header"
- slot-scope="{selected}"
- >
- <div class="profile-edit-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="profile-edit-domain-mute-form">
- <input
- v-model="newDomainToMute"
- :placeholder="$t('settings.type_domains_to_mute')"
- type="text"
- @keyup.enter="muteDomain"
- >
- <ProgressButton
- class="btn btn-default"
- :click="muteDomain"
- >
- {{ $t('domain_mute_card.mute') }}
- <template slot="progress">
- {{ $t('domain_mute_card.mute_progress') }}
- </template>
- </ProgressButton>
- </div>
- <DomainMuteList
- :refresh="true"
- :get-key="identity"
- >
- <template
- slot="header"
- slot-scope="{selected}"
- >
- <div class="profile-edit-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>
- </div>
- </div>
-</template>
-
-<script src="./user_settings.js">
-</script>
-
-<style lang="scss">
-@import '../../_variables.scss';
-
-.profile-edit {
- .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;
- }
-}
-</style>
diff --git a/src/i18n/en.json b/src/i18n/en.json
@@ -59,7 +59,10 @@
"apply": "Apply",
"submit": "Submit",
"more": "More",
+ "loading": "Loading…",
"generic_error": "An error occured",
+ "error_retry": "Please try again",
+ "retry": "Try again",
"optional": "optional",
"show_more": "Show more",
"show_less": "Show less",
@@ -68,7 +71,9 @@
"disable": "Disable",
"enable": "Enable",
"confirm": "Confirm",
- "verify": "Verify"
+ "verify": "Verify",
+ "close": "Close",
+ "peek": "Peek"
},
"image_cropper": {
"crop_picture": "Crop picture",
@@ -278,6 +283,7 @@
"current_avatar": "Your current avatar",
"current_password": "Current password",
"current_profile_banner": "Your current profile banner",
+ "mutes_and_blocks": "Mutes and Blocks",
"data_import_export_tab": "Data Import / Export",
"default_vis": "Default visibility scope",
"delete_account": "Delete Account",
diff --git a/src/modules/config.js b/src/modules/config.js
@@ -3,6 +3,16 @@ import { setPreset, applyTheme } from '../services/style_setter/style_setter.js'
const browserLocale = (window.navigator.language || 'en').split('-')[0]
+/* TODO this is a bit messy.
+ * We need to declare settings with their types and also deal with
+ * instance-default settings in some way, hopefully try to avoid copy-pasta
+ * in general.
+ */
+export const multiChoiceProperties = [
+ 'postContentType',
+ 'subjectLineBehavior'
+]
+
export const defaultState = {
colors: {},
theme: undefined,
diff --git a/src/modules/interface.js b/src/modules/interface.js
@@ -1,6 +1,8 @@
import { set, delete as del } from 'vue'
const defaultState = {
+ settingsModalState: 'hidden',
+ settingsModalLoaded: false,
settings: {
currentSaveStateNotice: null,
noticeClearTimeout: null,
@@ -35,6 +37,27 @@ const interfaceMod = {
},
setMobileLayout (state, value) {
state.mobileLayout = value
+ },
+ closeSettingsModal (state) {
+ state.settingsModalState = 'hidden'
+ },
+ togglePeekSettingsModal (state) {
+ switch (state.settingsModalState) {
+ case 'minimized':
+ state.settingsModalState = 'visible'
+ return
+ case 'visible':
+ state.settingsModalState = 'minimized'
+ return
+ default:
+ throw new Error('Illegal minimization state of settings modal')
+ }
+ },
+ openSettingsModal (state) {
+ state.settingsModalState = 'visible'
+ if (!state.settingsModalLoaded) {
+ state.settingsModalLoaded = true
+ }
}
},
actions: {
@@ -49,6 +72,15 @@ const interfaceMod = {
},
setMobileLayout ({ commit }, value) {
commit('setMobileLayout', value)
+ },
+ closeSettingsModal ({ commit }) {
+ commit('closeSettingsModal')
+ },
+ openSettingsModal ({ commit }) {
+ commit('openSettingsModal')
+ },
+ togglePeekSettingsModal ({ commit }) {
+ commit('togglePeekSettingsModal')
}
}
}
diff --git a/src/services/resettable_async_component.js b/src/services/resettable_async_component.js
@@ -0,0 +1,32 @@
+import Vue from 'vue'
+
+/* By default async components don't have any way to recover, if component is
+ * failed, it is failed forever. This helper tries to remedy that by recreating
+ * async component when retry is requested (by user). You need to emit the
+ * `resetAsyncComponent` event from child to reset the component. Generally,
+ * this should be done from error component but could be done from loading or
+ * actual target component itself if needs to be.
+ */
+function getResettableAsyncComponent (asyncComponent, options) {
+ const asyncComponentFactory = () => () => ({
+ component: asyncComponent(),
+ ...options
+ })
+
+ const observe = Vue.observable({ c: asyncComponentFactory() })
+
+ return {
+ functional: true,
+ render (createElement, { data, children }) {
+ // emit event resetAsyncComponent to reloading
+ data.on = {}
+ data.on.resetAsyncComponent = () => {
+ observe.c = asyncComponentFactory()
+ // parent.$forceUpdate()
+ }
+ return createElement(observe.c, data, children)
+ }
+ }
+}
+
+export default getResettableAsyncComponent
diff --git a/static/fontello.json b/static/fontello.json
@@ -363,6 +363,18 @@
"css": "ok",
"code": 59431,
"src": "fontawesome"
+ },
+ {
+ "uid": "4109c474ff99cad28fd5a2c38af2ec6f",
+ "css": "filter",
+ "code": 61616,
+ "src": "fontawesome"
+ },
+ {
+ "uid": "9a76bc135eac17d2c8b8ad4a5774fc87",
+ "css": "download",
+ "code": 59429,
+ "src": "fontawesome"
}
]
}
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
@@ -2327,6 +2327,7 @@ dateformat@^1.0.6:
de-indent@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d"
+ integrity sha1-sgOOhG3DO6pXlhKNCAS0VbjB4h0=
debug@2, debug@2.6.9, debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9:
version "2.6.9"
@@ -7903,9 +7904,10 @@ vue-style-loader@^4.0.0, vue-style-loader@^4.0.1:
hash-sum "^1.0.2"
loader-utils "^1.0.2"
-vue-template-compiler@^2.3.4:
- version "2.5.21"
- resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.5.21.tgz#a57ceb903177e8f643560a8d639a0f8db647054a"
+vue-template-compiler@^2.6.11:
+ version "2.6.11"
+ resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.6.11.tgz#c04704ef8f498b153130018993e56309d4698080"
+ integrity sha512-KIq15bvQDrcCjpGjrAhx4mUlyyHfdmTaoNfeoATHLAiWB+MU3cx4lOzMwrnUh9cCxy0Lt1T11hAFY6TQgroUAA==
dependencies:
de-indent "^1.0.2"
he "^1.1.0"
@@ -7914,9 +7916,10 @@ vue-template-es2015-compiler@^1.6.0:
version "1.9.1"
resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825"
-vue@^2.5.13:
- version "2.5.21"
- resolved "https://registry.yarnpkg.com/vue/-/vue-2.5.21.tgz#3d33dcd03bb813912ce894a8303ab553699c4a85"
+vue@^2.6.11:
+ version "2.6.11"
+ resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.11.tgz#76594d877d4b12234406e84e35275c6d514125c5"
+ integrity sha512-VfPwgcGABbGAue9+sfrD4PuwFar7gPb1yl1UK1MwXoQPAw0BKSqWfoYCT/ThFrdEVWoI51dBuyCoiNU9bZDZxQ==
vuelidate@^0.7.4:
version "0.7.4"