commit: 8b96ea93776fd1eb462a7c54822d4f8ad6a9e776
parent 4e3c4ec1dbbf88b09a84a80519f4ccced5a4dc2c
Author: HJ <30-hj@users.noreply.git.pleroma.social>
Date: Wed, 7 Apr 2021 17:40:07 +0000
Merge branch 'settings-import-export' into 'develop'
Settings backup/restore + small fixes
See merge request pleroma/pleroma-fe!1372
Diffstat:
14 files changed, 396 insertions(+), 194 deletions(-)
diff --git a/src/App.scss b/src/App.scss
@@ -547,9 +547,21 @@ main-router {
border-radius: var(--panelRadius, $fallback--panelRadius);
}
-.panel-footer {
+/* TODO Should remove timeline-footer from here when we refactor panels into
+ * separate component and utilize slots
+ */
+.panel-footer, .timeline-footer {
+ display: flex;
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
+ flex: none;
+ padding: 0.6em 0.6em;
+ text-align: left;
+ line-height: 28px;
+ align-items: baseline;
+ border-width: 1px 0 0 0;
+ border-style: solid;
+ border-color: var(--border, $fallback--border);
.faint {
color: $fallback--faint;
@@ -871,16 +883,10 @@ nav {
}
.new-status-notification {
- position:relative;
- margin-top: -1px;
+ position: relative;
font-size: 1.1em;
- border-width: 1px 0 0 0;
- border-style: solid;
- border-color: var(--border, $fallback--border);
- padding: 10px;
z-index: 1;
- background-color: $fallback--fg;
- background-color: var(--panel, $fallback--fg);
+ flex: 1;
}
.chat-layout {
diff --git a/src/components/export_import/export_import.vue b/src/components/export_import/export_import.vue
@@ -1,102 +0,0 @@
-<template>
- <div class="import-export-container">
- <slot name="before" />
- <button
- class="btn button-default"
- @click="exportData"
- >
- {{ exportLabel }}
- </button>
- <button
- class="btn button-default"
- @click="importData"
- >
- {{ importLabel }}
- </button>
- <slot name="afterButtons" />
- <p
- v-if="importFailed"
- class="alert error"
- >
- {{ importFailedText }}
- </p>
- <slot name="afterError" />
- </div>
-</template>
-
-<script>
-export default {
- props: [
- 'exportObject',
- 'importLabel',
- 'exportLabel',
- 'importFailedText',
- 'validator',
- 'onImport',
- 'onImportFailure'
- ],
- data () {
- return {
- importFailed: false
- }
- },
- methods: {
- exportData () {
- const stringified = JSON.stringify(this.exportObject, null, 2) // Pretty-print and indent with 2 spaces
-
- // Create an invisible link with a data url and simulate a click
- const e = document.createElement('a')
- e.setAttribute('download', 'pleroma_theme.json')
- e.setAttribute('href', 'data:application/json;base64,' + window.btoa(stringified))
- e.style.display = 'none'
-
- document.body.appendChild(e)
- e.click()
- document.body.removeChild(e)
- },
- importData () {
- this.importFailed = false
- const filePicker = document.createElement('input')
- filePicker.setAttribute('type', 'file')
- filePicker.setAttribute('accept', '.json')
-
- filePicker.addEventListener('change', event => {
- if (event.target.files[0]) {
- // eslint-disable-next-line no-undef
- const reader = new FileReader()
- reader.onload = ({ target }) => {
- try {
- const parsed = JSON.parse(target.result)
- const valid = this.validator(parsed)
- if (valid) {
- this.onImport(parsed)
- } else {
- this.importFailed = true
- // this.onImportFailure(valid)
- }
- } catch (e) {
- // This will happen both if there is a JSON syntax error or the theme is missing components
- this.importFailed = true
- // this.onImportFailure(e)
- }
- }
- reader.readAsText(event.target.files[0])
- }
- })
-
- document.body.appendChild(filePicker)
- filePicker.click()
- document.body.removeChild(filePicker)
- }
- }
-}
-</script>
-
-<style lang="scss">
-.import-export-container {
- display: flex;
- flex-wrap: wrap;
- align-items: baseline;
- justify-content: center;
-}
-</style>
diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss
@@ -1,6 +1,6 @@
@import '../../_variables.scss';
-.notifications {
+.Notifications {
&:not(.minimal) {
// a bit of a hack to allow scrolling below notifications
padding-bottom: 15em;
@@ -11,6 +11,10 @@
color: var(--text, $fallback--text);
}
+ .notifications-footer {
+ border: none;
+ }
+
.notification {
position: relative;
@@ -82,7 +86,6 @@
}
}
-
.follow-text, .move-text {
padding: 0.5em 0;
overflow-wrap: break-word;
diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue
@@ -1,7 +1,7 @@
<template>
<div
:class="{ minimal: minimalMode }"
- class="notifications"
+ class="Notifications"
>
<div :class="mainClass">
<div
@@ -35,10 +35,10 @@
<notification :notification="notification" />
</div>
</div>
- <div class="panel-footer">
+ <div class="panel-footer notifications-footer">
<div
v-if="bottomedOut"
- class="new-status-notification text-center panel-footer faint"
+ class="new-status-notification text-center faint"
>
{{ $t('notifications.no_more_notifications') }}
</div>
@@ -47,13 +47,13 @@
class="button-unstyled -link -fullwidth"
@click.prevent="fetchOlderNotifications()"
>
- <div class="new-status-notification text-center panel-footer">
+ <div class="new-status-notification text-center">
{{ minimalMode ? $t('interactions.load_older') : $t('notifications.load_older') }}
</div>
</button>
<div
v-else
- class="new-status-notification text-center panel-footer"
+ class="new-status-notification text-center"
>
<FAIcon
icon="circle-notch"
diff --git a/src/components/settings_modal/settings_modal.js b/src/components/settings_modal/settings_modal.js
@@ -2,10 +2,55 @@ import Modal from 'src/components/modal/modal.vue'
import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
import AsyncComponentError from 'src/components/async_component_error/async_component_error.vue'
import getResettableAsyncComponent from 'src/services/resettable_async_component.js'
+import Popover from '../popover/popover.vue'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import { cloneDeep } from 'lodash'
+import {
+ newImporter,
+ newExporter
+} from 'src/services/export_import/export_import.js'
+import {
+ faTimes,
+ faFileUpload,
+ faFileDownload,
+ faChevronDown
+} from '@fortawesome/free-solid-svg-icons'
+import {
+ faWindowMinimize
+} from '@fortawesome/free-regular-svg-icons'
+
+const PLEROMAFE_SETTINGS_MAJOR_VERSION = 1
+const PLEROMAFE_SETTINGS_MINOR_VERSION = 0
+
+library.add(
+ faTimes,
+ faWindowMinimize,
+ faFileUpload,
+ faFileDownload,
+ faChevronDown
+)
const SettingsModal = {
+ data () {
+ return {
+ dataImporter: newImporter({
+ validator: this.importValidator,
+ onImport: this.onImport,
+ onImportFailure: this.onImportFailure
+ }),
+ dataThemeExporter: newExporter({
+ filename: 'pleromafe_settings.full',
+ getExportedObject: () => this.generateExport(true)
+ }),
+ dataExporter: newExporter({
+ filename: 'pleromafe_settings',
+ getExportedObject: () => this.generateExport()
+ })
+ }
+ },
components: {
Modal,
+ Popover,
SettingsModalContent: getResettableAsyncComponent(
() => import('./settings_modal_content.vue'),
{
@@ -21,6 +66,85 @@ const SettingsModal = {
},
peekModal () {
this.$store.dispatch('togglePeekSettingsModal')
+ },
+ importValidator (data) {
+ if (!Array.isArray(data._pleroma_settings_version)) {
+ return {
+ messageKey: 'settings.file_import_export.invalid_file'
+ }
+ }
+
+ const [major, minor] = data._pleroma_settings_version
+
+ if (major > PLEROMAFE_SETTINGS_MAJOR_VERSION) {
+ return {
+ messageKey: 'settings.file_export_import.errors.file_too_new',
+ messageArgs: {
+ fileMajor: major,
+ feMajor: PLEROMAFE_SETTINGS_MAJOR_VERSION
+ }
+ }
+ }
+
+ if (major < PLEROMAFE_SETTINGS_MAJOR_VERSION) {
+ return {
+ messageKey: 'settings.file_export_import.errors.file_too_old',
+ messageArgs: {
+ fileMajor: major,
+ feMajor: PLEROMAFE_SETTINGS_MAJOR_VERSION
+ }
+ }
+ }
+
+ if (minor > PLEROMAFE_SETTINGS_MINOR_VERSION) {
+ this.$store.dispatch('pushGlobalNotice', {
+ level: 'warning',
+ messageKey: 'settings.file_export_import.errors.file_slightly_new'
+ })
+ }
+
+ return true
+ },
+ onImportFailure (result) {
+ if (result.error) {
+ this.$store.dispatch('pushGlobalNotice', { messageKey: 'settings.invalid_settings_imported', level: 'error' })
+ } else {
+ this.$store.dispatch('pushGlobalNotice', { ...result.validationResult, level: 'error' })
+ }
+ },
+ onImport (data) {
+ if (data) { this.$store.dispatch('loadSettings', data) }
+ },
+ restore () {
+ this.dataImporter.importData()
+ },
+ backup () {
+ this.dataExporter.exportData()
+ },
+ backupWithTheme () {
+ this.dataThemeExporter.exportData()
+ },
+ generateExport (theme = false) {
+ const { config } = this.$store.state
+ let sample = config
+ if (!theme) {
+ const ignoreList = new Set([
+ 'customTheme',
+ 'customThemeSource',
+ 'colors'
+ ])
+ sample = Object.fromEntries(
+ Object
+ .entries(sample)
+ .filter(([key]) => !ignoreList.has(key))
+ )
+ }
+ const clone = cloneDeep(sample)
+ clone._pleroma_settings_version = [
+ PLEROMAFE_SETTINGS_MAJOR_VERSION,
+ PLEROMAFE_SETTINGS_MINOR_VERSION
+ ]
+ return clone
}
},
computed: {
diff --git a/src/components/settings_modal/settings_modal.vue b/src/components/settings_modal/settings_modal.vue
@@ -31,20 +31,86 @@
</transition>
<button
class="btn button-default"
+ :title="$t('general.peek')"
@click="peekModal"
>
- {{ $t('general.peek') }}
+ <FAIcon
+ :icon="['far', 'window-minimize']"
+ fixed-width
+ />
</button>
<button
class="btn button-default"
+ :title="$t('general.close')"
@click="closeModal"
>
- {{ $t('general.close') }}
+ <FAIcon
+ icon="times"
+ fixed-width
+ />
</button>
</div>
<div class="panel-body">
<SettingsModalContent v-if="modalOpenedOnce" />
</div>
+ <div class="panel-footer">
+ <Popover
+ class="export"
+ trigger="click"
+ placement="top"
+ :offset="{ y: 5, x: 5 }"
+ :bound-to="{ x: 'container' }"
+ remove-padding
+ >
+ <button
+ slot="trigger"
+ class="btn button-default"
+ :title="$t('general.close')"
+ >
+ <span>{{ $t("settings.file_export_import.backup_restore") }}</span>
+ <FAIcon
+ icon="chevron-down"
+ />
+ </button>
+ <div
+ slot="content"
+ slot-scope="{close}"
+ >
+ <div class="dropdown-menu">
+ <button
+ class="button-default dropdown-item dropdown-item-icon"
+ @click.prevent="backup"
+ @click="close"
+ >
+ <FAIcon
+ icon="file-download"
+ fixed-width
+ /><span>{{ $t("settings.file_export_import.backup_settings") }}</span>
+ </button>
+ <button
+ class="button-default dropdown-item dropdown-item-icon"
+ @click.prevent="backupWithTheme"
+ @click="close"
+ >
+ <FAIcon
+ icon="file-download"
+ fixed-width
+ /><span>{{ $t("settings.file_export_import.backup_settings_theme") }}</span>
+ </button>
+ <button
+ class="button-default dropdown-item dropdown-item-icon"
+ @click.prevent="restore"
+ @click="close"
+ >
+ <FAIcon
+ icon="file-upload"
+ fixed-width
+ /><span>{{ $t("settings.file_export_import.restore_settings") }}</span>
+ </button>
+ </div>
+ </div>
+ </Popover>
+ </div>
</div>
</Modal>
</template>
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
@@ -16,6 +16,10 @@ import {
colors2to3
} from 'src/services/style_setter/style_setter.js'
import {
+ newImporter,
+ newExporter
+} from 'src/services/export_import/export_import.js'
+import {
SLOT_INHERITANCE
} from 'src/services/theme_data/pleromafe.js'
import {
@@ -31,7 +35,6 @@ 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'
@@ -67,6 +70,15 @@ const colorConvert = (color) => {
export default {
data () {
return {
+ themeImporter: newImporter({
+ validator: this.importValidator,
+ onImport: this.onImport,
+ onImportFailure: this.onImportFailure
+ }),
+ themeExporter: newExporter({
+ filename: 'pleroma_theme',
+ getExportedObject: () => this.exportedTheme
+ }),
availableStyles: [],
selected: this.$store.getters.mergedConfig.theme,
themeWarning: undefined,
@@ -383,7 +395,6 @@ export default {
FontControl,
TabSwitcher,
Preview,
- ExportImport,
Checkbox
},
methods: {
@@ -528,10 +539,15 @@ export default {
this.previewColors.mod
)
},
+ importTheme () { this.themeImporter.importData() },
+ exportTheme () { this.themeExporter.exportData() },
onImport (parsed, forceSource = false) {
this.tempImportFile = parsed
this.loadTheme(parsed, 'file', forceSource)
},
+ onImportFailure (result) {
+ this.$store.dispatch('pushGlobalNotice', { messageKey: 'settings.invalid_theme_imported', level: 'error' })
+ },
importValidator (parsed) {
const version = parsed._pleroma_theme_version
return version >= 1 || version <= 2
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
@@ -48,46 +48,51 @@
</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"
+ <div class="top">
+ <div class="presets">
+ {{ $t('settings.presets') }}
+ <label
+ for="preset-switcher"
+ class="select"
+ >
+ <select
+ id="preset-switcher"
+ v-model="selected"
+ class="preset-switcher"
>
- <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
+ }"
>
- <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>
- <FAIcon
- class="select-down-icon"
- icon="chevron-down"
- />
- </label>
- </div>
- </template>
- </ExportImport>
+ {{ style[0] || style.name }}
+ </option>
+ </select>
+ <FAIcon
+ class="select-down-icon"
+ icon="chevron-down"
+ />
+ </label>
+ </div>
+ <div class="export-import">
+ <button
+ class="btn button-default"
+ @click="importTheme"
+ >
+ {{ $t("settings.import_theme") }}
+ </button>
+ <button
+ class="btn button-default"
+ @click="exportTheme"
+ >
+ {{ $t("settings.export_theme") }}
+ </button>
+ </div>
+ </div>
</div>
<div class="save-load-options">
<span class="keep-option">
diff --git a/src/components/timeline/timeline.scss b/src/components/timeline/timeline.scss
@@ -0,0 +1,31 @@
+@import '../../_variables.scss';
+
+.Timeline {
+ .loadmore-text {
+ opacity: 1;
+ }
+
+ &.-blocked {
+ cursor: progress;
+ }
+
+ .timeline-heading {
+ max-width: 100%;
+ flex-wrap: nowrap;
+ align-items: center;
+ position: relative;
+
+ .loadmore-button {
+ flex-shrink: 0;
+ }
+
+ .loadmore-text {
+ flex-shrink: 0;
+ line-height: 1em;
+ }
+ }
+
+ .timeline-footer {
+ border: none;
+ }
+}
diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue
@@ -52,13 +52,13 @@
<div :class="classes.footer">
<div
v-if="count===0"
- class="new-status-notification text-center panel-footer faint"
+ class="new-status-notification text-center faint"
>
{{ $t('timeline.no_statuses') }}
</div>
<div
v-else-if="bottomedOut"
- class="new-status-notification text-center panel-footer faint"
+ class="new-status-notification text-center faint"
>
{{ $t('timeline.no_more_statuses') }}
</div>
@@ -67,13 +67,13 @@
class="button-unstyled -link -fullwidth"
@click.prevent="fetchOlderStatuses()"
>
- <div class="new-status-notification text-center panel-footer">
+ <div class="new-status-notification text-center">
{{ $t('timeline.load_older') }}
</div>
</button>
<div
v-else
- class="new-status-notification text-center panel-footer"
+ class="new-status-notification text-center"
>
<FAIcon
icon="circle-notch"
@@ -87,32 +87,4 @@
<script src="./timeline.js"></script>
-<style lang="scss">
-@import '../../_variables.scss';
-
-.Timeline {
- .loadmore-text {
- opacity: 1;
- }
-
- &.-blocked {
- cursor: progress;
- }
-}
-
-.timeline-heading {
- max-width: 100%;
- flex-wrap: nowrap;
- align-items: center;
- position: relative;
-
- .loadmore-button {
- flex-shrink: 0;
- }
-
- .loadmore-text {
- flex-shrink: 0;
- line-height: 1em;
- }
-}
-</style>
+<style src="./timeline.scss" lang="scss"> </style>
diff --git a/src/i18n/en.json b/src/i18n/en.json
@@ -368,6 +368,18 @@
"loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")",
"mutes_tab": "Mutes",
"play_videos_in_modal": "Play videos in a popup frame",
+ "file_export_import": {
+ "backup_restore": "Settings backup",
+ "backup_settings": "Backup settings to file",
+ "backup_settings_theme": "Backup settings and theme to file",
+ "restore_settings": "Restore settings from file",
+ "errors": {
+ "invalid_file": "The selected file is not a supported Pleroma settings backup. No changes were made.",
+ "file_too_new": "Incompatile major version: {fileMajor}, this PleromaFE (settings ver {feMajor}) is too old to handle it",
+ "file_too_old": "Incompatile major version: {fileMajor}, file version is too old and not supported (min. set. ver. {feMajor})",
+ "file_slightly_new": "File minor version is different, some settings might not load"
+ }
+ },
"profile_fields": {
"label": "Profile metadata",
"add_field": "Add field",
diff --git a/src/modules/config.js b/src/modules/config.js
@@ -110,6 +110,20 @@ const config = {
}
},
actions: {
+ loadSettings ({ dispatch }, data) {
+ const knownKeys = new Set(Object.keys(defaultState))
+ const presentKeys = new Set(Object.keys(data))
+ const intersection = new Set()
+ for (let elem of presentKeys) {
+ if (knownKeys.has(elem)) {
+ intersection.add(elem)
+ }
+ }
+
+ intersection.forEach(
+ name => dispatch('setOption', { name, value: data[name] })
+ )
+ },
setHighlight ({ commit, dispatch }, { user, color, type }) {
commit('setHighlight', { user, color, type })
},
diff --git a/src/services/export_import/export_import.js b/src/services/export_import/export_import.js
@@ -0,0 +1,55 @@
+export const newExporter = ({
+ filename = 'data',
+ getExportedObject
+}) => ({
+ exportData () {
+ const stringified = JSON.stringify(getExportedObject(), null, 2) // Pretty-print and indent with 2 spaces
+
+ // Create an invisible link with a data url and simulate a click
+ const e = document.createElement('a')
+ e.setAttribute('download', `${filename}.json`)
+ e.setAttribute('href', 'data:application/json;base64,' + window.btoa(stringified))
+ e.style.display = 'none'
+
+ document.body.appendChild(e)
+ e.click()
+ document.body.removeChild(e)
+ }
+})
+
+export const newImporter = ({
+ onImport,
+ onImportFailure,
+ validator = () => true
+}) => ({
+ importData () {
+ const filePicker = document.createElement('input')
+ filePicker.setAttribute('type', 'file')
+ filePicker.setAttribute('accept', '.json')
+
+ filePicker.addEventListener('change', event => {
+ if (event.target.files[0]) {
+ // eslint-disable-next-line no-undef
+ const reader = new FileReader()
+ reader.onload = ({ target }) => {
+ try {
+ const parsed = JSON.parse(target.result)
+ const validationResult = validator(parsed)
+ if (validationResult === true) {
+ onImport(parsed)
+ } else {
+ onImportFailure({ validationResult })
+ }
+ } catch (error) {
+ onImportFailure({ error })
+ }
+ }
+ reader.readAsText(event.target.files[0])
+ }
+ })
+
+ document.body.appendChild(filePicker)
+ filePicker.click()
+ document.body.removeChild(filePicker)
+ }
+})
diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js
@@ -380,7 +380,7 @@ export const colors2to3 = (colors) => {
*/
export const shadows2to3 = (shadows, opacity) => {
return Object.entries(shadows).reduce((shadowsAcc, [slotName, shadowDefs]) => {
- const isDynamic = ({ color }) => color.startsWith('--')
+ const isDynamic = ({ color = '#000000' }) => color.startsWith('--')
const getOpacity = ({ color }) => opacity[getOpacitySlot(color.substring(2).split(',')[0])]
const newShadow = shadowDefs.reduce((shadowAcc, def) => [
...shadowAcc,