Merge branch 'settings-import-export' into 'develop'

Settings backup/restore + small fixes

See merge request pleroma/pleroma-fe!1372
This commit is contained in:
HJ 2021-04-07 17:40:07 +00:00
commit 8b96ea9377
14 changed files with 396 additions and 194 deletions

View File

@ -547,9 +547,21 @@ main-router {
border-radius: var(--panelRadius, $fallback--panelRadius); 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 $fallback--panelRadius $fallback--panelRadius;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--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 { .faint {
color: $fallback--faint; color: $fallback--faint;
@ -871,16 +883,10 @@ nav {
} }
.new-status-notification { .new-status-notification {
position:relative; position: relative;
margin-top: -1px;
font-size: 1.1em; font-size: 1.1em;
border-width: 1px 0 0 0;
border-style: solid;
border-color: var(--border, $fallback--border);
padding: 10px;
z-index: 1; z-index: 1;
background-color: $fallback--fg; flex: 1;
background-color: var(--panel, $fallback--fg);
} }
.chat-layout { .chat-layout {

View File

@ -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>

View File

@ -1,6 +1,6 @@
@import '../../_variables.scss'; @import '../../_variables.scss';
.notifications { .Notifications {
&:not(.minimal) { &:not(.minimal) {
// a bit of a hack to allow scrolling below notifications // a bit of a hack to allow scrolling below notifications
padding-bottom: 15em; padding-bottom: 15em;
@ -11,6 +11,10 @@
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
} }
.notifications-footer {
border: none;
}
.notification { .notification {
position: relative; position: relative;
@ -82,7 +86,6 @@
} }
} }
.follow-text, .move-text { .follow-text, .move-text {
padding: 0.5em 0; padding: 0.5em 0;
overflow-wrap: break-word; overflow-wrap: break-word;

View File

@ -1,7 +1,7 @@
<template> <template>
<div <div
:class="{ minimal: minimalMode }" :class="{ minimal: minimalMode }"
class="notifications" class="Notifications"
> >
<div :class="mainClass"> <div :class="mainClass">
<div <div
@ -35,10 +35,10 @@
<notification :notification="notification" /> <notification :notification="notification" />
</div> </div>
</div> </div>
<div class="panel-footer"> <div class="panel-footer notifications-footer">
<div <div
v-if="bottomedOut" v-if="bottomedOut"
class="new-status-notification text-center panel-footer faint" class="new-status-notification text-center faint"
> >
{{ $t('notifications.no_more_notifications') }} {{ $t('notifications.no_more_notifications') }}
</div> </div>
@ -47,13 +47,13 @@
class="button-unstyled -link -fullwidth" class="button-unstyled -link -fullwidth"
@click.prevent="fetchOlderNotifications()" @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') }} {{ minimalMode ? $t('interactions.load_older') : $t('notifications.load_older') }}
</div> </div>
</button> </button>
<div <div
v-else v-else
class="new-status-notification text-center panel-footer" class="new-status-notification text-center"
> >
<FAIcon <FAIcon
icon="circle-notch" icon="circle-notch"

View File

@ -2,10 +2,55 @@ import Modal from 'src/components/modal/modal.vue'
import PanelLoading from 'src/components/panel_loading/panel_loading.vue' import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
import AsyncComponentError from 'src/components/async_component_error/async_component_error.vue' import AsyncComponentError from 'src/components/async_component_error/async_component_error.vue'
import getResettableAsyncComponent from 'src/services/resettable_async_component.js' 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 = { 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: { components: {
Modal, Modal,
Popover,
SettingsModalContent: getResettableAsyncComponent( SettingsModalContent: getResettableAsyncComponent(
() => import('./settings_modal_content.vue'), () => import('./settings_modal_content.vue'),
{ {
@ -21,6 +66,85 @@ const SettingsModal = {
}, },
peekModal () { peekModal () {
this.$store.dispatch('togglePeekSettingsModal') 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: { computed: {

View File

@ -31,20 +31,86 @@
</transition> </transition>
<button <button
class="btn button-default" class="btn button-default"
:title="$t('general.peek')"
@click="peekModal" @click="peekModal"
> >
{{ $t('general.peek') }} <FAIcon
:icon="['far', 'window-minimize']"
fixed-width
/>
</button> </button>
<button <button
class="btn button-default" class="btn button-default"
:title="$t('general.close')"
@click="closeModal" @click="closeModal"
> >
{{ $t('general.close') }} <FAIcon
icon="times"
fixed-width
/>
</button> </button>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<SettingsModalContent v-if="modalOpenedOnce" /> <SettingsModalContent v-if="modalOpenedOnce" />
</div> </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> </div>
</Modal> </Modal>
</template> </template>

View File

@ -15,6 +15,10 @@ import {
shadows2to3, shadows2to3,
colors2to3 colors2to3
} from 'src/services/style_setter/style_setter.js' } from 'src/services/style_setter/style_setter.js'
import {
newImporter,
newExporter
} from 'src/services/export_import/export_import.js'
import { import {
SLOT_INHERITANCE SLOT_INHERITANCE
} from 'src/services/theme_data/pleromafe.js' } from 'src/services/theme_data/pleromafe.js'
@ -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 FontControl from 'src/components/font_control/font_control.vue'
import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue' import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js' 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 Checkbox from 'src/components/checkbox/checkbox.vue'
import Preview from './preview.vue' import Preview from './preview.vue'
@ -67,6 +70,15 @@ const colorConvert = (color) => {
export default { export default {
data () { data () {
return { return {
themeImporter: newImporter({
validator: this.importValidator,
onImport: this.onImport,
onImportFailure: this.onImportFailure
}),
themeExporter: newExporter({
filename: 'pleroma_theme',
getExportedObject: () => this.exportedTheme
}),
availableStyles: [], availableStyles: [],
selected: this.$store.getters.mergedConfig.theme, selected: this.$store.getters.mergedConfig.theme,
themeWarning: undefined, themeWarning: undefined,
@ -383,7 +395,6 @@ export default {
FontControl, FontControl,
TabSwitcher, TabSwitcher,
Preview, Preview,
ExportImport,
Checkbox Checkbox
}, },
methods: { methods: {
@ -528,10 +539,15 @@ export default {
this.previewColors.mod this.previewColors.mod
) )
}, },
importTheme () { this.themeImporter.importData() },
exportTheme () { this.themeExporter.exportData() },
onImport (parsed, forceSource = false) { onImport (parsed, forceSource = false) {
this.tempImportFile = parsed this.tempImportFile = parsed
this.loadTheme(parsed, 'file', forceSource) this.loadTheme(parsed, 'file', forceSource)
}, },
onImportFailure (result) {
this.$store.dispatch('pushGlobalNotice', { messageKey: 'settings.invalid_theme_imported', level: 'error' })
},
importValidator (parsed) { importValidator (parsed) {
const version = parsed._pleroma_theme_version const version = parsed._pleroma_theme_version
return version >= 1 || version <= 2 return version >= 1 || version <= 2

View File

@ -48,46 +48,51 @@
</template> </template>
</div> </div>
</div> </div>
<ExportImport <div class="top">
:export-object="exportedTheme" <div class="presets">
:export-label="$t(&quot;settings.export_theme&quot;)" {{ $t('settings.presets') }}
:import-label="$t(&quot;settings.import_theme&quot;)" <label
:import-failed-text="$t(&quot;settings.invalid_theme_imported&quot;)" for="preset-switcher"
:on-import="onImport" class="select"
:validator="importValidator" >
> <select
<template slot="before"> id="preset-switcher"
<div class="presets"> v-model="selected"
{{ $t('settings.presets') }} class="preset-switcher"
<label
for="preset-switcher"
class="select"
> >
<select <option
id="preset-switcher" v-for="style in availableStyles"
v-model="selected" :key="style.name"
class="preset-switcher" :value="style"
:style="{
backgroundColor: style[1] || (style.theme || style.source).colors.bg,
color: style[3] || (style.theme || style.source).colors.text
}"
> >
<option {{ style[0] || style.name }}
v-for="style in availableStyles" </option>
:key="style.name" </select>
:value="style" <FAIcon
:style="{ class="select-down-icon"
backgroundColor: style[1] || (style.theme || style.source).colors.bg, icon="chevron-down"
color: style[3] || (style.theme || style.source).colors.text />
}" </label>
> </div>
{{ style[0] || style.name }} <div class="export-import">
</option> <button
</select> class="btn button-default"
<FAIcon @click="importTheme"
class="select-down-icon" >
icon="chevron-down" {{ $t(&quot;settings.import_theme&quot;) }}
/> </button>
</label> <button
</div> class="btn button-default"
</template> @click="exportTheme"
</ExportImport> >
{{ $t(&quot;settings.export_theme&quot;) }}
</button>
</div>
</div>
</div> </div>
<div class="save-load-options"> <div class="save-load-options">
<span class="keep-option"> <span class="keep-option">

View File

@ -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;
}
}

View File

@ -52,13 +52,13 @@
<div :class="classes.footer"> <div :class="classes.footer">
<div <div
v-if="count===0" v-if="count===0"
class="new-status-notification text-center panel-footer faint" class="new-status-notification text-center faint"
> >
{{ $t('timeline.no_statuses') }} {{ $t('timeline.no_statuses') }}
</div> </div>
<div <div
v-else-if="bottomedOut" 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') }} {{ $t('timeline.no_more_statuses') }}
</div> </div>
@ -67,13 +67,13 @@
class="button-unstyled -link -fullwidth" class="button-unstyled -link -fullwidth"
@click.prevent="fetchOlderStatuses()" @click.prevent="fetchOlderStatuses()"
> >
<div class="new-status-notification text-center panel-footer"> <div class="new-status-notification text-center">
{{ $t('timeline.load_older') }} {{ $t('timeline.load_older') }}
</div> </div>
</button> </button>
<div <div
v-else v-else
class="new-status-notification text-center panel-footer" class="new-status-notification text-center"
> >
<FAIcon <FAIcon
icon="circle-notch" icon="circle-notch"
@ -87,32 +87,4 @@
<script src="./timeline.js"></script> <script src="./timeline.js"></script>
<style lang="scss"> <style src="./timeline.scss" lang="scss"> </style>
@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>

View File

@ -368,6 +368,18 @@
"loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")", "loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")",
"mutes_tab": "Mutes", "mutes_tab": "Mutes",
"play_videos_in_modal": "Play videos in a popup frame", "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": { "profile_fields": {
"label": "Profile metadata", "label": "Profile metadata",
"add_field": "Add field", "add_field": "Add field",

View File

@ -110,6 +110,20 @@ const config = {
} }
}, },
actions: { 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 }) { setHighlight ({ commit, dispatch }, { user, color, type }) {
commit('setHighlight', { user, color, type }) commit('setHighlight', { user, color, type })
}, },

View File

@ -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)
}
})

View File

@ -380,7 +380,7 @@ export const colors2to3 = (colors) => {
*/ */
export const shadows2to3 = (shadows, opacity) => { export const shadows2to3 = (shadows, opacity) => {
return Object.entries(shadows).reduce((shadowsAcc, [slotName, shadowDefs]) => { 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 getOpacity = ({ color }) => opacity[getOpacitySlot(color.substring(2).split(',')[0])]
const newShadow = shadowDefs.reduce((shadowAcc, def) => [ const newShadow = shadowDefs.reduce((shadowAcc, def) => [
...shadowAcc, ...shadowAcc,