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