Page MenuHomePhorge

No OneTemporary

Size
433 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/changelog.d/settings-shuffle.change b/changelog.d/settings-shuffle.change
new file mode 100644
index 0000000000..f1d9ecf893
--- /dev/null
+++ b/changelog.d/settings-shuffle.change
@@ -0,0 +1,3 @@
+rearranged and split settings to make more sense and be less of a wall of text
+on mobile settings now take up full width and presented in navigation style
+improved styles for settings
diff --git a/src/components/color_input/color_input.scss b/src/components/color_input/color_input.scss
index 3128381f5f..fad2bfca71 100644
--- a/src/components/color_input/color_input.scss
+++ b/src/components/color_input/color_input.scss
@@ -1,105 +1,114 @@
.color-input {
display: inline-flex;
+ flex-wrap: wrap;
+ max-width: 10em;
+
+ &.-compact {
+ max-width: none;
+ }
.label {
flex: 1 1 auto;
+ grid-area: label;
}
.opt {
+ grid-area: checkbox;
margin-right: 0.5em;
}
&-field.input {
- display: inline-flex;
- flex: 0 0 0;
- max-width: 9em;
+ flex: 1 1 10em;
+ max-width: 10em;
+ grid-area: input;
+ display: flex;
align-items: stretch;
input {
color: var(--text);
background: none;
border: none;
padding: 0;
margin: 0;
&.textColor {
flex: 1 0 3em;
min-width: 3em;
padding: 0;
}
}
.nativeColor {
cursor: pointer;
flex: 0 0 auto;
padding: 0;
input {
appearance: none;
max-width: 0;
min-width: 0;
max-height: 0;
/* stylelint-disable-next-line declaration-no-important */
opacity: 0 !important;
}
}
.computedIndicator,
.validIndicator,
.invalidIndicator,
.transparentIndicator {
flex: 0 0 2em;
margin: 0.2em 0.5em;
min-width: 2em;
align-self: stretch;
min-height: 1.1em;
border-radius: var(--roundness);
}
.invalidIndicator {
background: transparent;
box-sizing: border-box;
border: 2px solid var(--cRed);
}
.transparentIndicator {
// forgot to install counter-strike source, ooops
background-color: #f0f;
position: relative;
&::before,
&::after {
display: block;
content: "";
background-color: #000;
position: absolute;
height: 50%;
width: 50%;
}
&::after {
top: 0;
left: 0;
border-top-left-radius: var(--roundness);
}
&::before {
bottom: 0;
right: 0;
border-bottom-right-radius: var(--roundness);
}
}
&.disabled,
&:disabled {
.nativeColor input,
.computedIndicator,
.validIndicator,
.invalidIndicator,
.transparentIndicator {
/* stylelint-disable-next-line declaration-no-important */
opacity: 0.25 !important;
}
}
}
}
diff --git a/src/components/color_input/color_input.vue b/src/components/color_input/color_input.vue
index 266ad4d4e9..58aee575c7 100644
--- a/src/components/color_input/color_input.vue
+++ b/src/components/color_input/color_input.vue
@@ -1,154 +1,158 @@
<template>
<div
class="color-input style-control"
- :class="{ disabled: !present || disabled }"
+ :class="{ disabled: !present || disabled, '-compact': compact }"
>
<label
:for="name"
class="label"
:class="{ faint: !present || disabled }"
>
{{ label }}
</label>
<Checkbox
v-if="typeof fallback !== 'undefined' && showOptionalCheckbox && !hideOptionalCheckbox"
:model-value="present"
:disabled="disabled"
class="opt"
@update:model-value="updateValue(typeof modelValue === 'undefined' ? fallback : undefined)"
/>
<div
class="input color-input-field"
:class="{ disabled: !present || disabled, unstyled }"
>
<input
:id="name + '-t'"
class="textColor unstyled"
:class="{ disabled: !present || disabled }"
type="text"
:value="modelValue ?? fallback"
:disabled="!present || disabled"
@input="updateValue($event.target.value)"
>
<div
v-if="validColor"
class="validIndicator"
:style="{backgroundColor: modelValue || fallback}"
/>
<div
v-else-if="transparentColor"
class="transparentIndicator"
/>
<div
v-else-if="computedColor"
class="computedIndicator"
:style="{backgroundColor: fallback}"
/>
<div
v-else
class="invalidIndicator"
/>
<label class="nativeColor">
<FAIcon icon="eye-dropper" />
<input
:id="name"
class="unstyled"
type="color"
:value="modelValue || fallback"
:disabled="!present || disabled"
:class="{ disabled: !present || disabled }"
@input="updateValue($event.target.value)"
>
</label>
</div>
</div>
</template>
<script>
import Checkbox from '../checkbox/checkbox.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js'
import { throttle } from 'lodash'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faEyeDropper
} from '@fortawesome/free-solid-svg-icons'
library.add(
faEyeDropper
)
export default {
components: {
Checkbox
},
props: {
// Name of color, used for identifying
name: {
required: true,
type: String
},
// Readable label
label: {
required: true,
type: String
},
// use unstyled, uh, style
unstyled: {
required: false,
type: Boolean
},
// Color value, should be required but vue cannot tell the difference
// between "property missing" and "property set to undefined"
modelValue: {
required: false,
type: String,
default: undefined
},
// Color fallback to use when value is not defeind
fallback: {
required: false,
type: String,
default: undefined
},
// Disable the control
disabled: {
required: false,
type: Boolean,
default: false
},
// Show "optional" tickbox, for when value might become mandatory
showOptionalCheckbox: {
required: false,
type: Boolean,
default: true
},
// Force "optional" tickbox to hide
hideOptionalCheckbox: {
required: false,
type: Boolean,
default: false
+ },
+ compact: {
+ required: false,
+ type: Boolean
}
},
emits: ['update:modelValue'],
computed: {
present () {
return typeof this.modelValue !== 'undefined'
},
validColor () {
return hex2rgb(this.modelValue || this.fallback)
},
transparentColor () {
return this.modelValue === 'transparent'
},
computedColor () {
return this.modelValue && (this.modelValue.startsWith('--') || this.modelValue.startsWith('$'))
}
},
methods: {
updateValue: throttle(function (value) {
this.$emit('update:modelValue', value)
}, 100)
}
}
</script>
<style lang="scss" src="./color_input.scss"></style>
diff --git a/src/components/component_preview/component_preview.vue b/src/components/component_preview/component_preview.vue
index 0d81128c7b..0a0f9541ea 100644
--- a/src/components/component_preview/component_preview.vue
+++ b/src/components/component_preview/component_preview.vue
@@ -1,114 +1,115 @@
<template>
<div
:id="'component-preview-' + randomSeed"
class="ComponentPreview"
:class="{ '-shadow-controls': shadowControl }"
>
<label
v-show="shadowControl"
role="heading"
class="header"
:class="{ faint: disabled }"
>
{{ $t('settings.style.shadows.offset') }}
</label>
<label
v-show="shadowControl && !hideControls"
class="x-shift-number"
>
{{ $t('settings.style.shadows.offset-x') }}
<input
:value="shadow?.x"
:disabled="disabled"
:class="{ disabled }"
class="input input-number"
type="number"
@input="e => updateProperty('x', e.target.value)"
>
</label>
<label
v-show="shadowControl && !hideControls"
class="y-shift-number"
>
{{ $t('settings.style.shadows.offset-y') }}
<input
:value="shadow?.y"
:disabled="disabled"
:class="{ disabled }"
class="input input-number"
type="number"
@input="e => updateProperty('y', e.target.value)"
>
</label>
<input
v-show="shadowControl && !hideControls"
:value="shadow?.x"
:disabled="disabled"
:class="{ disabled }"
class="input input-range x-shift-slider"
type="range"
max="20"
min="-20"
@input="e => updateProperty('x', e.target.value)"
>
<input
v-show="shadowControl && !hideControls"
:value="shadow?.y"
:disabled="disabled"
:class="{ disabled }"
class="input input-range y-shift-slider"
type="range"
max="20"
min="-20"
@input="e => updateProperty('y', e.target.value)"
>
<div
class="preview-window"
:class="{ '-light-grid': lightGrid }"
>
<div
class="preview-block"
:class="previewClass"
>
{{ $t('settings.style.themes3.editor.test_string') }}
</div>
<div
v-if="invalid"
class="invalid-container"
>
<div class="alert error invalid-label">
{{ $t('settings.style.themes3.editor.invalid') }}
</div>
</div>
</div>
<div class="assists">
<Checkbox
v-model="lightGrid"
name="lightGrid"
class="input-light-grid"
>
{{ $t('settings.style.shadows.light_grid') }}
</Checkbox>
<div class="style-control">
<label class="label">
{{ $t('settings.style.shadows.zoom') }}
</label>
<input
v-model="zoom"
class="input input-number y-shift-number"
type="number"
>
</div>
<ColorInput
v-if="!noColorControl"
v-model="colorOverride"
class="input-color-input"
fallback="#606060"
+ :compact="true"
:label="$t('settings.style.shadows.color_override')"
/>
</div>
</div>
</template>
<script src="./component_preview.js" />
<style src="./component_preview.scss" lang="scss" />
diff --git a/src/components/exporter/exporter.vue b/src/components/exporter/exporter.vue
index 79defdf6f7..e36d9dd62b 100644
--- a/src/components/exporter/exporter.vue
+++ b/src/components/exporter/exporter.vue
@@ -1,30 +1,33 @@
<template>
<div class="exporter">
<div v-if="processing">
<FAIcon
icon="circle-notch"
size="lg"
spin
/>
<span>{{ processingMessage || $t('exporter.processing') }}</span>
</div>
<button
v-else
class="btn button-default"
@click="process"
>
{{ exportButtonLabel || $t('exporter.export') }}
</button>
</div>
</template>
<script src="./exporter.js"></script>
<style lang="scss">
.exporter {
+ display: flex;
+ flex-direction: column;
+
&-processing {
margin: 0.25em;
}
}
</style>
diff --git a/src/components/font_control/font_control.vue b/src/components/font_control/font_control.vue
index 5f4ed105c6..9e2c338cf0 100644
--- a/src/components/font_control/font_control.vue
+++ b/src/components/font_control/font_control.vue
@@ -1,157 +1,152 @@
<template>
<div class="font-control">
- <label
- :id="name + '-label'"
- :for="manualEntry ? name : name + '-font-switcher'"
- class="label"
- >
- {{ $t('settings.style.themes3.font.label', { label }) }}
- </label>
- {{ ' ' }}
<Checkbox
v-if="typeof fallback !== 'undefined'"
:id="name + '-o'"
class="font-checkbox"
:model-value="present"
@change="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)"
>
- {{ $t('settings.style.themes3.define') }}
+ <i18n-t
+ scope="global"
+ keypath="settings.style.fonts.override"
+ tag="span"
+ >
+ {{ label }}
+ </i18n-t>
</Checkbox>
+ {{ ' ' }}
<div
v-if="modelValue?.family"
class="font-input"
>
<label
v-if="manualEntry"
:id="name + '-label'"
:for="manualEntry ? name : name + '-font-switcher'"
class="label"
>
<i18n-t
keypath="settings.style.themes3.font.entry"
tag="span"
scope="global"
>
<template #fontFamily>
<code>font-family</code>
</template>
</i18n-t>
</label>
<label
v-else
:id="name + '-label'"
:for="manualEntry ? name : name + '-font-switcher'"
class="label"
>
{{ $t('settings.style.themes3.font.select') }}
</label>
{{ ' ' }}
<span
v-if="manualEntry"
class="btn-group"
>
<button
class="btn button-default"
:title="$t('settings.style.themes3.font.lookup_local_fonts')"
@click="toggleManualEntry"
>
<FAIcon
fixed-width
icon="font"
/>
</button>
<input
:id="name"
:model-value="modelValue.family"
class="input custom-font"
type="text"
@update:modelValue="$emit('update:modelValue', { ...(modelValue || {}), family: $event.target.value })"
>
</span>
<span
v-else
class="btn-group"
>
<button
class="btn button-default"
:title="$t('settings.style.themes3.font.enter_manually')"
@click="toggleManualEntry"
>
<FAIcon
fixed-width
icon="keyboard"
/>
</button>
<Select
:id="name + '-local-font-switcher'"
:model-value="modelValue?.family"
class="custom-font"
@update:model-value="v => $emit('update:modelValue', { ...(modelValue || {}), family: v })"
>
<optgroup
:label="$t('settings.style.themes3.font.group-builtin')"
>
<option
v-for="option in availableOptions"
:key="option"
:value="option"
:style="{ fontFamily: option === 'inherit' ? null : option }"
>
{{ $t('settings.style.themes3.font.builtin.' + option) }}
</option>
</optgroup>
<optgroup
v-if="localFontsSize > 0"
:label="$t('settings.style.themes3.font.group-local')"
>
<option
v-for="option in localFontsList"
:key="option"
:value="option"
:style="{ fontFamily: option }"
>
{{ option }}
</option>
</optgroup>
<optgroup
v-else
:label="$t('settings.style.themes3.font.group-local')"
>
<option disabled>
{{ $t('settings.style.themes3.font.local-unavailable1') }}
</option>
<option disabled>
{{ $t('settings.style.themes3.font.local-unavailable2') }}
</option>
</optgroup>
</Select>
</span>
</div>
</div>
</template>
<script src="./font_control.js"></script>
<style lang="scss">
.font-control {
.custom-font {
min-width: 20em;
max-width: 20em;
}
.font-input {
margin-left: 2em;
margin-top: 0.5em;
}
-
- .font-checkbox {
- margin-left: 1em;
- }
}
.invalid-tooltip {
margin: 0.5em 1em;
min-width: 10em;
text-align: center;
}
</style>
diff --git a/src/components/importer/importer.vue b/src/components/importer/importer.vue
index 12779b4578..0259ca70e2 100644
--- a/src/components/importer/importer.vue
+++ b/src/components/importer/importer.vue
@@ -1,60 +1,60 @@
<template>
- <div class="importer">
+ <div class="importer btn-group">
<form>
<input
ref="input"
class="input"
type="file"
@change="change"
>
</form>
<FAIcon
v-if="submitting"
class="importer-uploading"
spin
icon="circle-notch"
/>
<button
v-else
class="btn button-default"
@click="submit"
>
- {{ submitButtonLabel || $t('importer.submit') }}
+ {{ submitButtonLabel || $t('importer.import') }}
</button>
<div v-if="success">
<button
class="button-unstyled"
@click="dismiss"
>
<FAIcon
icon="times"
/>
</button>
{{ ' ' }}
<span>{{ successMessage || $t('importer.success') }}</span>
</div>
<div v-else-if="error">
<button
class="button-unstyled"
@click="dismiss"
>
<FAIcon
icon="times"
/>
</button>
{{ ' ' }}
<span>{{ errorMessage || $t('importer.error') }}</span>
</div>
</div>
</template>
<script src="./importer.js"></script>
<style lang="scss">
.importer {
&-uploading {
font-size: 1.5em;
margin: 0.25em;
}
}
</style>
diff --git a/src/components/opacity_input/opacity_input.vue b/src/components/opacity_input/opacity_input.vue
index 041219feb5..19de233d7b 100644
--- a/src/components/opacity_input/opacity_input.vue
+++ b/src/components/opacity_input/opacity_input.vue
@@ -1,51 +1,51 @@
<template>
<div
class="opacity-control style-control"
:class="{ disabled: !present || disabled }"
>
<label
:for="name"
class="label"
:class="{ faint: !present || disabled }"
>
- {{ label }}
+ {{ label || $t('settings.style.themes3.editor.opacity') }}
</label>
<Checkbox
v-if="typeof fallback !== 'undefined'"
:model-value="present"
:disabled="disabled"
class="opt"
@update:model-value="$emit('update:modelValue', !present ? fallback : undefined)"
/>
<input
:id="name"
class="input input-number"
type="number"
:value="modelValue || fallback"
:disabled="!present || disabled"
:class="{ disabled: !present || disabled }"
max="1"
min="0"
step=".05"
@input="$emit('update:modelValue', $event.target.value)"
>
</div>
</template>
<script>
import Checkbox from '../checkbox/checkbox.vue'
export default {
components: {
Checkbox
},
props: [
'name', 'label', 'modelValue', 'fallback', 'disabled'
],
emits: ['update:modelValue'],
computed: {
present () {
return typeof this.modelValue !== 'undefined'
}
}
}
</script>
diff --git a/src/components/palette_editor/palette_editor.vue b/src/components/palette_editor/palette_editor.vue
index 252928b9e2..3040696d0f 100644
--- a/src/components/palette_editor/palette_editor.vue
+++ b/src/components/palette_editor/palette_editor.vue
@@ -1,195 +1,222 @@
<template>
<div
class="PaletteEditor"
- :class="{ '-compact': compact, '-apply': apply }"
+ :class="{ '-compact': compact, '-apply': apply, '-mobile': mobile }"
>
- <ColorInput
- v-for="key in paletteKeys"
- :key="key"
- :name="key"
- :model-value="props.modelValue[key]"
- :fallback="fallback(key)"
- :label="$t('settings.style.themes3.palette.' + key)"
- @update:model-value="value => updatePalette(key, value)"
- />
- <button
- class="btn button-default palette-import-button"
- @click="importPalette"
- >
- <FAIcon icon="file-import" />
- {{ $t('settings.style.themes3.palette.import') }}
- </button>
- <button
- class="btn button-default palette-export-button"
- @click="exportPalette"
- >
- <FAIcon icon="file-export" />
- {{ $t('settings.style.themes3.palette.export') }}
- </button>
- <button
- v-if="apply"
- class="btn button-default palette-apply-button"
- :disabled="disabled"
- :class="{ disabled }"
- @click="applyPalette"
- >
- {{ $t('settings.style.themes3.palette.apply') }}
- </button>
+ <div class="palette">
+ <ColorInput
+ v-for="key in paletteKeys"
+ :key="key"
+ :name="key"
+ :model-value="props.modelValue[key]"
+ :fallback="fallback(key)"
+ :label="$t('settings.style.themes3.palette.' + key)"
+ @update:model-value="value => updatePalette(key, value)"
+ />
+ </div>
+ <div class="buttons">
+ <button
+ class="btn button-default palette-import-button"
+ @click="importPalette"
+ >
+ <FAIcon icon="file-import" />
+ {{ $t('settings.style.themes3.palette.import') }}
+ </button>
+ <button
+ class="btn button-default palette-export-button"
+ @click="exportPalette"
+ >
+ <FAIcon icon="file-export" />
+ {{ $t('settings.style.themes3.palette.export') }}
+ </button>
+ </div>
+ <div class="buttons">
+ <button
+ v-if="apply"
+ class="btn button-default palette-apply-button"
+ :disabled="disabled"
+ :class="{ disabled }"
+ @click="applyPalette"
+ >
+ {{ $t('settings.style.themes3.palette.apply') }}
+ </button>
+ </div>
</div>
</template>
<script setup>
+import { computed } from 'vue'
+
import ColorInput from 'src/components/color_input/color_input.vue'
import {
newImporter,
newExporter
} from 'src/services/export_import/export_import.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faFileImport,
faFileExport
} from '@fortawesome/free-solid-svg-icons'
+import { useInterfaceStore } from 'src/stores/interface'
+
library.add(
faFileImport,
faFileExport
)
const paletteKeys = [
'bg',
'fg',
'text',
'link',
'accent',
'cRed',
'cBlue',
'cGreen',
'cOrange',
'wallpaper'
]
const props = defineProps(['modelValue', 'compact', 'apply', 'disabled'])
const emit = defineEmits(['update:modelValue', 'applyPalette'])
const getExportedObject = () => paletteKeys.reduce((acc, key) => {
const value = props.modelValue[key]
if (value == null) {
return acc
} else {
return { ...acc, [key]: props.modelValue[key] }
}
}, {})
const paletteExporter = newExporter({
filename: 'pleroma_palette',
extension: 'json',
getExportedObject
})
const paletteImporter = newImporter({
accept: '.json',
onImport (parsed) {
emit('update:modelValue', parsed)
}
})
const exportPalette = () => {
paletteExporter.exportData()
}
const importPalette = () => {
paletteImporter.importData()
}
const applyPalette = () => {
emit('applyPalette', getExportedObject())
}
+const mobile = computed(() => {
+ return useInterfaceStore().layoutType === 'mobile'
+})
+
const fallback = (key) => {
if (key === 'accent') {
return props.modelValue.link
}
if (key === 'link') {
return props.modelValue.accent
}
if (key.startsWith('extra')) {
return '#FF00FF'
}
if (key.startsWith('wallpaper')) {
return '#008080'
}
}
const updatePalette = (paletteKey, value) => {
emit('update:modelValue', {
...props.modelValue,
[paletteKey]: value
})
}
</script>
<style lang="scss">
.PaletteEditor {
- display: grid;
justify-content: space-around;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(5, 1fr) auto;
grid-gap: 0.5em;
align-items: baseline;
+ .buttons {
+ margin-top: 0.5em;
+ display: grid;
+ gap: 0.5em
+ }
+
+ .palette {
+ display: grid;
+ grid-template-rows: 1fr;
+ grid-auto-flow: row;
+ grid-auto-rows: auto;
+ grid-template-columns: repeat(auto-fill, 10em);
+ grid-gap: 0.5em;
+ margin-bottom: 0.5em;
+ }
+
.palette-import-button {
grid-column: 1 / span 2;
}
.palette-export-button {
grid-column: 3 / span 2;
}
.palette-apply-button {
grid-column: 1 / span 2;
}
.color-input.style-control {
margin: 0;
}
&.-compact {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(5, 1fr) auto;
.palette-import-button {
grid-column: 1;
}
.palette-export-button {
grid-column: 2;
}
&.-apply {
grid-template-rows: repeat(5, 1fr) auto auto;
.palette-apply-button {
grid-column: 1 / span 2;
}
}
+ }
- .-mobile & {
- grid-template-columns: 1fr;
- grid-template-rows: repeat(10, 1fr) auto;
-
- .palette-import-button {
- grid-column: 1;
+ &.-mobile {
+ &.-apply {
+ .palette-apply-button {
+ grid-column: 1 / span 2;
}
+ }
- .palette-export-button {
- grid-column: 1;
- }
+ .color-input {
+ display: grid;
+ gap: 0.5em;
- &.-apply {
- .palette-apply-button {
- grid-column: 1;
- }
+ label {
+ flex: 1;
}
}
}
}
</style>
diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js
index d2e605adbd..e8e8579185 100644
--- a/src/components/popover/popover.js
+++ b/src/components/popover/popover.js
@@ -1,396 +1,396 @@
/* global process */
const Popover = {
name: 'Popover',
props: {
// Action to trigger popover: either 'hover' or 'click'
trigger: String,
// 'top', 'bottom', 'left', 'right'
placement: String,
// Takes object with properties 'x' and 'y', values of these can be
// 'container' for using offsetParent as boundaries for either axis
// or 'viewport'
boundTo: Object,
// Takes a selector to use as a replacement for the parent container
// for getting boundaries for x an y axis
boundToSelector: String,
// Takes a top/bottom/left/right object, how much space to leave
// between boundary and popover element
margin: Object,
// Takes a x/y object and tells how many pixels to offset from
// anchor point on either axis
offset: Object,
// Replaces the classes you may want for the popover container.
// Use 'popover-default' in addition to get the default popover
// styles with your custom class.
popoverClass: String,
// If true, subtract padding when calculating position for the popover,
// use it when popover offset looks to be different on top vs bottom.
removePadding: Boolean,
// self-explanatory (i hope)
disabled: Boolean,
// Instead of putting popover next to anchor, overlay popover's center on top of anchor's center
overlayCenters: Boolean,
// What selector (witin popover!) to use for determining center of popover
overlayCentersSelector: String,
// Lets hover popover stay when clicking inside of it
stayOnClick: Boolean,
// Use styled button (to avoid nested buttons)
normalButton: Boolean,
// Whether to hide the trigger totally
hideTrigger: {
type: Boolean,
default: false,
},
triggerAttrs: {
type: Object,
default: {}
}
},
inject: { // override popover z layer
popoversZLayer: {
default: ''
}
},
data () {
return {
// lockReEntry is a flag that is set when mouse cursor is leaving the popover's content
// so that if mouse goes back into popover it won't be re-shown again to prevent annoyance
// with popovers refusing to be hidden when user wants to interact with something in below popover
anchorEl: null,
// There's an issue where having teleport enabled by default causes things just...
// not render at all, i.e. main post status form and its emoji inputs
teleport: false,
lockReEntry: false,
hidden: true,
styles: {},
oldSize: { width: 0, height: 0 },
scrollable: null,
// used to avoid blinking if hovered onto popover
graceTimeout: null,
parentPopover: null,
disableClickOutside: false,
childrenShown: new Set()
}
},
computed: {
allTriggerAttrs () {
if (process.env.NODE_ENV === 'development') {
if ('aria-hidden' in this.triggerAttrs) {
throw new Error('Do not use aria-hidden in triggerAttrs. Instead set hideTrigger to true')
}
}
const attrs = {
...this.triggerAttrs,
}
if (this.hideTrigger) {
attrs['aria-hidden'] = true
attrs.tabindex = 1
}
return attrs
}
},
methods: {
setAnchorEl (el) {
this.anchorEl = el
this.updateStyles()
},
containerBoundingClientRect () {
const container = this.boundToSelector ? this.$el.closest(this.boundToSelector) : this.$el.offsetParent
return container.getBoundingClientRect()
},
updateStyles () {
if (this.hidden) {
this.styles = {}
return
}
// Popover will be anchored around this element, trigger ref is the container, so
// its children are what are inside the slot. Expect only one v-slot:trigger.
const anchorEl = this.anchorEl || (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el
// SVGs don't have offsetWidth/Height, use fallback
const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight
const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
const anchorScreenBox = anchorEl.getBoundingClientRect()
const anchorStyle = getComputedStyle(anchorEl)
const topPadding = parseFloat(anchorStyle.paddingTop)
const bottomPadding = parseFloat(anchorStyle.paddingBottom)
const rightPadding = parseFloat(anchorStyle.paddingRight)
const leftPadding = parseFloat(anchorStyle.paddingLeft)
// Screen position of the origin point for popover = center of the anchor
const origin = {
x: anchorScreenBox.left + anchorWidth * 0.5,
y: anchorScreenBox.top + anchorHeight * 0.5
}
const content = this.$refs.content
const overlayCenter = this.overlayCenters
? this.$refs.content.querySelector(this.overlayCentersSelector)
: null
// Minor optimization, don't call a slow reflow call if we don't have to
const parentScreenBox = this.boundTo &&
(this.boundTo.x === 'container' || this.boundTo.y === 'container') &&
this.containerBoundingClientRect()
const margin = this.margin || {}
// What are the screen bounds for the popover? Viewport vs container
// when using viewport, using default margin values to dodge the navbar
const xBounds = this.boundTo && this.boundTo.x === 'container'
? {
min: parentScreenBox.left + (margin.left || 0),
max: parentScreenBox.right - (margin.right || 0)
}
: {
min: 0 + (margin.left || 10),
max: window.innerWidth - (margin.right || 10)
}
const yBounds = this.boundTo && this.boundTo.y === 'container'
? {
min: parentScreenBox.top + (margin.top || 0),
max: parentScreenBox.bottom - (margin.bottom || 0)
}
: {
min: 0 + (margin.top || 50),
max: window.innerHeight - (margin.bottom || 5)
}
let horizOffset = 0
let vertOffset = 0
if (overlayCenter) {
const box = content.getBoundingClientRect()
const overlayCenterScreenBox = overlayCenter.getBoundingClientRect()
const leftInnerOffset = overlayCenterScreenBox.left - box.left
const topInnerOffset = overlayCenterScreenBox.top - box.top
horizOffset = -leftInnerOffset - overlayCenter.offsetWidth * 0.5
vertOffset = -topInnerOffset - overlayCenter.offsetHeight * 0.5
} else {
horizOffset = content.offsetWidth * -0.5
vertOffset = content.offsetHeight * -0.5
}
const leftBorder = origin.x + horizOffset
const rightBorder = leftBorder + content.offsetWidth
const topBorder = origin.y + vertOffset
const bottomBorder = topBorder + content.offsetHeight
// If overflowing from left, move it so that it doesn't
if (leftBorder < xBounds.min) {
horizOffset += xBounds.min - leftBorder
}
// If overflowing from right, move it so that it doesn't
if (rightBorder > xBounds.max) {
horizOffset -= rightBorder - xBounds.max
}
// If overflowing from top, move it so that it doesn't
if (topBorder < yBounds.min) {
vertOffset += yBounds.min - topBorder
}
// If overflowing from bottom, move it so that it doesn't
if (bottomBorder > yBounds.max) {
vertOffset -= bottomBorder - yBounds.max
}
let translateX = 0
let translateY = 0
if (overlayCenter) {
translateX = origin.x + horizOffset
translateY = origin.y + vertOffset
} else if (this.placement !== 'right' && this.placement !== 'left') {
// Default to whatever user wished with placement prop
let usingTop = this.placement !== 'bottom'
// Handle special cases, first force to displaying on top if there's no space on bottom,
// regardless of what placement value was. Then check if there's no space on top, and
// force to bottom, again regardless of what placement value was.
const topBoundary = origin.y - anchorHeight * 0.5 + (this.removePadding ? topPadding : 0)
const bottomBoundary = origin.y + anchorHeight * 0.5 - (this.removePadding ? bottomPadding : 0)
if (bottomBoundary + content.offsetHeight > yBounds.max) usingTop = true
if (topBoundary - content.offsetHeight < yBounds.min) usingTop = false
const yOffset = (this.offset && this.offset.y) || 0
translateY = usingTop
? topBoundary - yOffset - content.offsetHeight
: bottomBoundary + yOffset
const xOffset = (this.offset && this.offset.x) || 0
translateX = origin.x + horizOffset + xOffset
} else {
// Default to whatever user wished with placement prop
let usingLeft = this.placement !== 'right'
// Handle special cases, first force to displaying on left if there's no space on right,
// regardless of what placement value was. Then check if there's no space on right, and
// force to left, again regardless of what placement value was.
const leftBoundary = origin.x - anchorWidth * 0.5 + (this.removePadding ? leftPadding : 0)
const rightBoundary = origin.x + anchorWidth * 0.5 - (this.removePadding ? rightPadding : 0)
if (rightBoundary + content.offsetWidth > xBounds.max) usingLeft = true
if (leftBoundary - content.offsetWidth < xBounds.min) usingLeft = false
const xOffset = (this.offset && this.offset.x) || 0
translateX = usingLeft
? leftBoundary - xOffset - content.offsetWidth
: rightBoundary + xOffset
const yOffset = (this.offset && this.offset.y) || 0
translateY = origin.y + vertOffset + yOffset
}
this.styles = {
left: `${Math.round(translateX)}px`,
top: `${Math.round(translateY)}px`
}
if (this.popoversZLayer) {
this.styles['--ZI_popover_override'] = `var(--ZI_${this.popoversZLayer}_popovers)`
}
if (parentScreenBox) {
this.styles.maxWidth = `${Math.round(parentScreenBox.width)}px`
}
},
showPopover () {
if (this.disabled) return
this.disableClickOutside = true
setTimeout(() => {
this.disableClickOutside = false
}, 0)
const wasHidden = this.hidden
this.hidden = false
this.parentPopover && this.parentPopover.onChildPopoverState(this, true)
if (this.trigger === 'click' || this.stayOnClick) {
document.addEventListener('click', this.onClickOutside)
}
this.scrollable.addEventListener('scroll', this.onScroll)
this.scrollable.addEventListener('resize', this.onResize)
this.$nextTick(() => {
if (wasHidden) this.$emit('show')
this.updateStyles()
})
},
hidePopover () {
if (this.disabled) return
if (!this.hidden) this.$emit('close')
this.hidden = true
this.parentPopover && this.parentPopover.onChildPopoverState(this, false)
if (this.trigger === 'click') {
document.removeEventListener('click', this.onClickOutside)
}
- this.scrollable.removeEventListener('scroll', this.onScroll)
- this.scrollable.removeEventListener('resize', this.onResize)
+ this.scrollable?.removeEventListener('scroll', this.onScroll)
+ this.scrollable?.removeEventListener('resize', this.onResize)
},
resizePopover () {
setTimeout(() => {
this.updateStyles()
}, 1)
},
onMouseenter () {
if (this.trigger === 'hover') {
this.lockReEntry = false
clearTimeout(this.graceTimeout)
this.graceTimeout = null
this.showPopover()
}
},
onMouseleave () {
if (this.trigger === 'hover' && this.childrenShown.size === 0) {
this.graceTimeout = setTimeout(() => this.hidePopover(), 1)
}
},
onMouseenterContent () {
if (this.trigger === 'hover' && !this.lockReEntry) {
this.lockReEntry = true
clearTimeout(this.graceTimeout)
this.graceTimeout = null
this.showPopover()
}
},
onMouseleaveContent () {
if (this.trigger === 'hover' && this.childrenShown.size === 0) {
this.graceTimeout = setTimeout(() => this.hidePopover(), 1)
}
},
onClick () {
if (this.trigger === 'click') {
if (this.hidden) {
this.showPopover()
} else {
this.hidePopover()
}
}
},
onClickOutside (e) {
if (this.disableClickOutside) return
if (this.hidden) return
if (this.$refs.content && this.$refs.content.contains(e.target)) return
if (this.$el.contains(e.target)) return
if (this.childrenShown.size > 0) return
this.hidePopover()
if (this.parentPopover) this.parentPopover.onClickOutside(e)
},
onScroll () {
this.updateStyles()
},
onResize () {
const content = this.$refs.content
if (!content) return
if (this.oldSize.width !== content.offsetWidth || this.oldSize.height !== content.offsetHeight) {
this.updateStyles()
this.oldSize = { width: content.offsetWidth, height: content.offsetHeight }
}
},
onChildPopoverState (childRef, state) {
if (state) {
this.childrenShown.add(childRef)
} else {
this.childrenShown.delete(childRef)
}
}
},
updated () {
// Monitor changes to content size, update styles only when content sizes have changed,
// that should be the only time we need to move the popover box if we don't care about scroll
// or resize
this.onResize()
},
mounted () {
this.teleport = true
let scrollable = this.$refs.trigger.closest('.column.-scrollable') ||
this.$refs.trigger.closest('.mobile-notifications')
if (!scrollable) scrollable = window
this.scrollable = scrollable
let parent = this.$parent
while (parent && parent.$.type.name !== 'Popover') {
parent = parent.$parent
}
this.parentPopover = parent
},
beforeUnmount () {
this.hidePopover()
}
}
export default Popover
diff --git a/src/components/post_status_form/post_status_form.scss b/src/components/post_status_form/post_status_form.scss
index 78b93fd284..641dc6dac3 100644
--- a/src/components/post_status_form/post_status_form.scss
+++ b/src/components/post_status_form/post_status_form.scss
@@ -1,276 +1,276 @@
.post-status-form {
position: relative;
.attachments {
margin-bottom: 0.5em;
}
.more-post-actions {
height: 100%;
.btn {
height: 100%;
}
}
.form-bottom {
display: flex;
justify-content: space-between;
padding: 0.5em;
height: 2.5em;
.post-button-group {
width: 10em;
display: flex;
.post-button {
flex: 1 0 auto;
}
.more-post-actions {
flex: 0 0 auto;
}
}
p {
margin: 0.35em;
padding: 0.35em;
display: flex;
}
}
.form-bottom-left {
display: flex;
gap: 1.5em;
button {
padding: 0.5em;
margin: -0.5em;
}
}
.preview-heading {
display: flex;
flex-wrap: wrap;
}
.preview-toggle {
flex: 10 0 auto;
cursor: pointer;
user-select: none;
padding-left: 0.5em;
&:hover {
text-decoration: underline;
}
svg,
i {
margin-left: 0.2em;
font-size: 0.8em;
transform: rotate(90deg);
}
}
.preview-container {
margin-bottom: 1em;
}
.preview-error {
font-style: italic;
color: var(--textFaint);
}
.preview-status {
border: 1px solid var(--border);
border-radius: var(--roundness);
padding: 0.5em;
margin: 0;
}
.reply-or-quote-selector {
flex: 1 0 auto;
margin-bottom: 0.5em;
display: grid;
grid-template-columns: 1fr 1fr;
}
.text-format {
.only-format {
color: var(--textFaint);
}
}
.visibility-tray {
display: flex;
justify-content: space-between;
- padding-top: 0.5em;
align-items: baseline;
+ margin-left: -0.5em;
}
.visibility-notice {
border: 1px solid var(--border);
border-radius: var(--roundness);
padding: 0.5em 1em
}
.visibility-notice.edit-warning {
> :first-child {
margin-top: 0;
}
> :last-child {
margin-bottom: 0;
}
}
// Order is not necessary but a good indicator
.media-upload-icon {
order: 1;
justify-content: left;
}
.emoji-icon {
order: 2;
justify-content: center;
}
.poll-icon {
order: 3;
justify-content: right;
}
.media-upload-icon,
.poll-icon,
.emoji-icon {
font-size: 1.85em;
line-height: 1.1;
flex: 1;
padding: 0 0.1em;
display: flex;
align-items: center;
}
.error {
text-align: center;
}
.media-upload-wrapper {
margin-right: 0.2em;
margin-bottom: 0.5em;
width: 18em;
img,
video {
object-fit: contain;
max-height: 10em;
}
.video {
max-height: 10em;
}
input {
flex: 1;
width: 100%;
}
}
.status-input-wrapper {
display: flex;
position: relative;
width: 100%;
flex-direction: column;
}
.btn[disabled] {
cursor: not-allowed;
}
form {
display: flex;
flex-direction: column;
margin: 0.6em;
position: relative;
}
.form-group {
display: flex;
flex-direction: column;
padding: 0.25em 0.5em 0.5em;
line-height: 1.85;
}
.inputs-wrapper {
padding: 0;
}
.input.form-post-body {
// TODO: make a resizable textarea component?
box-sizing: content-box; // needed for easier computation of dynamic size
overflow: hidden;
transition: min-height 200ms 100ms;
// stock padding + 1 line of text (for counter)
padding-bottom: calc(var(--_padding) + var(--post-line-height) * 1em);
padding-right: 1.5em;
// two lines of text
height: calc(var(--post-line-height) * 1em);
min-height: calc(var(--post-line-height) * 1em);
resize: none;
background: transparent;
text-wrap: stable;
&.scrollable-form {
overflow-y: auto;
}
}
.main-input {
position: relative;
}
.subject-input {
border-bottom: 1px solid var(--border);
}
.character-counter {
position: absolute;
bottom: 0;
right: 0;
padding: 0;
margin: 0 0.5em;
&.error {
color: var(--cRed);
}
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 0.6; }
}
@keyframes fade-out {
from { opacity: 0.6; }
to { opacity: 0; }
}
.drop-indicator {
position: absolute;
width: 100%;
height: 100%;
font-size: 5em;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.6;
color: var(--text);
background-color: var(--bg);
border-radius: var(--roundness);
border: 2px dashed var(--text);
}
.auto-save-status {
align-self: center;
}
}
diff --git a/src/components/scope_selector/scope_selector.js b/src/components/scope_selector/scope_selector.js
index 52cda368b8..462892cb6d 100644
--- a/src/components/scope_selector/scope_selector.js
+++ b/src/components/scope_selector/scope_selector.js
@@ -1,69 +1,90 @@
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faEnvelope,
faLock,
faLockOpen,
faGlobe
} from '@fortawesome/free-solid-svg-icons'
library.add(
faEnvelope,
faGlobe,
faLock,
faLockOpen
)
const ScopeSelector = {
- props: [
- 'showAll',
- 'userDefault',
- 'originalScope',
- 'initialScope',
- 'onScopeChange'
- ],
+ props: {
+ showAll: {
+ required: true,
+ type: Boolean
+ },
+ userDefault: {
+ required: true,
+ type: String
+ },
+ originalScope: {
+ required: false,
+ type: String
+ },
+ initialScope: {
+ required: false,
+ type: String
+ },
+ onScopeChange: {
+ required: true,
+ type: Function
+ },
+ unstyled: {
+ required: false,
+ type: Boolean,
+ default: true
+ }
+ },
data () {
return {
currentScope: this.initialScope
}
},
computed: {
showNothing () {
return !this.showPublic && !this.showUnlisted && !this.showPrivate && !this.showDirect
},
showPublic () {
return this.originalScope !== 'direct' && this.shouldShow('public')
},
showUnlisted () {
return this.originalScope !== 'direct' && this.shouldShow('unlisted')
},
showPrivate () {
return this.originalScope !== 'direct' && this.shouldShow('private')
},
showDirect () {
return this.shouldShow('direct')
},
css () {
+ const style = this.unstyled ? 'button-unstyled' : 'button-default'
return {
- public: { toggled: this.currentScope === 'public' },
- unlisted: { toggled: this.currentScope === 'unlisted' },
- private: { toggled: this.currentScope === 'private' },
- direct: { toggled: this.currentScope === 'direct' }
+ public: [style, { toggled: this.currentScope === 'public' }],
+ unlisted: [style, { toggled: this.currentScope === 'unlisted' }],
+ private: [style, { toggled: this.currentScope === 'private' }],
+ direct: [style, { toggled: this.currentScope === 'direct' }]
}
}
},
methods: {
shouldShow (scope) {
return this.showAll ||
this.currentScope === scope ||
this.originalScope === scope ||
this.userDefault === scope ||
scope === 'direct'
},
changeVis (scope) {
this.currentScope = scope
this.onScopeChange && this.onScopeChange(scope)
}
}
}
export default ScopeSelector
diff --git a/src/components/scope_selector/scope_selector.vue b/src/components/scope_selector/scope_selector.vue
index b90ae0205c..cbe51a07f5 100644
--- a/src/components/scope_selector/scope_selector.vue
+++ b/src/components/scope_selector/scope_selector.vue
@@ -1,76 +1,78 @@
<template>
<div
v-if="!showNothing"
- class="ScopeSelector"
+ class="ScopeSelector btn-group"
>
<button
v-if="showDirect"
- class="button-unstyled scope"
+ class="scope"
:class="css.direct"
:title="$t('post_status.scope.direct')"
type="button"
@click="changeVis('direct')"
>
<FAIcon
icon="envelope"
class="fa-scale-110 fa-old-padding"
/>
</button>
{{ ' ' }}
<button
v-if="showPrivate"
- class="button-unstyled scope"
+ class="scope"
:class="css.private"
:title="$t('post_status.scope.private')"
type="button"
@click="changeVis('private')"
>
<FAIcon
icon="lock"
class="fa-scale-110 fa-old-padding"
/>
</button>
{{ ' ' }}
<button
v-if="showUnlisted"
- class="button-unstyled scope"
+ class="scope"
:class="css.unlisted"
:title="$t('post_status.scope.unlisted')"
type="button"
@click="changeVis('unlisted')"
>
<FAIcon
icon="lock-open"
class="fa-scale-110 fa-old-padding"
/>
</button>
{{ ' ' }}
<button
v-if="showPublic"
- class="button-unstyled scope"
+ class="scope"
:class="css.public"
:title="$t('post_status.scope.public')"
type="button"
@click="changeVis('public')"
>
<FAIcon
icon="globe"
class="fa-scale-110 fa-old-padding"
/>
</button>
</div>
</template>
<script src="./scope_selector.js"></script>
<style lang="scss">
.ScopeSelector {
+ display: inline-block;
+
.scope {
display: inline-block;
- cursor: pointer;
min-width: 1.3em;
min-height: 1.3em;
text-align: center;
+ padding: 0.5em 0.25em
}
}
</style>
diff --git a/src/components/tab_switcher/tab_switcher.jsx b/src/components/settings_modal/helpers/vertical_tab_switcher.jsx
similarity index 55%
copy from src/components/tab_switcher/tab_switcher.jsx
copy to src/components/settings_modal/helpers/vertical_tab_switcher.jsx
index 02a0b47c04..7fea1cab85 100644
--- a/src/components/tab_switcher/tab_switcher.jsx
+++ b/src/components/settings_modal/helpers/vertical_tab_switcher.jsx
@@ -1,189 +1,213 @@
// eslint-disable-next-line no-unused
import { h, Fragment } from 'vue'
import { mapState } from 'pinia'
+import { throttle } from 'lodash'
+import { mapState as mapPiniaState } from 'pinia'
import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome'
-import './tab_switcher.scss'
+import './vertical_tab_switcher.scss'
import { useInterfaceStore } from 'src/stores/interface'
const findFirstUsable = (slots) => slots.findIndex(_ => _.props)
export default {
- name: 'TabSwitcher',
+ name: 'VerticalTabSwitcher',
props: {
renderOnlyFocused: {
required: false,
type: Boolean,
default: false
},
onSwitch: {
required: false,
type: Function,
default: undefined
},
activeTab: {
required: false,
type: String,
default: undefined
},
- scrollableTabs: {
- required: false,
- type: Boolean,
- default: false
- },
- sideTabBar: {
+ bodyScrollLock: {
required: false,
type: Boolean,
default: false
},
- bodyScrollLock: {
+ parentCollapsed: {
required: false,
type: Boolean,
- default: false
+ default: null
}
},
data () {
return {
- active: findFirstUsable(this.slots())
+ active: findFirstUsable(this.slots()),
+ resizeHandler: null,
+ navSide: 'tabs',
}
},
computed: {
activeIndex () {
// In case of controlled component
if (this.activeTab) {
return this.slots().findIndex(slot => slot && slot.props && this.activeTab === slot.props.key)
} else {
return this.active
}
},
isActive () {
return tabName => {
const isWanted = slot => slot.props && slot.props['data-tab-name'] === tabName
return this.$slots.default().findIndex(isWanted) === this.activeIndex
}
- }
+ },
+ ...mapPiniaState(useInterfaceStore, {
+ mobileLayout: store => store.layoutType === 'mobile'
+ }),
},
beforeUpdate () {
const currentSlot = this.slots()[this.active]
if (!currentSlot.props) {
this.active = findFirstUsable(this.slots())
}
},
methods: {
clickTab (index) {
return (e) => {
e.preventDefault()
this.setTab(index)
}
},
- // DO NOT put it to computed, it doesn't work (caching?)
- slots () {
- if (this.$slots.default()[0].type === Fragment) {
- return this.$slots.default()[0].children
- }
- return this.$slots.default()
- },
setTab (index) {
if (typeof this.onSwitch === 'function') {
this.onSwitch.call(null, this.slots()[index].key)
}
this.active = index
- if (this.scrollableTabs) {
- this.$refs.contents.scrollTop = 0
+ this.changeNavSide('content')
+ },
+ changeNavSide (side) {
+ if (this.navSide !== side) {
+ this.navSide = side
+ }
+ },
+ // DO NOT put it to computed, it doesn't work (caching?)
+ slots () {
+ if (this.$slots.default()[0].type === Fragment) {
+ return this.$slots.default()[0].children
}
+ return this.$slots.default()
}
},
render () {
const tabs = this.slots()
.map((slot, index) => {
const props = slot.props
if (!props) return
- const classesTab = ['tab']
- const classesWrapper = ['tab-wrapper']
- if (this.activeIndex === index) {
- classesTab.push('active')
- classesWrapper.push('active')
- }
- if (props.image) {
- return (
- <div class={classesWrapper.join(' ')}>
- <button
- disabled={props.disabled}
- onClick={this.clickTab(index)}
- class={classesTab.join(' ')}
- type="button"
- role="tab"
- >
- <img src={props.image} title={props['image-tooltip']}/>
- {props.label ? '' : props.label}
- </button>
- </div>
- )
+ const classesTab = ['vertical-tab', 'menu-item']
+ if (this.activeIndex === index && useInterfaceStore().layoutType !== 'mobile') {
+ classesTab.push('-active')
}
return (
- <div class={classesWrapper.join(' ')}>
- <button
- disabled={props.disabled}
- onClick={this.clickTab(index)}
- class={classesTab.join(' ')}
- type="button"
- role="tab"
- >
- {!props.icon ? '' : (<FAIcon class="tab-icon" size="2x" fixed-width icon={props.icon}/>)}
- <span class="text">
- {props.label}
- </span>
- </button>
- </div>
+ <button
+ disabled={props.disabled}
+ onClick={this.clickTab(index)}
+ class={classesTab.join(' ')}
+ type="button"
+ role="tab"
+ title={props.label}
+ >
+ {!props.icon ? '' : (<FAIcon class="tab-icon" size="1x" fixed-width icon={props.icon}/>)}
+ <span class="text">
+ {props.label}
+ </span>
+ </button>
)
})
const contents = this.slots().map((slot, index) => {
const props = slot.props
if (!props) return
const active = this.activeIndex === index
- const classes = [ active ? 'active' : 'hidden' ]
- if (props.fullHeight) {
- classes.push('full-height')
- }
+
let delayRender = slot.props['delay-render']
if (delayRender && active) {
slot.props['delay-render'] = false
delayRender = false
}
const renderSlot = (!delayRender && (!this.renderOnlyFocused || active))
? slot
: ''
+ const headerClasses = ['tab-content-label']
+ const header = (
+ <h2 class={headerClasses}>
+ <button
+ type="button"
+ onClick={() => this.changeNavSide('tabs')}
+ title={this.$t('nav.back')}
+ class="button-unstyled"
+ >
+ <FAIcon
+ size="lg"
+ class="back-button-icon"
+ icon="chevron-left"
+ />
+ </button>
+ {props.label}
+ </h2>
+ )
+
+ const wrapperClasses = ['tab-content-wrapper', active ? '-active' : '-hidden' ]
+ const contentClasses = ['tab-content']
+ if (props['full-width']) {
+ contentClasses.push('-full-width')
+ }
+ if (props['full-height']) {
+ contentClasses.push('-full-height')
+ }
return (
- <div class={classes}>
- {
- this.sideTabBar
- ? <h1 class="mobile-label">{props.label}</h1>
- : ''
- }
- {renderSlot}
+ <div class={wrapperClasses} >
+ <div class="tab-mobile-header">
+ {header}
+ </div>
+ <div class="tab-slot-wrapper">
+ <div class={contentClasses} >
+ {renderSlot}
+ </div>
+ </div>
</div>
)
})
+ const rootClasses = ['vertical-tab-switcher']
+ if (useInterfaceStore().layoutType === 'mobile') {
+ rootClasses.push('-mobile')
+ }
+
+ if (this.navSide === 'tabs') {
+ rootClasses.push('-nav-tabs')
+ } else {
+ rootClasses.push('-nav-contents')
+ }
+
return (
- <div class={'tab-switcher ' + (this.sideTabBar ? 'side-tabs' : 'top-tabs')}>
+ <div ref="root" class={ rootClasses.join(' ') }>
<div
class="tabs"
role="tablist"
+ ref="nav"
>
{tabs}
</div>
<div
- ref="contents"
role="tabpanel"
class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}
v-body-scroll-lock={this.bodyScrollLock}
+ ref="contents"
>
{contents}
</div>
</div>
)
}
}
diff --git a/src/components/settings_modal/helpers/vertical_tab_switcher.scss b/src/components/settings_modal/helpers/vertical_tab_switcher.scss
new file mode 100644
index 0000000000..b51df7d666
--- /dev/null
+++ b/src/components/settings_modal/helpers/vertical_tab_switcher.scss
@@ -0,0 +1,181 @@
+.vertical-tab-switcher {
+ display: flex;
+ flex-direction: row;
+ container-type: inline-size;
+
+ > .tabs {
+ flex: 0 0 15em;
+ flex-direction: column;
+ overflow: hidden auto;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ width: 15em;
+ min-width: 15em;
+ border-right: 1px solid;
+ border-right-color: var(--border);
+ box-sizing: border-box;
+
+ > .menu-item {
+ padding: 0.5em 1em;
+
+ .tab-icon {
+ vertical-align: middle;
+ margin-right: 0.75em;
+ }
+ }
+ }
+
+ > .contents {
+ flex: 1 0 35em;
+
+ .tab-content {
+ align-self: center;
+
+ &:not(.-full-width) {
+ max-width: 40em;
+ }
+
+ &.-full-width {
+ align-self: stretch;
+ }
+
+ &.-full-height {
+ flex: 1;
+ }
+ }
+
+ .tab-content-label {
+ box-sizing: border-box;
+ margin: 0;
+ border-bottom: 1px solid var(--border);
+ display: none;
+
+ button {
+ box-sizing: border-box;
+ padding: 0.5em;
+ }
+ }
+
+ .tab-slot-wrapper {
+ flex: 1 1 auto;
+ height: 100%;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .tab-content-wrapper {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ flex: 1 1 auto;
+
+ &.-hidden {
+ display: none;
+ }
+ }
+ }
+
+ &.-mobile {
+ > .contents {
+ .tab-content-label {
+ display: block
+ }
+ }
+
+ &.-nav-contents {
+ > .contents {
+ display: block;
+ flex-grow: 1;
+ flex-shrink: 1;
+ }
+
+ > .tabs {
+ display: none;
+ flex-grow: 0;
+ flex-shrink: 1;
+ }
+ }
+
+ &.-nav-tabs {
+ > .tabs {
+ display: block;
+ flex-grow: 1;
+ }
+
+ > .contents {
+ display: none;
+ flex-grow: 0;
+ flex-shrink: 1;
+ }
+ }
+ }
+
+ @supports (container-type: inline-size) {
+ &,
+ &.-mobile {
+ &.-nav-contents,
+ &.-nav-tabs {
+ /* I THINK it's a false positive and eslint doesn't understand the @-rule */
+ /* stylelint-disable no-descending-specificity */
+ > .contents {
+ display: block;
+ flex-grow: 1;
+ }
+
+ > .tabs {
+ display: block;
+ flex-grow: 0;
+ }
+ /* stylelint-enable no-descending-specificity */
+ }
+ }
+
+ @container (width < 50em) {
+ > .contents {
+ .tab-content-label {
+ display: block
+ }
+ }
+
+ &.-mobile {
+ > .contents {
+ .tab-content-label {
+ display: block
+ }
+ }
+ }
+
+ &,
+ &.-mobile {
+ &.-nav-contents {
+ > .contents {
+ display: block;
+ flex-grow: 1;
+ }
+
+ > .tabs {
+ display: none;
+ flex-grow: 0;
+ flex-shrink: 1;
+ }
+ }
+
+ &.-nav-tabs {
+ /* stylelint-disable no-descending-specificity */
+ > .tabs {
+ display: block;
+ flex-grow: 1;
+ }
+
+ > .contents {
+ display: none;
+ flex-grow: 0;
+ flex-shrink: 1;
+ }
+ /* stylelint-enable no-descending-specificity */
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss
index 2c975917fd..fd24697b6c 100644
--- a/src/components/settings_modal/settings_modal.scss
+++ b/src/components/settings_modal/settings_modal.scss
@@ -1,117 +1,158 @@
.settings-modal {
overflow: hidden;
+ h2 {
+ font-size: 1.3rem;
+ font-weight: 500;
+ margin-top: 1em;
+ margin-bottom: 1em;
+ }
+
+ h3 {
+ font-size: 1.2rem;
+ font-weight: 500;
+ margin-top: 1em;
+ margin-bottom: 0.5em;
+ border-bottom: 1px solid var(--border);
+ padding-bottom: 0.25em;
+ box-sizing: border-box;
+ padding-left: 0.5em;
+ }
+
h4 {
+ font-size: 1.1rem;
+ margin-top: 1em;
+ margin-bottom: 0.5em;
+ }
+
+ h5 {
+ font-size: 1rem;
margin-bottom: 0.5em;
+ margin-top: 0;
}
.setting-list,
.option-list {
list-style-type: none;
padding-left: 2em;
+ margin: 0;
.btn:not(.dropdown-button) {
padding: 0 2em;
}
li {
- margin-bottom: 0.5em;
+ margin: 1em 0;
}
.suboptions {
margin-top: 0.3em;
}
&.two-column {
- column-count: 2;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
> li {
+ margin: 0;
break-inside: avoid;
}
}
}
.setting-description {
margin-top: 0.2em;
margin-bottom: 2em;
font-size: 70%;
}
.settings-modal-panel {
overflow: hidden;
transition: transform;
transition-timing-function: ease-in-out;
transition-duration: 300ms;
- width: 1000px;
+ width: 70em;
max-width: 90vw;
height: 90vh;
@media all and (width <= 800px) {
max-width: 100vw;
height: 100%;
}
>.panel-body {
height: 100%;
overflow-y: hidden;
.btn {
min-height: 2em;
}
}
}
.settings-footer {
display: flex;
flex-wrap: wrap;
line-height: 2;
>* {
margin-right: 0.5em;
}
.extra-content {
display: flex;
flex-grow: 1;
}
}
&.-mobile {
- .setting-list,
+ .tabs {
+ .menu-item {
+ font-size: 1.2em;
+ padding-top: 0.75em;
+ padding-bottom: 0.75em;
+ }
+ }
+
+ .setting-list:not(.suboptions),
.option-list {
padding-left: 0.25em;
+
+ /* stylelint-disable no-descending-specificity */
+ // it makes no sense
> li {
margin: 1em 0;
line-height: 1.5em;
vertical-align: middle;
}
+ /* stylelint-enable no-descending-specificity */
&.two-column {
- column-count: 1;
+ grid-template-columns: 1fr;
}
}
}
&.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));
@media all and (width <= 800px) {
/* For mobile, the modal takes 100% of the available screen.
This ensures the minimized modal is always 50px above the browser bottom
bar regardless of whether or not it is visible.
*/
transform: translateY(calc(100% - 50px));
}
}
}
}
diff --git a/src/components/settings_modal/settings_modal_admin_content.js b/src/components/settings_modal/settings_modal_admin_content.js
index 593318ec42..bc0039118d 100644
--- a/src/components/settings_modal/settings_modal_admin_content.js
+++ b/src/components/settings_modal/settings_modal_admin_content.js
@@ -1,96 +1,96 @@
-import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
+import VerticalTabSwitcher from './helpers/vertical_tab_switcher.jsx'
import InstanceTab from './admin_tabs/instance_tab.vue'
import LimitsTab from './admin_tabs/limits_tab.vue'
import FrontendsTab from './admin_tabs/frontends_tab.vue'
import EmojiTab from './admin_tabs/emoji_tab.vue'
import { useInterfaceStore } from 'src/stores/interface'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faWrench,
faHand,
faLaptopCode,
faPaintBrush,
faBell,
faDownload,
faEyeSlash,
faInfo
} from '@fortawesome/free-solid-svg-icons'
library.add(
faWrench,
faHand,
faLaptopCode,
faPaintBrush,
faBell,
faDownload,
faEyeSlash,
faInfo
)
const SettingsModalAdminContent = {
components: {
- TabSwitcher,
+ VerticalTabSwitcher,
InstanceTab,
LimitsTab,
FrontendsTab,
EmojiTab
},
computed: {
user () {
return this.$store.state.users.currentUser
},
isLoggedIn () {
return !!this.$store.state.users.currentUser
},
open () {
return useInterfaceStore().settingsModalState !== 'hidden'
},
bodyLock () {
return useInterfaceStore().settingsModalState === 'visible'
},
adminDbLoaded () {
return this.$store.state.adminSettings.loaded
},
adminDescriptionsLoaded () {
return this.$store.state.adminSettings.descriptions !== null
},
noDb () {
return this.$store.state.adminSettings.dbConfigEnabled === false
}
},
created () {
if (this.user.rights.admin) {
this.$store.dispatch('loadAdminStuff')
}
},
methods: {
onOpen () {
const targetTab = useInterfaceStore().settingsModalTargetTab
// We're being told to open in specific tab
if (targetTab) {
const tabIndex = this.$refs.tabSwitcher.$slots.default().findIndex(elm => {
return elm.props && elm.props['data-tab-name'] === targetTab
})
if (tabIndex >= 0) {
this.$refs.tabSwitcher.setTab(tabIndex)
}
}
// Clear the state of target tab, so that next time settings is opened
// it doesn't force it.
useInterfaceStore().clearSettingsModalTargetTab()
}
},
mounted () {
this.onOpen()
},
watch: {
open: function (value) {
if (value) this.onOpen()
}
}
}
export default SettingsModalAdminContent
diff --git a/src/components/settings_modal/settings_modal_admin_content.vue b/src/components/settings_modal/settings_modal_admin_content.vue
index 39ef74f64d..501a3acf6e 100644
--- a/src/components/settings_modal/settings_modal_admin_content.vue
+++ b/src/components/settings_modal/settings_modal_admin_content.vue
@@ -1,79 +1,79 @@
<template>
- <tab-switcher
+ <vertical-tab-switcher
v-if="adminDescriptionsLoaded && (noDb || adminDbLoaded)"
ref="tabSwitcher"
class="settings_tab-switcher"
:side-tab-bar="true"
:scrollable-tabs="true"
:render-only-focused="true"
:body-scroll-lock="bodyLock"
>
<div
v-if="noDb"
:label="$t('admin_dash.tabs.nodb')"
icon="exclamation-triangle"
data-tab-name="nodb-notice"
>
<div :label="$t('admin_dash.tabs.nodb')">
<div class="setting-item">
<h2>{{ $t('admin_dash.nodb.heading') }}</h2>
<i18n-t
scope="global"
keypath="admin_dash.nodb.text"
>
<template #documentation>
<a
href="https://docs-develop.pleroma.social/backend/configuration/howto_database_config/"
target="_blank"
>
{{ $t("admin_dash.nodb.documentation") }}
</a>
</template>
<template #property>
<code>config :pleroma, configurable_from_database</code>
</template>
<template #value>
<code>true</code>
</template>
</i18n-t>
<p>{{ $t('admin_dash.nodb.text2') }}</p>
</div>
</div>
</div>
<div
v-if="adminDbLoaded"
:label="$t('admin_dash.tabs.instance')"
icon="wrench"
data-tab-name="general"
>
<InstanceTab />
</div>
<div
v-if="adminDbLoaded"
:label="$t('admin_dash.tabs.limits')"
icon="hand"
data-tab-name="limits"
>
<LimitsTab />
</div>
<div
:label="$t('admin_dash.tabs.frontends')"
icon="laptop-code"
data-tab-name="frontends"
>
<FrontendsTab />
</div>
<div
:label="$t('admin_dash.tabs.emoji')"
icon="face-smile-beam"
data-tab-name="emoji"
>
<EmojiTab />
</div>
- </tab-switcher>
+ </vertical-tab-switcher>
</template>
<script src="./settings_modal_admin_content.js"></script>
<style src="./settings_modal_admin_content.scss" lang="scss"></style>
diff --git a/src/components/settings_modal/settings_modal_user_content.js b/src/components/settings_modal/settings_modal_user_content.js
index c46b477d83..51718ab260 100644
--- a/src/components/settings_modal/settings_modal_user_content.js
+++ b/src/components/settings_modal/settings_modal_user_content.js
@@ -1,103 +1,122 @@
-import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
+import VerticalTabSwitcher from './helpers/vertical_tab_switcher.jsx'
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 PostsTab from './tabs/posts_tab.vue'
+import ComposingTab from './tabs/composing_tab.vue'
+import ClutterTab from './tabs/clutter_tab.vue'
+import LayoutTab from './tabs/layout_tab.vue'
import AppearanceTab from './tabs/appearance_tab.vue'
-import VersionTab from './tabs/version_tab.vue'
-import ThemeTab from './tabs/theme_tab/theme_tab.vue'
+import DeveloperTab from './tabs/developer_tab.vue'
+import OldThemeTab from './tabs/old_theme_tab/old_theme_tab.vue'
import StyleTab from './tabs/style_tab/style_tab.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faWrench,
faUser,
+ faMessage,
faFilter,
faPaintBrush,
faPalette,
faBell,
faDownload,
faEyeSlash,
- faInfo,
- faWindowRestore
+ faWindowRestore,
+ faCode,
+ faBroom,
+ faLock,
+ faColumns
} from '@fortawesome/free-solid-svg-icons'
import { useInterfaceStore } from 'src/stores/interface'
library.add(
faWrench,
faUser,
- faFilter,
- faPaintBrush,
- faPalette,
+ faMessage,
+ faWindowRestore,
+ faColumns,
faBell,
- faDownload,
+ faFilter,
faEyeSlash,
- faInfo,
- faWindowRestore
+ faBroom,
+ faLock,
+ faDownload,
+ faPalette,
+ faPaintBrush,
+ faCode
)
const SettingsModalContent = {
components: {
- TabSwitcher,
+ VerticalTabSwitcher,
DataImportExportTab,
MutesAndBlocksTab,
NotificationsTab,
FilteringTab,
SecurityTab,
ProfileTab,
GeneralTab,
+ PostsTab,
+ ComposingTab,
+ ClutterTab,
+ LayoutTab,
AppearanceTab,
StyleTab,
- VersionTab,
- ThemeTab
+ DeveloperTab,
+ OldThemeTab
},
computed: {
isLoggedIn () {
return !!this.$store.state.users.currentUser
},
open () {
return useInterfaceStore().settingsModalState !== 'hidden'
},
bodyLock () {
return useInterfaceStore().settingsModalState === 'visible'
},
expertLevel () {
return this.$store.state.config.expertLevel
- },
- isMobileLayout () {
- return useInterfaceStore().layoutType === 'mobile'
+ }
+ },
+ data () {
+ return {
+ navCollapsed: false,
+ navHideHeader: false
}
},
methods: {
onOpen () {
const targetTab = useInterfaceStore().settingsModalTargetTab
// We're being told to open in specific tab
if (targetTab) {
const tabIndex = this.$refs.tabSwitcher.$slots.default().findIndex(elm => {
return elm.props && elm.props['data-tab-name'] === targetTab
})
if (tabIndex >= 0) {
this.$refs.tabSwitcher.setTab(tabIndex)
}
}
// Clear the state of target tab, so that next time settings is opened
// it doesn't force it.
useInterfaceStore().clearSettingsModalTargetTab()
}
},
mounted () {
this.onOpen()
},
watch: {
open: function (value) {
if (value) this.onOpen()
}
}
}
export default SettingsModalContent
diff --git a/src/components/settings_modal/settings_modal_user_content.scss b/src/components/settings_modal/settings_modal_user_content.scss
index 25e9bda3a7..306d8e898f 100644
--- a/src/components/settings_modal/settings_modal_user_content.scss
+++ b/src/components/settings_modal/settings_modal_user_content.scss
@@ -1,62 +1,50 @@
.settings_tab-switcher {
height: 100%;
- h1 {
- margin-bottom: 0.5em;
- margin-top: 0.5em;
- }
-
- h4 {
- margin-bottom: 0;
- margin-top: 0.25em;
- }
-
- h5 {
- margin-bottom: 0;
- margin-top: 0.25em;
+ [full-height="true"] {
+ height: 100%
}
.setting-item {
- border-bottom: 2px solid var(--border);
margin: 1em 1em 1.4em;
padding-bottom: 1.4em;
> div,
> label {
margin-bottom: 0.5em;
&:last-child {
margin-bottom: 0;
}
}
.select-multiple {
margin-top: 1em;
display: flex;
flex-direction: column;
.option-list {
margin: 0;
margin-top: 0.5em;
padding-left: 0.5em;
}
}
&:last-child {
border-bottom: none;
padding-bottom: 0;
margin-bottom: 1em;
}
textarea {
width: 100%;
max-width: 100%;
height: 100px;
}
.unavailable,
.unavailable svg {
color: var(--cRed);
}
}
}
diff --git a/src/components/settings_modal/settings_modal_user_content.vue b/src/components/settings_modal/settings_modal_user_content.vue
index f9a1e99bc5..b2b291e9b9 100644
--- a/src/components/settings_modal/settings_modal_user_content.vue
+++ b/src/components/settings_modal/settings_modal_user_content.vue
@@ -1,102 +1,143 @@
<template>
- <tab-switcher
+ <vertical-tab-switcher
ref="tabSwitcher"
class="settings_tab-switcher"
- :side-tab-bar="true"
:scrollable-tabs="true"
+ :child-collapsed="childCollapsed"
:body-scroll-lock="bodyLock"
+ :hide-header="navHideHeader"
>
<div
:label="$t('settings.general')"
icon="wrench"
data-tab-name="general"
>
<GeneralTab />
</div>
<div
- :label="$t('settings.appearance')"
- icon="window-restore"
- data-tab-name="appearance"
+ v-if="isLoggedIn"
+ :label="$t('settings.profile_tab')"
+ icon="user"
+ data-tab-name="profile"
+ :full-width="true"
+ >
+ <ProfileTab />
+ </div>
+ <div
+ :label="$t('settings.composing')"
+ icon="pen-alt"
+ data-tab-name="composing"
:delay-render="true"
>
- <AppearanceTab />
+ <ComposingTab />
</div>
<div
- v-if="expertLevel > 0"
- :label="$t('settings.style.themes3.editor.title')"
- icon="palette"
- data-tab-name="style"
+ :label="$t('settings.posts')"
+ icon="message"
+ data-tab-name="posts"
:delay-render="true"
>
- <StyleTab />
+ <PostsTab />
</div>
<div
- v-if="expertLevel > 0"
- :label="$t('settings.theme_old')"
- icon="paint-brush"
- data-tab-name="theme"
+ :full-width="true"
+ :label="$t('settings.appearance')"
+ icon="window-restore"
+ data-tab-name="appearance"
:delay-render="true"
>
- <ThemeTab />
+ <AppearanceTab />
</div>
<div
- v-if="isLoggedIn"
- :label="$t('settings.profile_tab')"
- icon="user"
- data-tab-name="profile"
+ :full-width="true"
+ :label="$t('settings.layout')"
+ icon="table-columns"
+ data-tab-name="layout"
+ :delay-render="true"
>
- <ProfileTab />
+ <LayoutTab />
</div>
<div
v-if="isLoggedIn"
+ :full-width="true"
:label="$t('settings.notifications')"
icon="bell"
data-tab-name="notifications"
>
<NotificationsTab />
</div>
- <div
- v-if="isLoggedIn"
- :label="$t('settings.security_tab')"
- icon="lock"
- data-tab-name="security"
- >
- <SecurityTab />
- </div>
<div
:label="$t('settings.filtering')"
+ :full-width="true"
icon="filter"
data-tab-name="filtering"
>
<FilteringTab />
</div>
<div
v-if="isLoggedIn"
:label="$t('settings.mutes_and_blocks')"
- :fullHeight="true"
icon="eye-slash"
data-tab-name="mutesAndBlocks"
+ :full-width="true"
+ :full-height="true"
>
<MutesAndBlocksTab />
</div>
+ <div
+ :label="$t('settings.clutter')"
+ icon="broom"
+ data-tab-name="clutter"
+ >
+ <ClutterTab />
+ </div>
+ <div
+ v-if="isLoggedIn"
+ :label="$t('settings.security_tab')"
+ icon="lock"
+ data-tab-name="security"
+ >
+ <SecurityTab />
+ </div>
<div
v-if="isLoggedIn"
:label="$t('settings.data_import_export_tab')"
icon="download"
data-tab-name="dataImportExport"
>
<DataImportExportTab />
</div>
<div
- :label="$t('settings.version.title')"
- icon="info"
- data-tab-name="version"
+ v-if="expertLevel > 0"
+ :label="$t('settings.style.themes3.editor.title')"
+ icon="palette"
+ data-tab-name="style"
+ :delay-render="true"
+ :full-width="true"
+ >
+ <StyleTab />
+ </div>
+ <div
+ v-if="expertLevel > 0"
+ :label="$t('settings.theme_old')"
+ icon="paint-brush"
+ data-tab-name="theme"
+ :delay-render="true"
+ :full-width="true"
+ >
+ <OldThemeTab />
+ </div>
+ <div
+ v-if="expertLevel > 0"
+ :label="$t('settings.developer')"
+ icon="code"
+ data-tab-name="developer"
>
- <VersionTab />
+ <DeveloperTab />
</div>
- </tab-switcher>
+ </vertical-tab-switcher>
</template>
<script src="./settings_modal_user_content.js"></script>
<style src="./settings_modal_user_content.scss" lang="scss"></style>
diff --git a/src/components/settings_modal/tabs/appearance_tab.js b/src/components/settings_modal/tabs/appearance_tab.js
index 7fbb0a5cd5..e4c3741a9e 100644
--- a/src/components/settings_modal/tabs/appearance_tab.js
+++ b/src/components/settings_modal/tabs/appearance_tab.js
@@ -1,482 +1,437 @@
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import FloatSetting from '../helpers/float_setting.vue'
import UnitSetting from '../helpers/unit_setting.vue'
-import { defaultHorizontalUnits } from '../helpers/unit_setting.js'
import PaletteEditor from 'src/components/palette_editor/palette_editor.vue'
-import Preview from './theme_tab/theme_preview.vue'
-import FontControl from 'src/components/font_control/font_control.vue'
+import Preview from './old_theme_tab/theme_preview.vue'
import { newImporter } from 'src/services/export_import/export_import.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
import { init } from 'src/services/theme_data/theme_data_3.service.js'
import {
getCssRules
} from 'src/services/theme_data/css_utils.js'
import { deserialize } from 'src/services/theme_data/iss_deserializer.js'
import { createStyleSheet, adoptStyleSheets } from 'src/services/style_setter/style_setter.js'
import fileSizeFormatService from 'src/components/../services/file_size_format/file_size_format.js'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
import { mapActions } from 'pinia'
import { useInterfaceStore, normalizeThemeData } from 'src/stores/interface'
-import { library } from '@fortawesome/fontawesome-svg-core'
-import {
- faGlobe
-} from '@fortawesome/free-solid-svg-icons'
-
-library.add(
- faGlobe
-)
-
const AppearanceTab = {
data () {
return {
availableThemesV3: [],
availableThemesV2: [],
bundledPalettes: [],
compilationCache: {},
fileImporter: newImporter({
accept: '.json, .iss',
validator: this.importValidator,
onImport: this.onImport,
parser: this.importParser,
onImportFailure: this.onImportFailure
}),
palettesKeys: [
'bg',
'fg',
'link',
'text',
'cRed',
'cGreen',
'cBlue',
'cOrange'
],
userPalette: {},
intersectionObserver: null,
- thirdColumnModeOptions: ['none', 'notifications', 'postform'].map(mode => ({
- key: mode,
- value: mode,
- label: this.$t(`settings.third_column_mode_${mode}`)
- })),
forcedRoundnessOptions: ['disabled', 'sharp', 'nonsharp', 'round'].map((mode, i) => ({
key: mode,
value: i - 1,
label: this.$t(`settings.style.themes3.hacks.forced_roundness_mode_${mode}`)
})),
underlayOverrideModes: ['none', 'opaque', 'transparent'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.style.themes3.hacks.underlay_override_mode_${mode}`)
})),
backgroundUploading: false,
background: null,
backgroundPreview: null,
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
FloatSetting,
UnitSetting,
ProfileSettingIndicator,
- FontControl,
Preview,
PaletteEditor
},
mounted () {
useInterfaceStore().getThemeData()
const updateIndex = (resource) => {
const capitalizedResource = resource[0].toUpperCase() + resource.slice(1)
const currentIndex = this.$store.state.instance[`${resource}sIndex`]
let promise
if (currentIndex) {
promise = Promise.resolve(currentIndex)
} else {
promise = useInterfaceStore()[`fetch${capitalizedResource}sIndex`]()
}
return promise.then(index => {
return Object
.entries(index)
.map(([k, func]) => [k, func()])
})
}
updateIndex('style').then(styles => {
styles.forEach(([key, stylePromise]) => stylePromise.then(data => {
const meta = data.find(x => x.component === '@meta')
this.availableThemesV3.push({ key, data, name: meta.directives.name, version: 'v3' })
}))
})
updateIndex('theme').then(themes => {
themes.forEach(([key, themePromise]) => themePromise.then(data => {
if (!data) {
console.warn(`Theme with key ${key} is empty or malformed`)
} else if (Array.isArray(data)) {
console.warn(`Theme with key ${key} is a v1 theme and should be moved to static/palettes/index.json`)
} else if (!data.source && !data.theme) {
console.warn(`Theme with key ${key} is malformed`)
} else {
this.availableThemesV2.push({ key, data, name: data.name, version: 'v2' })
}
}))
})
this.userPalette = useInterfaceStore().paletteDataUsed || {}
updateIndex('palette').then(bundledPalettes => {
bundledPalettes.forEach(([key, palettePromise]) => palettePromise.then(v => {
let palette
if (Array.isArray(v)) {
const [
name,
bg,
fg,
text,
link,
cRed = '#FF0000',
cGreen = '#00FF00',
cBlue = '#0000FF',
cOrange = '#E3FF00'
] = v
palette = { key, name, bg, fg, text, link, cRed, cBlue, cGreen, cOrange }
} else {
palette = { key, ...v }
}
if (!palette.key.startsWith('style.')) {
this.bundledPalettes.push(palette)
}
}))
})
this.previewTheme('stock', 'v3')
if (window.IntersectionObserver) {
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(({ target, isIntersecting }) => {
if (!isIntersecting) return
const theme = this.availableStyles.find(x => x.key === target.dataset.themeKey)
this.$nextTick(() => {
if (theme) this.previewTheme(theme.key, theme.version, theme.data)
})
observer.unobserve(target)
})
}, {
root: this.$refs.themeList
})
} else {
this.availableStyles.forEach(theme => this.previewTheme(theme.key, theme.version, theme.data))
}
},
updated () {
this.$nextTick(() => {
this.$refs.themeList.querySelectorAll('.theme-preview').forEach(node => {
this.intersectionObserver.observe(node)
})
})
},
watch: {
paletteDataUsed () {
this.userPalette = this.paletteDataUsed || {}
}
},
computed: {
isDefaultBackground () {
return !(this.$store.state.users.currentUser.background_image)
},
switchInProgress () {
return useInterfaceStore().themeChangeInProgress
},
paletteDataUsed () {
return useInterfaceStore().paletteDataUsed
},
availableStyles () {
return [
...this.availableThemesV3,
...this.availableThemesV2
]
},
availablePalettes () {
return [
...this.bundledPalettes,
...this.stylePalettes
]
},
stylePalettes () {
const ruleset = useInterfaceStore().styleDataUsed || []
if (!ruleset && ruleset.length === 0) return
const meta = ruleset.find(x => x.component === '@meta')
const result = ruleset.filter(x => x.component.startsWith('@palette'))
.map(x => {
const { variant, directives } = x
const {
bg,
fg,
text,
link,
accent,
cRed,
cBlue,
cGreen,
cOrange,
wallpaper
} = directives
const result = {
name: `${meta.directives.name || this.$t('settings.style.themes3.palette.imported')}: ${variant}`,
key: `style.${variant.toLowerCase().replace(/ /g, '_')}`,
bg,
fg,
text,
link,
accent,
cRed,
cBlue,
cGreen,
cOrange,
wallpaper
}
return Object.fromEntries(Object.entries(result).filter(([, v]) => v))
})
return result
},
noIntersectionObserver () {
return !window.IntersectionObserver
},
- horizontalUnits () {
- return defaultHorizontalUnits
- },
- fontsOverride () {
- return this.$store.getters.mergedConfig.fontsOverride
- },
- columns () {
- const mode = this.$store.getters.mergedConfig.thirdColumnMode
-
- const notif = mode === 'none' ? [] : ['notifs']
-
- if (this.$store.getters.mergedConfig.sidebarRight || mode === 'postform') {
- return [...notif, 'content', 'sidebar']
- } else {
- return ['sidebar', 'content', ...notif]
- }
+ instanceWallpaper () {
+ console.log(this.$store.state.instance.background)
+ this.$store.state.instance.background
},
instanceWallpaperUsed () {
return this.$store.state.instance.background &&
!this.$store.state.users.currentUser.background_image
},
- language: {
- get: function () { return this.$store.getters.mergedConfig.interfaceLanguage },
- set: function (val) {
- this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
- }
- },
customThemeVersion () {
const { themeVersion } = useInterfaceStore()
return themeVersion
},
isCustomThemeUsed () {
const { customTheme, customThemeSource } = this.mergedConfig
return customTheme != null || customThemeSource != null
},
isCustomStyleUsed () {
const { styleCustomData } = this.mergedConfig
return styleCustomData != null
},
...SharedComputedObject()
},
methods: {
- updateFont (key, value) {
- this.$store.dispatch('setOption', {
- name: 'theme3hacks',
- value: {
- ...this.mergedConfig.theme3hacks,
- fonts: {
- ...this.mergedConfig.theme3hacks.fonts,
- [key]: value
- }
- }
- })
- },
importFile () {
this.fileImporter.importData()
},
importParser (file, filename) {
if (filename.endsWith('.json')) {
return JSON.parse(file)
} else if (filename.endsWith('.iss')) {
return deserialize(file)
}
},
onImport (parsed, filename) {
if (filename.endsWith('.json')) {
useInterfaceStore().setThemeCustom(parsed.source || parsed.theme)
} else if (filename.endsWith('.iss')) {
useInterfaceStore().setStyleCustom(parsed)
}
},
onImportFailure (result) {
console.error('Failure importing theme:', result)
useInterfaceStore().pushGlobalNotice({ messageKey: 'settings.invalid_theme_imported', level: 'error' })
},
importValidator (parsed, filename) {
if (filename.endsWith('.json')) {
const version = parsed._pleroma_theme_version
return version >= 1 || version <= 2
} else if (filename.endsWith('.iss')) {
if (!Array.isArray(parsed)) return false
if (parsed.length < 1) return false
if (parsed.find(x => x.component === '@meta') == null) return false
return true
}
},
isThemeActive (key) {
return key === (this.mergedConfig.theme || this.$store.state.instance.theme)
},
isStyleActive (key) {
return key === (this.mergedConfig.style || this.$store.state.instance.style)
},
isPaletteActive (key) {
return key === (this.mergedConfig.palette || this.$store.state.instance.palette)
},
...mapActions(useInterfaceStore, [
'setStyle',
'setTheme'
]),
setPalette (name, data) {
useInterfaceStore().setPalette(name)
this.userPalette = data
},
setPaletteCustom (data) {
useInterfaceStore().setPaletteCustom(data)
this.userPalette = data
},
resetTheming () {
useInterfaceStore().setStyle('stock')
},
previewTheme (key, version, input) {
let theme3
if (this.compilationCache[key]) {
theme3 = this.compilationCache[key]
} else if (input) {
if (version === 'v2') {
const style = normalizeThemeData(input)
const theme2 = convertTheme2To3(style)
theme3 = init({
inputRuleset: theme2,
ultimateBackgroundColor: '#000000',
liteMode: true,
debug: true,
onlyNormalState: true
})
} else if (version === 'v3') {
const palette = input.find(x => x.component === '@palette')
let paletteRule
if (palette) {
const { directives } = palette
directives.link = directives.link || directives.accent
directives.accent = directives.accent || directives.link
paletteRule = {
component: 'Root',
directives: Object.fromEntries(
Object
.entries(directives)
.filter(([k]) => k && k !== 'name')
.map(([k, v]) => ['--' + k, 'color | ' + v])
)
}
} else {
paletteRule = null
}
theme3 = init({
inputRuleset: [...input, paletteRule].filter(x => x),
ultimateBackgroundColor: '#000000',
liteMode: true,
onlyNormalState: true
})
}
} else {
theme3 = init({
inputRuleset: [],
ultimateBackgroundColor: '#000000',
liteMode: true,
onlyNormalState: true
})
}
if (!this.compilationCache[key]) {
this.compilationCache[key] = theme3
}
const sheet = createStyleSheet('appearance-tab-previews', 90)
sheet.addRule([
'#theme-preview-', key, ' {\n',
getCssRules(theme3.eager).join('\n'),
'\n}'
].join(''))
sheet.ready = true
adoptStyleSheets()
},
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'])
useInterfaceStore().pushGlobalNotice({
messageKey: 'upload.error.message',
messageArgs: [
this.$t('upload.error.file_too_big', {
filesize: filesize.num,
filesizeunit: filesize.unit,
allowedsize: allowedsize.num,
allowedsizeunit: allowedsize.unit
})
],
level: 'error'
})
return
}
const reader = new FileReader()
reader.onload = ({ target }) => {
const img = target.result
this[slot + 'Preview'] = img
this[slot] = file
}
reader.readAsDataURL(file)
},
resetBackground () {
const confirmed = window.confirm(this.$t('settings.reset_background_confirm'))
if (confirmed) {
this.submitBackground('')
}
},
+ resetUploadedBackground () {
+ this.backgroundPreview = null
+ },
submitBackground (background) {
if (!this.backgroundPreview && background !== '') { return }
this.backgroundUploading = true
this.$store.state.api.backendInteractor.updateProfileImages({ background })
.then((data) => {
this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data)
this.backgroundPreview = null
})
.catch(this.displayUploadError)
.finally(() => { this.backgroundUploading = false })
},
}
}
export default AppearanceTab
diff --git a/src/components/settings_modal/tabs/appearance_tab.scss b/src/components/settings_modal/tabs/appearance_tab.scss
index d786cfa388..a52bd97d04 100644
--- a/src/components/settings_modal/tabs/appearance_tab.scss
+++ b/src/components/settings_modal/tabs/appearance_tab.scss
@@ -1,182 +1,249 @@
.appearance-tab {
+ h3 {
+ border: none
+ }
+
.palette,
.theme-notice {
padding: 0.5em;
- margin: 1em;
}
- .setting-item {
- padding-bottom: 0;
+ .theme-name {
+ font-weight: 900;
+ padding-bottom: 0.5em;
+ }
- &.heading {
- display: grid;
- align-items: baseline;
- grid-template-columns: 1fr auto auto auto;
- grid-gap: 0.5em;
+ input[type="file"] {
+ padding: 0.25em;
+ height: auto;
+ }
- h2 {
- flex: 1 0 auto;
- }
+ .banner-background {
+ display: flex;
+ gap: 1em;
+ flex-wrap: wrap;
+
+ h4 {
+ margin: 0;
}
}
- h4 {
- margin: 0.5em 0;
- }
+ .banner-background-input {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5em;
- input[type="file"] {
- padding: 0.25em;
- height: auto;
+ .custom-bg-control {
+ display: grid;
+ gap: 0.5em;
+ grid-template-columns: 1fr 1fr;
+ }
}
.banner-background-preview {
+ display: flex;
max-width: 100%;
width: 300px;
position: relative;
img {
width: 100%;
}
- }
- .reset-button {
- position: absolute;
- top: 0.2em;
- right: 0.2em;
- border-radius: var(--roundness);
- background-color: rgb(0 0 0 / 60%);
- opacity: 0.7;
- width: 1.5em;
- height: 1.5em;
- text-align: center;
- line-height: 1.5em;
- font-size: 1.5em;
- cursor: pointer;
-
- &:hover {
- opacity: 1;
- }
+ .fun-monitor {
+ position: relative;
+ pointer-events: none;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
- svg {
- color: white;
- }
- }
+ * {
+ line-height: 1;
+ }
+ &-display-bezel,
+ &-display-screen {
+ aspect-ratio: 16 / 9;
+ width: 16em;
+ }
+
+ img {
+ object-fit: cover;
+ }
+
+ .wallpaper {
+ position: absolute;
+ inset: 0;
+ background-color: var(--wallpaper);
+ }
+ &-display-uploading {
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ display: flex;
+ place-items: center;
+ place-content: center;
+ background-color: rgb(0 0 0 / 60%);
+ font-size: 4em;
+ }
+
+ &-display-screen {
+ padding: 0;
+ overflow: hidden;
+ position: relative;
+
+ &-overlay {
+ background: transparent;
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ }
+
+ &-image {
+ aspect-ratio: 16 / 9
+ }
+ }
+
+ &-display-bezel {
+ padding: 1em;
+ margin: 0;
+ order: 1;
+ z-index: 3;
+ }
+
+ &-neck {
+ width: 5em;
+ height: 3em;
+ margin-top: -1em;
+ margin-bottom: -0.5em;
+ order: 2
+ }
+
+ &-stand {
+ width: 8em;
+ height: 1em;
+ order: 3;
+ z-index: 1
+ }
+ }
+ }
.palettes-container {
height: 15em;
overflow: hidden auto;
scrollbar-gutter: stable;
border-radius: var(--roundness);
border: 1px solid var(--border);
- margin: -0.5em;
+ margin-bottom: 0.5em;
+ margin-top: 0;
}
.palettes {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 0.5em;
padding: 0.5em;
width: 100%;
- h4 {
- margin: 0;
+ h5 {
grid-column: 1 / span 2;
+ margin-bottom: 0;
}
}
.palette-entry {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
justify-content: space-between;
padding: 0 0.5em;
height: max-content;
.palette-label {
height: auto;
label {
text-align: center;
}
}
.palette-square {
flex: 0 0 auto;
display: inline-block;
min-width: 1em;
min-height: 1em;
}
}
.column-settings {
display: flex;
justify-content: space-evenly;
flex-wrap: wrap;
}
.column-settings .size-label {
display: block;
margin-bottom: 0.5em;
margin-top: 0.5em;
}
.modal-view.-mobile & {
.palettes {
grid-template-columns: 1fr;
}
.palette-entry {
grid-column: 1;
justify-content: center;
}
.palette-label {
line-height: 1.5em;
margin-top: 0.5em;
}
}
.palette-preview {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-template-rows: 1em 1em;
margin: 0.5em 0;
}
.theme-list {
list-style: none;
display: flex;
flex-wrap: wrap;
margin: -0.5em 0;
height: 25em;
overflow: hidden auto;
scrollbar-gutter: stable;
border-radius: var(--roundness);
border: 1px solid var(--border);
padding: 0;
margin-bottom: 1em;
.theme-preview {
font-size: 1rem; // fix for firefox
- width: 19rem;
+ width: 14rem;
display: flex;
flex-direction: column;
align-items: center;
margin: 0.5em;
&.placeholder {
opacity: 0.2;
}
.theme-preview-container {
pointer-events: none;
zoom: 0.5;
border: none;
border-radius: var(--roundness);
text-align: left;
}
}
}
}
diff --git a/src/components/settings_modal/tabs/appearance_tab.vue b/src/components/settings_modal/tabs/appearance_tab.vue
index cd54e2c82f..d9303ce7b4 100644
--- a/src/components/settings_modal/tabs/appearance_tab.vue
+++ b/src/components/settings_modal/tabs/appearance_tab.vue
@@ -1,429 +1,253 @@
<template>
<div
class="appearance-tab"
- :label="$t('settings.general')"
+ :label="$t('settings.interface')"
+ icon="table-columns"
>
- <div class="setting-item">
- <h2>{{ $t('settings.theme') }}</h2>
+ <div
+ class="setting-item"
+ :label="$t('settings.theme')"
+ icon="paintbrush"
+ >
+ <h3>{{ $t('settings.style.style_section') }}</h3>
<ul
ref="themeList"
class="theme-list"
>
<button
class="button-default theme-preview"
data-theme-key="stock"
:class="{ toggled: isStyleActive('stock'), disabled: switchInProgress }"
:disabled="switchInProgress"
@click="resetTheming"
>
<preview id="theme-preview-stock" />
- <h4 class="theme-name">
+ <span class="theme-name">
{{ $t('settings.style.stock_theme_used') }}
<span class="alert neutral version">v3</span>
- </h4>
+ </span>
</button>
<button
v-if="isCustomThemeUsed"
disabled
class="button-default theme-preview toggled"
>
<preview />
- <h4 class="theme-name">
+ <span class="theme-name">
{{ $t('settings.style.custom_theme_used') }}
<span class="alert neutral version">v2</span>
- </h4>
+ </span>
</button>
<button
v-if="isCustomStyleUsed"
disabled
class="button-default theme-preview toggled"
>
<preview />
- <h4 class="theme-name">
+ <span class="theme-name">
{{ $t('settings.style.custom_style_used') }}
<span class="alert neutral version">v3</span>
- </h4>
+ </span>
</button>
<button
v-for="style in availableStyles"
:key="style.key"
:data-theme-key="style.key"
class="button-default theme-preview"
:class="{ toggled: isThemeActive(style.key), disabled: switchInProgress }"
:disabled="switchInProgress"
@click="style.version === 'v2' ? setTheme(style.key) : setStyle(style.key)"
>
<preview :id="'theme-preview-' + style.key" />
- <h4 class="theme-name">
+ <span class="theme-name">
{{ style.name }}
<span class="alert neutral version">{{ style.version }}</span>
- </h4>
+ </span>
</button>
</ul>
<div class="import-file-container">
<button
class="btn button-default"
:class="{ disabled: switchInProgress }"
:disabled="switchInProgress"
@click="importFile"
>
<FAIcon icon="folder-open" />
{{ $t('settings.style.themes3.editor.load_style') }}
</button>
- </div>
- <div class="setting-item">
- <h2>{{ $t('settings.style.themes3.palette.label') }}</h2>
+ <h4>{{ $t('settings.style.themes3.palette.label') }}</h4>
<div
v-if="customThemeVersion === 'v3'"
class="palettes-container"
>
- <h4 v-if="stylePalettes?.length > 0">
+ <h5 v-if="stylePalettes?.length > 0">
{{ $t('settings.style.themes3.palette.style') }}
- </h4>
+ </h5>
<div class="palettes">
<button
v-for="p in stylePalettes || []"
:key="p.name"
class="btn button-default palette-entry"
:class="{ toggled: isPaletteActive(p.key), disabled: switchInProgress }"
:disabled="switchInProgress"
@click="() => setPalette(p.key, p)"
>
<div class="palette-label">
<label>
{{ p.name ?? $t('settings.style.themes3.palette.user') }}
</label>
</div>
<div class="palette-preview">
<span
v-for="c in palettesKeys"
:key="c"
class="palette-square"
:style="{ backgroundColor: p[c], border: '1px solid ' + (p[c] ?? 'var(--text)') }"
/>
</div>
</button>
- <h4>{{ $t('settings.style.themes3.palette.bundled') }}</h4>
+ <h5>{{ $t('settings.style.themes3.palette.bundled') }}</h5>
<button
v-for="p in bundledPalettes"
:key="p.name"
class="btn button-default palette-entry"
:class="{ toggled: isPaletteActive(p.key), disabled: switchInProgress }"
:disabled="switchInProgress"
@click="() => setPalette(p.key, p)"
>
<div class="palette-label">
<label>
{{ p.name }}
</label>
</div>
<div class="palette-preview">
<span
v-for="c in palettesKeys"
:key="c"
class="palette-square"
:style="{ backgroundColor: p[c], border: '1px solid ' + (p[c] ?? 'var(--text)') }"
/>
</div>
</button>
</div>
</div>
<div>
<template v-if="customThemeVersion === 'v3'">
- <h4 v-if="expertLevel > 0">
+ <h5 v-if="expertLevel > 0">
{{ $t('settings.style.themes3.palette.user') }}
- </h4>
+ </h5>
<PaletteEditor
v-if="expertLevel > 0"
v-model="userPalette"
class="userPalette"
:compact="true"
:apply="true"
:disabled="switchInProgress"
@apply-palette="data => setPaletteCustom(data)"
/>
</template>
<template v-else-if="customThemeVersion === 'v2'">
<div class="alert neutral theme-notice unsupported-theme-v2">
{{ $t('settings.style.themes3.palette.v2_unsupported') }}
</div>
</template>
</div>
</div>
- </div>
- <div class="setting-item">
- <h2>{{ $t('settings.background') }}</h2>
- <div class="banner-background-preview">
- <img :src="user.background_image">
- <button
- v-if="!isDefaultBackground"
- class="button-unstyled reset-button"
- :title="$t('settings.reset_profile_background')"
- @click="resetBackground"
- >
- <FAIcon
- icon="times"
- type="button"
- />
- </button>
- </div>
- <p>{{ $t('settings.set_new_background') }}</p>
- <img
- v-if="backgroundPreview"
- class="banner-background-preview"
- :src="backgroundPreview"
- >
- <div>
- <input
- type="file"
- class="input"
- @change="uploadFile('background', $event)"
- >
- </div>
- <FAIcon
- v-if="backgroundUploading"
- class="uploading"
- spin
- icon="circle-notch"
- />
- <button
- v-else-if="backgroundPreview"
- class="btn button-default"
- @click="submitBackground(background)"
- >
- {{ $t('settings.save') }}
- </button>
- </div>
- <div class="setting-item">
- <h2>{{ $t('settings.scale_and_layout') }}</h2>
- <div class="alert neutral theme-notice">
- {{ $t("settings.style.appearance_tab_note") }}
- </div>
- <ul class="setting-list">
- <li>
- <UnitSetting
- path="textSize"
- :step="0.1"
- :units="['px', 'rem']"
- :reset-default="{ 'px': 14, 'rem': 1 }"
- timed-apply-mode
- >
- {{ $t('settings.text_size') }}
- </UnitSetting>
- <div>
- <small>
- <i18n-t
- scope="global"
- keypath="settings.text_size_tip"
- tag="span"
- >
- <code>px</code>
- <code>rem</code>
- </i18n-t>
- <br>
- <i18n-t
- scope="global"
- keypath="settings.text_size_tip2"
- tag="span"
- >
- <code>14px</code>
- </i18n-t>
- </small>
+ <h3>{{ $t('settings.background') }}</h3>
+ <div class="banner-background">
+ <div class="banner-background-preview">
+ <div class="fun-monitor">
+ <div class="fun-monitor-stand button-default" />
+ <div class="fun-monitor-neck button-default" />
+ <div class="fun-monitor-display-bezel button-default">
+ <div class="fun-monitor-display-screen input">
+ <img
+ v-if="backgroundPreview || user.background_image || instanceWallpaper"
+ class="fun-monitor-display-screen-image"
+ :src="backgroundPreview || user.background_image || instanceWallpaper"
+ />
+ <div v-else class="wallpaper" />
+ <div class="fun-monitor-display-screen-overlay input" />
+ <div
+ v-if="backgroundUploading"
+ class="fun-monitor-display-uploading"
+ >
+ <FAIcon
+ class="fun-monitor-display-screen-uploading"
+ spin
+ icon="circle-notch"
+ />
+ </div>
+ </div>
+ </div>
</div>
- </li>
- <li>
- <UnitSetting
- path="emojiSize"
- :step="0.1"
- :units="['px', 'rem']"
- :reset-default="{ 'px': 32, 'rem': 2.2 }"
- >
- {{ $t('settings.emoji_size') }}
- </UnitSetting>
- <ul
- class="setting-list suboptions"
- >
- <li>
- <FloatSetting
- v-if="user"
- path="emojiReactionsScale"
- expert="1"
- >
- {{ $t('settings.emoji_reactions_scale') }}
- </FloatSetting>
- </li>
- </ul>
- </li>
- <li>
- <UnitSetting
- path="navbarSize"
- :step="0.1"
- :units="['px', 'rem']"
- :reset-default="{ 'px': 55, 'rem': 3.5 }"
- >
- {{ $t('settings.navbar_size') }}
- </UnitSetting>
- </li>
- <h3>{{ $t('settings.style.interface_font_user_override') }}</h3>
- <li>
- <FontControl
- :model-value="mergedConfig.theme3hacks.fonts.interface"
- name="ui"
- :label="$t('settings.style.fonts.components.interface')"
- :fallback="{ family: 'sans-serif' }"
- no-inherit="1"
- @update:model-value="v => updateFont('interface', v)"
- />
- </li>
- <li>
- <FontControl
- v-if="expertLevel > 0"
- :model-value="mergedConfig.theme3hacks.fonts.input"
- name="input"
- :fallback="{ family: 'inherit' }"
- :label="$t('settings.style.fonts.components.input')"
- @update:model-value="v => updateFont('input', v)"
- />
- </li>
- <li>
- <FontControl
- v-if="expertLevel > 0"
- :model-value="mergedConfig.theme3hacks.fonts.post"
- name="post"
- :fallback="{ family: 'inherit' }"
- :label="$t('settings.style.fonts.components.post')"
- @update:model-value="v => updateFont('post', v)"
- />
- </li>
- <li>
- <FontControl
- v-if="expertLevel > 0"
- :model-value="mergedConfig.theme3hacks.fonts.monospace"
- name="postCode"
- :fallback="{ family: 'monospace' }"
- :label="$t('settings.style.fonts.components.monospace')"
- @update:model-value="v => updateFont('monospace', v)"
- />
- </li>
- <h3>{{ $t('settings.columns') }}</h3>
- <li>
- <UnitSetting
- path="panelHeaderSize"
- :step="0.1"
- :units="['px', 'rem']"
- :reset-default="{ 'px': 52, 'rem': 3.2 }"
- timed-apply-mode
- >
- {{ $t('settings.panel_header_size') }}
- </UnitSetting>
- </li>
- <li>
- <BooleanSetting path="sidebarRight">
- {{ $t('settings.right_sidebar') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="navbarColumnStretch">
- {{ $t('settings.navbar_column_stretch') }}
- </BooleanSetting>
- </li>
- <li>
- <ChoiceSetting
- v-if="user"
- id="thirdColumnMode"
- path="thirdColumnMode"
- :options="thirdColumnModeOptions"
+ </div>
+ <div class="banner-background-input">
+ <h4>{{ $t('settings.set_new_background') }}</h4>
+ <input
+ type="file"
+ class="input"
+ @change="uploadFile('background', $event)"
>
- {{ $t('settings.third_column_mode') }}
- </ChoiceSetting>
- </li>
- <li v-if="expertLevel > 0">
- {{ $t('settings.column_sizes') }}
- <div class="column-settings">
- <UnitSetting
- v-for="column in columns"
- :key="column"
- :path="column + 'ColumnWidth'"
- :units="horizontalUnits"
- expert="1"
+ <div class="custom-bg-control">
+ <button
+ :disabled="!backgroundPreview"
+ class="btn button-default"
+ @click="submitBackground(background)"
+ >
+ {{ $t('settings.save') }}
+ </button>
+ <button
+ :disabled="!backgroundPreview"
+ class="btn button-default"
+ @click="resetUploadedBackground"
>
- {{ $t('settings.column_sizes_' + column) }}
- </UnitSetting>
+ {{ $t('settings.reset') }}
+ </button>
</div>
- </li>
- <li>
- <BooleanSetting path="disableStickyHeaders">
- {{ $t('settings.disable_sticky_headers') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="showScrollbars">
- {{ $t('settings.show_scrollbars') }}
- </BooleanSetting>
- </li>
- <li>
- <UnitSetting
- path="themeEditorMinWidth"
- :units="['px', 'rem']"
- expert="1"
+ <button
+ v-if="!isDefaultBackground"
+ class="btn button-default reset-button"
+ :title="$t('settings.reset_profile_background')"
+ @click="resetBackground"
>
- {{ $t('settings.theme_editor_min_width') }}
- </UnitSetting>
- </li>
- </ul>
- </div>
- <div class="setting-item">
- <h2>{{ $t('settings.visual_tweaks') }}</h2>
+ {{ $t('settings.reset_profile_background') }}
+ </button>
+ </div>
+ </div>
+ <h3>{{ $t('settings.visual_tweaks') }}</h3>
+ <div class="alert neutral theme-notice">
+ {{ $t("settings.style.visual_tweaks_section_note") }}
+ </div>
<ul class="setting-list">
- <li>
- <BooleanSetting path="modalMobileCenter">
- {{ $t('settings.mobile_center_dialog') }}
- </BooleanSetting>
- </li>
<li>
<ChoiceSetting
id="forcedRoundness"
path="forcedRoundness"
:options="forcedRoundnessOptions"
>
{{ $t('settings.style.themes3.hacks.force_interface_roundness') }}
</ChoiceSetting>
</li>
<li>
<ChoiceSetting
id="underlayOverride"
path="theme3hacks.underlay"
:options="underlayOverrideModes"
>
{{ $t('settings.style.themes3.hacks.underlay_overrides') }}
</ChoiceSetting>
</li>
<li v-if="instanceWallpaperUsed">
<BooleanSetting path="hideInstanceWallpaper">
{{ $t('settings.hide_wallpaper') }}
</BooleanSetting>
</li>
- <li>
- <BooleanSetting
- path="forceThemeRecompilation"
- :expert="1"
- >
- {{ $t('settings.force_theme_recompilation_debug') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting
- path="themeDebug"
- :expert="1"
- >
- {{ $t('settings.theme_debug') }}
- </BooleanSetting>
- </li>
</ul>
</div>
</div>
</template>
<script src="./appearance_tab.js"></script>
<style lang="scss" src="./appearance_tab.scss"></style>
diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/clutter_tab.js
similarity index 74%
copy from src/components/settings_modal/tabs/filtering_tab.js
copy to src/components/settings_modal/tabs/clutter_tab.js
index 9a4ed814d2..8e12104fcd 100644
--- a/src/components/settings_modal/tabs/filtering_tab.js
+++ b/src/components/settings_modal/tabs/clutter_tab.js
@@ -1,258 +1,194 @@
-import { cloneDeep } from 'lodash'
import { mapState, mapActions } from 'pinia'
import { mapState as mapVuexState } from 'vuex'
import { v4 as uuidv4 } from 'uuid';
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
-import { useInterfaceStore } from 'src/stores/interface'
-
-import {
- newImporter,
- newExporter
-} from 'src/services/export_import/export_import.js'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import UnitSetting from '../helpers/unit_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import HelpIndicator from '../helpers/help_indicator.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Select from 'src/components/select/select.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
-const SUPPORTED_TYPES = new Set(['word', 'regexp', 'user', 'user_regexp'])
-
-const FilteringTab = {
- data () {
- return {
- replyVisibilityOptions: ['all', 'following', 'self'].map(mode => ({
- key: mode,
- value: mode,
- label: this.$t(`settings.reply_visibility_${mode}`)
- })),
- muteBlockLv1Options: ['ask', 'forever', 'temporarily'].map(mode => ({
- key: mode,
- value: mode,
- label: this.$t(`user_card.mute_block_${mode}`)
- })),
- muteFiltersDraftObject: cloneDeep(useServerSideStorageStore().prefsStorage.simple.muteFilters),
- muteFiltersDraftDirty: Object.fromEntries(
- Object.entries(
- useServerSideStorageStore().prefsStorage.simple.muteFilters
- ).map(([k]) => [k, false])
- ),
- exportedFilter: null,
- filterImporter: newImporter({
- validator (parsed) {
- if (Array.isArray(parsed)) return false
- if (!SUPPORTED_TYPES.has(parsed.type)) return false
- return true
- },
- onImport: (data) => {
- const {
- enabled = true,
- expires = null,
- hide = false,
- name = '',
- value = ''
- } = data
- this.createFilter({
- enabled,
- expires,
- hide,
- name,
- value
- })
- },
- onImportFailure (result) {
- console.error('Failure importing filter:', result)
- useInterfaceStore()
- .pushGlobalNotice({
- messageKey: 'settings.filter.import_failure',
- level: 'error'
- })
- }
- }),
- filterExporter: newExporter({
- filename: 'pleromafe_mute-filter',
- getExportedObject: () => this.exportedFilter
- })
- }
- },
+const ClutterTab = {
components: {
BooleanSetting,
ChoiceSetting,
UnitSetting,
IntegerSetting,
Checkbox,
Select,
HelpIndicator
},
computed: {
+ instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
...SharedComputedObject(),
...mapState(
useServerSideStorageStore,
{
muteFilters: store => Object.entries(store.prefsStorage.simple.muteFilters),
muteFiltersObject: store => store.prefsStorage.simple.muteFilters
}
),
...mapVuexState({
blockExpirationSupported: state => state.instance.blockExpiration
}),
onMuteDefaultActionLv1: {
get () {
const value = this.$store.state.config.onMuteDefaultAction
if (value === 'ask' || value === 'forever') {
return value
} else {
return 'temporarily'
}
},
set (value) {
let realValue = value
if (value !== 'ask' && value !== 'forever') {
realValue = '14d'
}
this.$store.dispatch('setOption', { name: 'onMuteDefaultAction', value: realValue })
}
},
onBlockDefaultActionLv1: {
get () {
const value = this.$store.state.config.onBlockDefaultAction
if (value === 'ask' || value === 'forever') {
return value
} else {
return 'temporarily'
}
},
set (value) {
let realValue = value
if (value !== 'ask' && value !== 'forever') {
realValue = '14d'
}
this.$store.dispatch('setOption', { name: 'onBlockDefaultAction', value: realValue })
}
},
muteFiltersDraft () {
return Object.entries(this.muteFiltersDraftObject)
},
muteFiltersExpired () {
const now = Date.now()
return Object
.entries(this.muteFiltersDraftObject)
.filter(([, { expires }]) => expires != null && expires <= now)
}
},
methods: {
...mapActions(useServerSideStorageStore, ['setPreference', 'unsetPreference', 'pushServerSideStorage']),
getDatetimeLocal (timestamp) {
const date = new Date(timestamp)
const fmt = new Intl.NumberFormat("en-US", {minimumIntegerDigits: 2})
const datetime = [
date.getFullYear(),
'-',
fmt.format(date.getMonth() + 1),
'-',
fmt.format(date.getDate()),
'T',
fmt.format(date.getHours()),
':',
fmt.format(date.getMinutes())
].join('')
return datetime
},
checkRegexValid (id) {
const filter = this.muteFiltersObject[id]
if (filter.type !== 'regexp') return true
if (filter.type !== 'user_regexp') return true
const { value } = filter
let valid = true
try {
new RegExp(value)
} catch {
valid = false
console.error('Invalid RegExp: ' + value)
}
return valid
},
createFilter (filter = {
type: 'word',
value: '',
name: 'New Filter',
enabled: true,
expires: null,
hide: false,
}) {
const newId = uuidv4()
filter.order = this.muteFilters.length + 2
this.muteFiltersDraftObject[newId] = filter
this.setPreference({ path: 'simple.muteFilters.' + newId , value: filter })
this.pushServerSideStorage()
},
exportFilter(id) {
this.exportedFilter = { ...this.muteFiltersDraftObject[id] }
delete this.exportedFilter.order
this.filterExporter.exportData()
},
importFilter() {
this.filterImporter.importData()
},
copyFilter (id) {
const filter = { ...this.muteFiltersDraftObject[id] }
const newId = uuidv4()
this.muteFiltersDraftObject[newId] = filter
this.setPreference({ path: 'simple.muteFilters.' + newId , value: filter })
this.pushServerSideStorage()
},
deleteFilter (id) {
delete this.muteFiltersDraftObject[id]
this.unsetPreference({ path: 'simple.muteFilters.' + id , value: null })
this.pushServerSideStorage()
},
purgeExpiredFilters () {
this.muteFiltersExpired.forEach(([id]) => {
console.log(id)
delete this.muteFiltersDraftObject[id]
this.unsetPreference({ path: 'simple.muteFilters.' + id , value: null })
})
this.pushServerSideStorage()
},
updateFilter(id, field, value) {
const filter = { ...this.muteFiltersDraftObject[id] }
if (field === 'expires-never') {
if (!value) {
const offset = 1000 * 60 * 60 * 24 * 14 // 2 weeks
const date = Date.now() + offset
filter.expires = date
} else {
filter.expires = null
}
} else if (field === 'expires') {
const parsed = Date.parse(value)
filter.expires = parsed.valueOf()
} else {
filter[field] = value
}
this.muteFiltersDraftObject[id] = filter
this.muteFiltersDraftDirty[id] = true
},
saveFilter(id) {
this.setPreference({ path: 'simple.muteFilters.' + id , value: this.muteFiltersDraftObject[id] })
this.pushServerSideStorage()
this.muteFiltersDraftDirty[id] = false
},
},
// Updating nested properties
watch: {
replyVisibility () {
this.$store.dispatch('queueFlushAll')
}
}
}
-export default FilteringTab
+export default ClutterTab
diff --git a/src/components/settings_modal/tabs/clutter_tab.vue b/src/components/settings_modal/tabs/clutter_tab.vue
new file mode 100644
index 0000000000..aa3b4cb09f
--- /dev/null
+++ b/src/components/settings_modal/tabs/clutter_tab.vue
@@ -0,0 +1,88 @@
+<template>
+ <div class="clutter-tab">
+ <div class="setting-item">
+ <h3>{{ $t('settings.interface') }}</h3>
+ <ul class="setting-list">
+ <li>
+ <BooleanSetting path="alwaysShowSubjectInput">
+ {{ $t('settings.subject_input_always_show') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="minimalScopesMode">
+ {{ $t('settings.minimal_scopes_mode') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="hidePostStats">
+ {{ $t('settings.hide_post_stats') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="hideUserStats"
+ >
+ {{ $t('settings.hide_user_stats') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="hideBotIndication">
+ {{ $t('settings.hide_actor_type_indication') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="hideScrobbles">
+ {{ $t('settings.hide_scrobbles') }}
+ </BooleanSetting>
+ <ul class="setting-list suboptions">
+ <li>
+ <UnitSetting
+ key="hideScrobblesAfter"
+ path="hideScrobblesAfter"
+ :units="['m', 'h', 'd']"
+ unit-set="time"
+ >
+ {{ $t('settings.hide_scrobbles_after') }}
+ </UnitSetting>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ <h3>{{ $t('settings.attachments') }}</h3>
+ <ul class="setting-list">
+ <li>
+ <IntegerSetting
+ path="maxThumbnails"
+ :min="0"
+ >
+ {{ $t('settings.max_thumbnails') }}
+ </IntegerSetting>
+ </li>
+ <li>
+ <BooleanSetting path="hideAttachments">
+ {{ $t('settings.hide_attachments_in_tl') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="hideAttachmentsInConv">
+ {{ $t('settings.hide_attachments_in_convo') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="userCardHidePersonalMarks">
+ {{ $t('settings.user_card_hide_personal_marks') }}
+ </BooleanSetting>
+ </li>
+ <li v-if="instanceShoutboxPresent">
+ <BooleanSetting
+ path="hideShoutbox"
+ >
+ {{ $t('settings.hide_shoutbox') }}
+ </BooleanSetting>
+ </li>
+ </ul>
+ </div>
+ </div>
+</template>
+
+<script src="./clutter_tab.js"></script>
diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/composing_tab.js
similarity index 84%
copy from src/components/settings_modal/tabs/general_tab.js
copy to src/components/settings_modal/tabs/composing_tab.js
index ff6d0e4770..7efbe70470 100644
--- a/src/components/settings_modal/tabs/general_tab.js
+++ b/src/components/settings_modal/tabs/composing_tab.js
@@ -1,143 +1,178 @@
import { mapState } from 'vuex'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import FloatSetting from '../helpers/float_setting.vue'
import UnitSetting from '../helpers/unit_setting.vue'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
import Select from 'src/components/select/select.vue'
import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
+import FontControl from 'src/components/font_control/font_control.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import localeService from 'src/services/locale/locale.service.js'
import { clearCache, cacheKey, emojiCacheKey } from 'src/services/sw/sw.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
- faGlobe
+ faGlobe,
+ faMessage,
+ faPenAlt,
+ faDatabase,
+ faSliders
} from '@fortawesome/free-solid-svg-icons'
library.add(
- faGlobe
+ faGlobe,
+ faMessage,
+ faPenAlt,
+ faDatabase,
+ faSliders
)
-const GeneralTab = {
+const ComposingTab = {
+ props: {
+ parentCollapsed: {
+ required: true,
+ type: Boolean
+ }
+ },
data () {
return {
subjectLineOptions: ['email', 'noop', 'masto'].map(mode => ({
key: mode,
value: mode,
label: this.$t(`settings.subject_line_${mode === 'masto' ? 'mastodon' : mode}`)
})),
conversationDisplayOptions: ['tree', 'linear'].map(mode => ({
key: mode,
value: mode,
label: this.$t(`settings.conversation_display_${mode}`)
})),
absoluteTime12hOptions: ['24h', '12h'].map(mode => ({
key: mode,
value: mode,
label: this.$t(`settings.absolute_time_format_12h_${mode}`)
})),
conversationOtherRepliesButtonOptions: ['below', 'inside'].map(mode => ({
key: mode,
value: mode,
label: this.$t(`settings.conversation_other_replies_button_${mode}`)
})),
mentionLinkDisplayOptions: ['short', 'full_for_remote', 'full'].map(mode => ({
key: mode,
value: mode,
label: this.$t(`settings.mention_link_display_${mode}`)
})),
userPopoverAvatarActionOptions: ['close', 'zoom', 'open'].map(mode => ({
key: mode,
value: mode,
label: this.$t(`settings.user_popover_avatar_action_${mode}`)
})),
unsavedPostActionOptions: ['save', 'discard', 'confirm'].map(mode => ({
key: mode,
value: mode,
label: this.$t(`settings.unsaved_post_action_${mode}`)
})),
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'),
emailLanguage: this.$store.state.users.currentUser.language || ['']
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
FloatSetting,
UnitSetting,
InterfaceLanguageSwitcher,
ProfileSettingIndicator,
ScopeSelector,
- Select
+ Select,
+ FontControl
},
computed: {
postFormats () {
return this.$store.state.instance.postFormats || []
},
postContentOptions () {
return this.postFormats.map(format => ({
key: format,
value: format,
label: this.$t(`post_status.content_type["${format}"]`)
}))
},
language: {
get: function () { return this.$store.getters.mergedConfig.interfaceLanguage },
set: function (val) {
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
}
},
- instanceShoutboxPresent () { return this.$store.state.instance.shoutAvailable },
- instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
...SharedComputedObject(),
...mapState({
blockExpirationSupported: state => state.instance.blockExpiration,
})
},
methods: {
changeDefaultScope (value) {
this.$store.dispatch('setProfileOption', { name: 'defaultScope', value })
},
clearCache (key) {
clearCache(key)
.then(() => {
this.$store.dispatch('settingsSaved', { success: true })
})
.catch(error => {
this.$store.dispatch('settingsSaved', { error })
})
},
+ tooSmall () {
+ this.$emit('tooSmall')
+ },
+ tooBig () {
+ this.$emit('tooBig')
+ },
+ getNavMode () {
+ return this.$refs.tabSwitcher.getNavMode()
+ },
clearAssetCache () {
this.clearCache(cacheKey)
},
clearEmojiCache () {
this.clearCache(emojiCacheKey)
},
updateProfile () {
const params = {
language: localeService.internalToBackendLocaleMulti(this.emailLanguage)
}
this.$store.state.api.backendInteractor
.updateProfile({ params })
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
})
},
+ updateFont (key, value) {
+ this.$store.dispatch('setOption', {
+ name: 'theme3hacks',
+ value: {
+ ...this.mergedConfig.theme3hacks,
+ fonts: {
+ ...this.mergedConfig.theme3hacks.fonts,
+ [key]: value
+ }
+ }
+ })
+ },
}
}
-export default GeneralTab
+export default ComposingTab
diff --git a/src/components/settings_modal/tabs/composing_tab.vue b/src/components/settings_modal/tabs/composing_tab.vue
new file mode 100644
index 0000000000..60640aab02
--- /dev/null
+++ b/src/components/settings_modal/tabs/composing_tab.vue
@@ -0,0 +1,115 @@
+<template>
+ <div :label="$t('settings.posts')">
+ <div class="setting-item">
+ <h3>{{ $t('settings.general') }}</h3>
+ <ul class="setting-list">
+ <li>
+ <label for="default-vis">
+ {{ $t('settings.default_vis') }}
+ {{ ' ' }}
+ <ScopeSelector
+ class="scope-selector"
+ :show-all="true"
+ :user-default="$store.state.profileConfig.defaultScope"
+ :initial-scope="$store.state.profileConfig.defaultScope"
+ :on-scope-change="changeDefaultScope"
+ :unstyled="false"
+ uns
+ />
+ <ProfileSettingIndicator :is-profile="true" />
+ </label>
+ </li>
+ <li>
+ <!-- <BooleanSetting source="profile" path="defaultNSFW"> -->
+ <BooleanSetting path="sensitiveByDefault">
+ {{ $t('settings.sensitive_by_default') }}
+ </BooleanSetting>
+ </li>
+ <li v-if="postFormats.length > 0">
+ <ChoiceSetting
+ id="postContentType"
+ path="postContentType"
+ :options="postContentOptions"
+ >
+ {{ $t('settings.default_post_status_content_type') }}
+ </ChoiceSetting>
+ </li>
+ <li>
+ <BooleanSetting path="padEmoji">
+ {{ $t('settings.pad_emoji') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="autocompleteSelect"
+ expert="1"
+ >
+ {{ $t('settings.autocomplete_select_first') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="autoSaveDraft"
+ >
+ {{ $t('settings.auto_save_draft') }}
+ </BooleanSetting>
+ </li>
+ <li v-if="!mergedConfig.autoSaveDraft">
+ <ChoiceSetting
+ id="unsavedPostAction"
+ path="unsavedPostAction"
+ :options="unsavedPostActionOptions"
+ expert="1"
+ >
+ {{ $t('settings.unsaved_post_action') }}
+ </ChoiceSetting>
+ </li>
+ </ul>
+ <h3>{{ $t('settings.replies') }}</h3>
+ <ul class="setting-list">
+ <li>
+ <BooleanSetting
+ path="scopeCopy"
+ >
+ {{ $t('settings.scope_copy') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <ChoiceSetting
+ id="subjectLineBehavior"
+ path="subjectLineBehavior"
+ :options="subjectLineOptions"
+ >
+ {{ $t('settings.subject_line_behavior') }}
+ </ChoiceSetting>
+ </li>
+ </ul>
+ <h3 v-if="expertLevel > 0">
+ {{ $t('settings.attachments') }}
+ </h3>
+ <ul class="setting-list">
+ <li>
+ <BooleanSetting
+ path="imageCompression"
+ expert="1"
+ >
+ {{ $t('settings.image_compression') }}
+ </BooleanSetting>
+ <ul class="setting-list suboptions">
+ <li>
+ <BooleanSetting
+ path="alwaysUseJpeg"
+ expert="1"
+ parent-path="imageCompression"
+ >
+ {{ $t('settings.always_use_jpeg') }}
+ </BooleanSetting>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </div>
+ </div>
+</template>
+
+<script src="./composing_tab.js"></script>
diff --git a/src/components/settings_modal/tabs/data_import_export_tab.scss b/src/components/settings_modal/tabs/data_import_export_tab.scss
new file mode 100644
index 0000000000..477dab66c7
--- /dev/null
+++ b/src/components/settings_modal/tabs/data_import_export_tab.scss
@@ -0,0 +1,21 @@
+.data-import-export-tab {
+ .importer-exporter {
+ display: inline-flex;
+ flex-direction: column;
+ gap: 0.5em;
+ }
+
+ table {
+ td, th {
+ line-height: 1.5;
+ }
+
+ th {
+ padding: 0 0.5em;
+ }
+
+ td {
+ padding: 0.5em;
+ }
+ }
+}
diff --git a/src/components/settings_modal/tabs/data_import_export_tab.vue b/src/components/settings_modal/tabs/data_import_export_tab.vue
index eb3c864252..cfd2542775 100644
--- a/src/components/settings_modal/tabs/data_import_export_tab.vue
+++ b/src/components/settings_modal/tabs/data_import_export_tab.vue
@@ -1,131 +1,134 @@
<template>
<div
+ class="data-import-export-tab"
: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 class="setting-item">
- <h2>{{ $t('settings.mute_import') }}</h2>
- <p>{{ $t('settings.import_mutes_from_a_csv_file') }}</p>
- <Importer
- :submit-handler="importMutes"
- :success-message="$t('settings.mutes_imported')"
- :error-message="$t('settings.mute_import_error')"
- />
- </div>
- <div class="setting-item">
- <h2>{{ $t('settings.mute_export') }}</h2>
- <Exporter
- :get-content="getMutesContent"
- filename="mutes.csv"
- :export-button-label="$t('settings.mute_export_button')"
- />
- </div>
- <div class="setting-item">
- <h2>{{ $t('settings.account_backup') }}</h2>
- <p>{{ $t('settings.account_backup_description') }}</p>
- <table>
+ <h3>{{ $t('settings.import_export.title') }}</h3>
+ <ul class="setting-list">
+ <li>
+ <h4>{{ $t('settings.import_export.follows') }}</h4>
+ <p>{{ $t('settings.import_followers_from_a_csv_file') }}</p>
+ <div class="importer-exporter">
+ <Importer
+ :submit-handler="importFollows"
+ :success-message="$t('settings.follows_imported')"
+ :error-message="$t('settings.follow_import_error')"
+ />
+ <Exporter
+ :get-content="getFollowsContent"
+ filename="friends.csv"
+ :export-button-label="$t('settings.follow_export_button')"
+ />
+ </div>
+ </li>
+ <li>
+ <h4>{{ $t('settings.import_export.mutes') }}</h4>
+ <p>{{ $t('settings.import_mutes_from_a_csv_file') }}</p>
+ <div class="importer-exporter">
+ <Importer
+ :submit-handler="importMutes"
+ :success-message="$t('settings.mutes_imported')"
+ :error-message="$t('settings.mute_import_error')"
+ />
+ <Exporter
+ :get-content="getMutesContent"
+ filename="friends.csv"
+ :export-button-label="$t('settings.mute_export_button')"
+ />
+ </div>
+ </li>
+ <li>
+ <h4>{{ $t('settings.import_export.blocks') }}</h4>
+ <p>{{ $t('settings.import_blocks_from_a_csv_file') }}</p>
+ <div class="importer-exporter">
+ <Importer
+ :submit-handler="importBlocks"
+ :success-message="$t('settings.blocks_imported')"
+ :error-message="$t('settings.block_import_error')"
+ />
+ <Exporter
+ :get-content="getBlocksContent"
+ filename="friends.csv"
+ :export-button-label="$t('settings.block_export_button')"
+ />
+ </div>
+ </li>
+ </ul>
+ <h3>{{ $t('settings.account_backup') }}</h3>
+ <div class="setting-list">
+ <p>{{ $t('settings.account_backup_description') }}</p>
+ <button
+ class="btn button-default"
+ @click="addBackup"
+ >
+ {{ $t('settings.add_backup') }}
+ </button>
+ <p v-if="addedBackup">
+ {{ $t('settings.added_backup') }}
+ </p>
+ <template v-if="addBackupError !== false">
+ <p>{{ $t('settings.add_backup_error', { error: addBackupError }) }}</p>
+ </template>
+ </div>
+ <table class="setting-list">
<thead>
<tr>
<th>{{ $t('settings.account_backup_table_head') }}</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="backup in backups"
:key="backup.id"
>
<td>{{ backup.inserted_at }}</td>
<td class="actions">
<a
v-if="backup.processed"
target="_blank"
:href="backup.url"
>
{{ $t('settings.download_backup') }}
</a>
<span
v-else-if="backup.state === 'running'"
>
{{ $t('settings.backup_running', { number: backup.processed_number }, backup.processed_number) }}
</span>
<span
v-else-if="backup.state === 'failed'"
>
{{ $t('settings.backup_failed') }}
</span>
<span
v-else
>
{{ $t('settings.backup_not_ready') }}
</span>
</td>
</tr>
</tbody>
</table>
<div
v-if="listBackupsError"
class="alert error"
>
{{ $t('settings.list_backups_error', { error }) }}
<button
:title="$t('settings.hide_list_backups_error_action')"
@click="listBackupsError = false"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
/>
</button>
</div>
- <button
- class="btn button-default"
- @click="addBackup"
- >
- {{ $t('settings.add_backup') }}
- </button>
- <p v-if="addedBackup">
- {{ $t('settings.added_backup') }}
- </p>
- <template v-if="addBackupError !== false">
- <p>{{ $t('settings.add_backup_error', { error: addBackupError }) }}</p>
- </template>
</div>
</div>
</template>
<script src="./data_import_export_tab.js"></script>
-<!-- <style lang="scss" src="./profile.scss"></style> -->
+<style lang="scss" src="./data_import_export_tab.scss"></style>
diff --git a/src/components/settings_modal/tabs/developer_tab.js b/src/components/settings_modal/tabs/developer_tab.js
new file mode 100644
index 0000000000..f8f21e9f4f
--- /dev/null
+++ b/src/components/settings_modal/tabs/developer_tab.js
@@ -0,0 +1,46 @@
+import BooleanSetting from '../helpers/boolean_setting.vue'
+
+import SharedComputedObject from '../helpers/shared_computed_object.js'
+
+import { clearCache, cacheKey, emojiCacheKey } from 'src/services/sw/sw.js'
+
+const pleromaFeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma-fe/commit/'
+
+const VersionTab = {
+ data () {
+ const instance = this.$store.state.instance
+ return {
+ backendVersion: instance.backendVersion,
+ backendRepository: instance.backendRepository,
+ frontendVersion: instance.frontendVersion
+ }
+ },
+ components: {
+ BooleanSetting
+ },
+ computed: {
+ frontendVersionLink () {
+ return pleromaFeCommitUrl + this.frontendVersion
+ },
+ ...SharedComputedObject(),
+ },
+ methods: {
+ clearAssetCache () {
+ this.clearCache(cacheKey)
+ },
+ clearEmojiCache () {
+ this.clearCache(emojiCacheKey)
+ },
+ clearCache (key) {
+ clearCache(key)
+ .then(() => {
+ this.$store.dispatch('settingsSaved', { success: true })
+ })
+ .catch(error => {
+ this.$store.dispatch('settingsSaved', { error })
+ })
+ }
+ }
+}
+
+export default VersionTab
diff --git a/src/components/settings_modal/tabs/developer_tab.scss b/src/components/settings_modal/tabs/developer_tab.scss
new file mode 100644
index 0000000000..944cf052fc
--- /dev/null
+++ b/src/components/settings_modal/tabs/developer_tab.scss
@@ -0,0 +1,10 @@
+.developer-tab {
+ dt {
+ font-weight: 900;
+ }
+
+ dd {
+ margin-top: 0.5em;
+ margin-bottom: 1em;
+ }
+}
diff --git a/src/components/settings_modal/tabs/developer_tab.vue b/src/components/settings_modal/tabs/developer_tab.vue
new file mode 100644
index 0000000000..aafa4ec174
--- /dev/null
+++ b/src/components/settings_modal/tabs/developer_tab.vue
@@ -0,0 +1,72 @@
+<template>
+ <div
+ :label="$t('settings.developer')"
+ class="developer-tab"
+ >
+ <div class="setting-item">
+ <h3>{{ $t('settings.version.title') }}</h3>
+ <dl class="setting-list">
+ <dt>{{ $t('settings.version.backend_version') }}</dt>
+ <dd>
+ <a
+ :href="backendRepository"
+ target="_blank"
+ >
+ {{ backendVersion }}
+ </a>
+ </dd>
+ <dt>{{ $t('settings.version.frontend_version') }}</dt>
+ <dd>
+ <a
+ :href="frontendVersionLink"
+ target="_blank"
+ >
+ {{ frontendVersion }}
+ </a>
+ </dd>
+ </dl>
+ <h3>{{ $t('settings.debug') }}</h3>
+ <ul class="setting-list">
+ <li>
+ <BooleanSetting path="virtualScrolling">
+ {{ $t('settings.virtual_scrolling') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <button
+ class="btn button-default"
+ @click="clearAssetCache"
+ >
+ {{ $t('settings.clear_asset_cache') }}
+ </button>
+ </li>
+ <li>
+ <button
+ class="btn button-default"
+ @click="clearEmojiCache"
+ >
+ {{ $t('settings.clear_emoji_cache') }}
+ </button>
+ </li>
+ <li>
+ <BooleanSetting
+ path="themeDebug"
+ :expert="1"
+ >
+ {{ $t('settings.theme_debug') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="forceThemeRecompilation"
+ :expert="1"
+ >
+ {{ $t('settings.force_theme_recompilation_debug') }}
+ </BooleanSetting>
+ </li>
+ </ul>
+ </div>
+ </div>
+</template>
+<script src="./developer_tab.js" />
+<style lang="scss" src="./developer_tab.scss"></style>
diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js
index 9a4ed814d2..ee9142501c 100644
--- a/src/components/settings_modal/tabs/filtering_tab.js
+++ b/src/components/settings_modal/tabs/filtering_tab.js
@@ -1,258 +1,259 @@
import { cloneDeep } from 'lodash'
import { mapState, mapActions } from 'pinia'
import { mapState as mapVuexState } from 'vuex'
import { v4 as uuidv4 } from 'uuid';
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import { useInterfaceStore } from 'src/stores/interface'
import {
newImporter,
newExporter
} from 'src/services/export_import/export_import.js'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import UnitSetting from '../helpers/unit_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import HelpIndicator from '../helpers/help_indicator.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Select from 'src/components/select/select.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
const SUPPORTED_TYPES = new Set(['word', 'regexp', 'user', 'user_regexp'])
const FilteringTab = {
data () {
return {
replyVisibilityOptions: ['all', 'following', 'self'].map(mode => ({
key: mode,
value: mode,
label: this.$t(`settings.reply_visibility_${mode}`)
})),
muteBlockLv1Options: ['ask', 'forever', 'temporarily'].map(mode => ({
key: mode,
value: mode,
label: this.$t(`user_card.mute_block_${mode}`)
})),
muteFiltersDraftObject: cloneDeep(useServerSideStorageStore().prefsStorage.simple.muteFilters),
muteFiltersDraftDirty: Object.fromEntries(
Object.entries(
useServerSideStorageStore().prefsStorage.simple.muteFilters
).map(([k]) => [k, false])
),
exportedFilter: null,
filterImporter: newImporter({
validator (parsed) {
if (Array.isArray(parsed)) return false
if (!SUPPORTED_TYPES.has(parsed.type)) return false
return true
},
onImport: (data) => {
const {
enabled = true,
expires = null,
hide = false,
name = '',
value = ''
} = data
this.createFilter({
enabled,
expires,
hide,
name,
value
})
},
onImportFailure (result) {
console.error('Failure importing filter:', result)
useInterfaceStore()
.pushGlobalNotice({
messageKey: 'settings.filter.import_failure',
level: 'error'
})
}
}),
filterExporter: newExporter({
filename: 'pleromafe_mute-filter',
getExportedObject: () => this.exportedFilter
})
}
},
components: {
BooleanSetting,
ChoiceSetting,
UnitSetting,
IntegerSetting,
Checkbox,
Select,
HelpIndicator
},
computed: {
+ instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
...SharedComputedObject(),
...mapState(
useServerSideStorageStore,
{
muteFilters: store => Object.entries(store.prefsStorage.simple.muteFilters),
muteFiltersObject: store => store.prefsStorage.simple.muteFilters
}
),
...mapVuexState({
blockExpirationSupported: state => state.instance.blockExpiration
}),
onMuteDefaultActionLv1: {
get () {
const value = this.$store.state.config.onMuteDefaultAction
if (value === 'ask' || value === 'forever') {
return value
} else {
return 'temporarily'
}
},
set (value) {
let realValue = value
if (value !== 'ask' && value !== 'forever') {
realValue = '14d'
}
this.$store.dispatch('setOption', { name: 'onMuteDefaultAction', value: realValue })
}
},
onBlockDefaultActionLv1: {
get () {
const value = this.$store.state.config.onBlockDefaultAction
if (value === 'ask' || value === 'forever') {
return value
} else {
return 'temporarily'
}
},
set (value) {
let realValue = value
if (value !== 'ask' && value !== 'forever') {
realValue = '14d'
}
this.$store.dispatch('setOption', { name: 'onBlockDefaultAction', value: realValue })
}
},
muteFiltersDraft () {
return Object.entries(this.muteFiltersDraftObject)
},
muteFiltersExpired () {
const now = Date.now()
return Object
.entries(this.muteFiltersDraftObject)
.filter(([, { expires }]) => expires != null && expires <= now)
}
},
methods: {
...mapActions(useServerSideStorageStore, ['setPreference', 'unsetPreference', 'pushServerSideStorage']),
getDatetimeLocal (timestamp) {
const date = new Date(timestamp)
const fmt = new Intl.NumberFormat("en-US", {minimumIntegerDigits: 2})
const datetime = [
date.getFullYear(),
'-',
fmt.format(date.getMonth() + 1),
'-',
fmt.format(date.getDate()),
'T',
fmt.format(date.getHours()),
':',
fmt.format(date.getMinutes())
].join('')
return datetime
},
checkRegexValid (id) {
const filter = this.muteFiltersObject[id]
if (filter.type !== 'regexp') return true
if (filter.type !== 'user_regexp') return true
const { value } = filter
let valid = true
try {
new RegExp(value)
} catch {
valid = false
console.error('Invalid RegExp: ' + value)
}
return valid
},
createFilter (filter = {
type: 'word',
value: '',
name: 'New Filter',
enabled: true,
expires: null,
hide: false,
}) {
const newId = uuidv4()
filter.order = this.muteFilters.length + 2
this.muteFiltersDraftObject[newId] = filter
this.setPreference({ path: 'simple.muteFilters.' + newId , value: filter })
this.pushServerSideStorage()
},
exportFilter(id) {
this.exportedFilter = { ...this.muteFiltersDraftObject[id] }
delete this.exportedFilter.order
this.filterExporter.exportData()
},
importFilter() {
this.filterImporter.importData()
},
copyFilter (id) {
const filter = { ...this.muteFiltersDraftObject[id] }
const newId = uuidv4()
this.muteFiltersDraftObject[newId] = filter
this.setPreference({ path: 'simple.muteFilters.' + newId , value: filter })
this.pushServerSideStorage()
},
deleteFilter (id) {
delete this.muteFiltersDraftObject[id]
this.unsetPreference({ path: 'simple.muteFilters.' + id , value: null })
this.pushServerSideStorage()
},
purgeExpiredFilters () {
this.muteFiltersExpired.forEach(([id]) => {
console.log(id)
delete this.muteFiltersDraftObject[id]
this.unsetPreference({ path: 'simple.muteFilters.' + id , value: null })
})
this.pushServerSideStorage()
},
updateFilter(id, field, value) {
const filter = { ...this.muteFiltersDraftObject[id] }
if (field === 'expires-never') {
if (!value) {
const offset = 1000 * 60 * 60 * 24 * 14 // 2 weeks
const date = Date.now() + offset
filter.expires = date
} else {
filter.expires = null
}
} else if (field === 'expires') {
const parsed = Date.parse(value)
filter.expires = parsed.valueOf()
} else {
filter[field] = value
}
this.muteFiltersDraftObject[id] = filter
this.muteFiltersDraftDirty[id] = true
},
saveFilter(id) {
this.setPreference({ path: 'simple.muteFilters.' + id , value: this.muteFiltersDraftObject[id] })
this.pushServerSideStorage()
this.muteFiltersDraftDirty[id] = false
},
},
// Updating nested properties
watch: {
replyVisibility () {
this.$store.dispatch('queueFlushAll')
}
}
}
export default FilteringTab
diff --git a/src/components/settings_modal/tabs/filtering_tab.vue b/src/components/settings_modal/tabs/filtering_tab.vue
index a699653f05..e695a1061c 100644
--- a/src/components/settings_modal/tabs/filtering_tab.vue
+++ b/src/components/settings_modal/tabs/filtering_tab.vue
@@ -1,405 +1,338 @@
<template>
- <div
- :label="$t('settings.filtering')"
- class="filtering-tab"
- >
+ <div class="filtering-tab">
<div class="setting-item">
- <h2>{{ $t('settings.filter.clutter') }}</h2>
+ <h3>{{ $t('settings.filter.mute_filter') }}</h3>
<ul class="setting-list">
<li>
<ChoiceSetting
v-if="user"
id="replyVisibility"
path="replyVisibility"
:options="replyVisibilityOptions"
>
{{ $t('settings.replies_in_timeline') }}
</ChoiceSetting>
</li>
- <li>
- <BooleanSetting
- expert="1"
- path="hidePostStats"
- >
- {{ $t('settings.hide_post_stats') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting
- expert="1"
- path="hideUserStats"
- >
- {{ $t('settings.hide_user_stats') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="hideBotIndication">
- {{ $t('settings.hide_actor_type_indication') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="hideScrobbles">
- {{ $t('settings.hide_scrobbles') }}
- </BooleanSetting>
- <ul class="setting-list suboptions">
- <li>
- <UnitSetting
- key="hideScrobblesAfter"
- path="hideScrobblesAfter"
- :units="['m', 'h', 'd']"
- unit-set="time"
- expert="1"
- >
- {{ $t('settings.hide_scrobbles_after') }}
- </UnitSetting>
- </li>
- </ul>
- </li>
- <h3>{{ $t('settings.attachments') }}</h3>
- <li>
- <IntegerSetting
- path="maxThumbnails"
- expert="1"
- :min="0"
- >
- {{ $t('settings.max_thumbnails') }}
- </IntegerSetting>
- </li>
- <li>
- <BooleanSetting path="hideAttachments">
- {{ $t('settings.hide_attachments_in_tl') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="hideAttachmentsInConv">
- {{ $t('settings.hide_attachments_in_convo') }}
- </BooleanSetting>
- </li>
- </ul>
- </div>
- <div class="setting-item">
- <h2>{{ $t('settings.filter.mute_filter') }}</h2>
- <ul class="setting-list">
<li>
{{ $t('user_card.default_mute_expiration') }}
<Select
id="onMuteDefaultActionLv1"
v-model="onMuteDefaultActionLv1"
>
<option
v-for="option in muteBlockLv1Options"
:key="option.key"
:value="option.value"
>
{{ option.label }}
</option>
</Select>
<ul
v-if="onMuteDefaultActionLv1 === 'temporarily'"
class="setting-list suboptions"
>
<li>
<UnitSetting
path="onMuteDefaultAction"
unit-set="time"
:units="['s', 'm', 'h', 'd']"
:min="0"
>
{{ $t('user_card.default_expiration_time') }}
</UnitSetting>
</li>
</ul>
</li>
<li v-if="blockExpirationSupported">
{{ $t('user_card.default_block_expiration') }}
<Select
id="onBlockDefaultActionLv1"
v-model="onBlockDefaultActionLv1"
>
<option
v-for="option in muteBlockLv1Options"
:key="option.key"
:value="option.value"
>
{{ option.label }}
</option>
</Select>
<ul
v-if="onBlockDefaultActionLv1 === 'temporarily'"
class="setting-list suboptions"
>
<li>
<UnitSetting
path="onBlockDefaultAction"
unit-set="time"
:units="['s', 'm', 'h', 'd']"
:min="0"
>
{{ $t('user_card.default_expiration_time') }}
</UnitSetting>
</li>
</ul>
</li>
<li>
<BooleanSetting path="hideFilteredStatuses">
{{ $t('settings.hide_muted_statuses') }}
</BooleanSetting>
<ul class="setting-list suboptions">
<li>
<BooleanSetting
parent-path="hideFilteredStatuses"
:parent-invert="true"
path="hideWordFilteredPosts"
expert="1"
>
{{ $t('settings.hide_filtered_statuses') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
v-if="user"
parent-path="hideFilteredStatuses"
:parent-invert="true"
path="hideMutedThreads"
>
{{ $t('settings.hide_muted_threads') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
v-if="user"
parent-path="hideFilteredStatuses"
:parent-invert="true"
path="hideMutedPosts"
>
{{ $t('settings.hide_muted_posts') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
<BooleanSetting path="muteBotStatuses">
{{ $t('settings.mute_bot_posts') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="muteSensitiveStatuses">
{{ $t('settings.mute_sensitive_posts') }}
</BooleanSetting>
</li>
<li>
<h3>{{ $t('settings.filter.custom_filters') }}</h3>
<p class="muteFiltersActions">
<span class="total">
{{ $t('settings.filter.total_count', { count: muteFiltersDraft.length }) }}
</span>
<button
class="add-button button-default"
type="button"
@click="createFilter()"
>
{{ $t('settings.filter.new') }}
</button>
<button
class="add-button button-default"
type="button"
@click="importFilter()"
>
{{ $t('settings.filter.import') }}
</button>
</p>
<div class="muteFilterContainer">
<div
v-for="filter in muteFiltersDraft"
:key="filter[0]"
class="mute-filter"
:style="{ order: filter[1].order }"
>
<div class="filter-name">
<label
:for="'filterName' + filter[0]"
>
{{ $t('settings.filter.name') }}
</label>
{{ ' ' }}
<input
:id="'filterName' + filter[0]"
class="input"
:value="filter[1].name"
@input="updateFilter(filter[0], 'name', $event.target.value)"
>
<span
v-if="filter[1].expires !== null && Date.now() > filter[1].expires"
class="alert neutral"
>
{{ $t('settings.filter.expired') }}
</span>
</div>
<div class="filter-enabled">
<Checkbox
:id="'filterHide' + filter[0]"
:model-value="filter[1].hide"
:name="'filterHide' + filter[0]"
@update:model-value="updateFilter(filter[0], 'hide', $event)"
>
{{ $t('settings.filter.hide') }}
</Checkbox>
{{ ' ' }}
<Checkbox
:id="'filterEnabled' + filter[0]"
:model-value="filter[1].enabled"
:name="'filterEnabled' + filter[0]"
@update:model-value="updateFilter(filter[0], 'enabled', $event)"
>
{{ $t('settings.enabled') }}
</Checkbox>
</div>
<div class="filter-type filter-field">
<label :for="'filterType' + filter[0]">
<HelpIndicator>
<p>
{{ $t('settings.filter.help.word') }}
</p>
<p>
{{ $t('settings.filter.help.user') }}
</p>
<i18n-t
keypath="settings.filter.help.regexp"
tag="p"
>
<template #link>
<a
:href="$t('settings.filter.help.regexp_url')"
target="_blank"
>
{{ $t('settings.filter.help.regexp_link') }}
</a>
</template>
</i18n-t>
</HelpIndicator>
{{ $t('settings.filter.type') }}
</label>
<Select
:id="'filterType' + filter[0]"
class="filter-field-value"
:model-value="filter[1].type"
@update:model-value="updateFilter(filter[0], 'type', $event)"
>
<option value="word">
{{ $t('settings.filter.plain') }}
</option>
<option value="regexp">
{{ $t('settings.filter.regexp') }}
</option>
<option value="user">
{{ $t('settings.filter.user') }}
</option>
<option value="user_regexp">
{{ $t('settings.filter.user_regexp') }}
</option>
</Select>
</div>
<div class="filter-value filter-field">
<label
:for="'filterValue' + filter[0]"
>
{{ $t('settings.filter.value') }}
</label>
{{ ' ' }}
<input
:id="'filterValue' + filter[0]"
class="input filter-field-value"
:value="filter[1].value"
@input="updateFilter(filter[0], 'value', $event.target.value)"
>
</div>
<div class="filter-expires filter-field">
<label
:for="'filterExpires' + filter[0]"
>
{{ $t('settings.filter.expires') }}
</label>
{{ ' ' }}
<div class="filter-field-value">
<input
:id="'filterExpires' + filter[0]"
class="input"
:class="{ disabled: filter[1].expires === null }"
type="datetime-local"
:disabled="filter[1].expires === null"
:value="filter[1].expires ? getDatetimeLocal(filter[1].expires) : null"
@input="updateFilter(filter[0], 'expires', $event.target.value)"
>
{{ ' ' }}
<Checkbox
:id="'filterExpiresNever' + filter[0]"
:model-value="filter[1].expires === null"
:name="'filterExpiresNever' + filter[0]"
class="input-inset input-boolean never"
@update:model-value="updateFilter(filter[0], 'expires-never', $event)"
>
{{ $t('settings.filter.never_expires') }}
</Checkbox>
</div>
</div>
<div
v-if="!checkRegexValid(filter[0])"
class="alert error"
>
{{ $t('settings.filter.regexp_error') }}
</div>
<div class="filter-buttons">
<button
class="delete-button button-default -danger"
type="button"
@click="deleteFilter(filter[0])"
>
{{ $t('settings.filter.delete') }}
</button>
<button
class="export-button button-default"
type="button"
@click="exportFilter(filter[0])"
>
{{ $t('settings.filter.export') }}
</button>
<button
class="copy-button button-default"
type="button"
@click="copyFilter(filter[0])"
>
{{ $t('settings.filter.copy') }}
</button>
<button
class="save-button button-default"
:class="{ disabled: !muteFiltersDraftDirty[filter[0]] }"
:disabled="!muteFiltersDraftDirty[filter[0]]"
type="button"
@click="saveFilter(filter[0])"
>
{{ $t('settings.filter.save') }}
</button>
</div>
</div>
</div>
<p class="muteFiltersActionsBottom">
<span class="total">
{{ $t('settings.filter.expired_count', { count: muteFiltersExpired.length }) }}
</span>
<button
class="add-button button-default"
type="button"
:class="{ disabled: muteFiltersExpired.length === 0 }"
:disabled="muteFiltersExpired.length === 0"
@click="purgeExpiredFilters()"
>
{{ $t('settings.filter.purge_expired') }}
</button>
</p>
</li>
</ul>
</div>
</div>
</template>
<script src="./filtering_tab.js"></script>
<style src="./filtering_tab.scss"></style>
diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js
index ff6d0e4770..4d3b01c466 100644
--- a/src/components/settings_modal/tabs/general_tab.js
+++ b/src/components/settings_modal/tabs/general_tab.js
@@ -1,143 +1,81 @@
import { mapState } from 'vuex'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
-import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
-import IntegerSetting from '../helpers/integer_setting.vue'
-import FloatSetting from '../helpers/float_setting.vue'
import UnitSetting from '../helpers/unit_setting.vue'
+import FloatSetting from '../helpers/float_setting.vue'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
-import Select from 'src/components/select/select.vue'
import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
+import FontControl from 'src/components/font_control/font_control.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import localeService from 'src/services/locale/locale.service.js'
-import { clearCache, cacheKey, emojiCacheKey } from 'src/services/sw/sw.js'
-import { library } from '@fortawesome/fontawesome-svg-core'
-import {
- faGlobe
-} from '@fortawesome/free-solid-svg-icons'
-
-library.add(
- faGlobe
-)
const GeneralTab = {
+ props: {
+ parentCollapsed: {
+ required: true,
+ type: Boolean
+ }
+ },
data () {
return {
- subjectLineOptions: ['email', 'noop', 'masto'].map(mode => ({
- key: mode,
- value: mode,
- label: this.$t(`settings.subject_line_${mode === 'masto' ? 'mastodon' : mode}`)
- })),
- conversationDisplayOptions: ['tree', 'linear'].map(mode => ({
- key: mode,
- value: mode,
- label: this.$t(`settings.conversation_display_${mode}`)
- })),
absoluteTime12hOptions: ['24h', '12h'].map(mode => ({
key: mode,
value: mode,
label: this.$t(`settings.absolute_time_format_12h_${mode}`)
})),
- conversationOtherRepliesButtonOptions: ['below', 'inside'].map(mode => ({
- key: mode,
- value: mode,
- label: this.$t(`settings.conversation_other_replies_button_${mode}`)
- })),
- mentionLinkDisplayOptions: ['short', 'full_for_remote', 'full'].map(mode => ({
- key: mode,
- value: mode,
- label: this.$t(`settings.mention_link_display_${mode}`)
- })),
- userPopoverAvatarActionOptions: ['close', 'zoom', 'open'].map(mode => ({
- key: mode,
- value: mode,
- label: this.$t(`settings.user_popover_avatar_action_${mode}`)
- })),
- unsavedPostActionOptions: ['save', 'discard', 'confirm'].map(mode => ({
- key: mode,
- value: mode,
- label: this.$t(`settings.unsaved_post_action_${mode}`)
- })),
- 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'),
emailLanguage: this.$store.state.users.currentUser.language || ['']
}
},
components: {
BooleanSetting,
ChoiceSetting,
- IntegerSetting,
- FloatSetting,
UnitSetting,
+ FloatSetting,
+ FontControl,
InterfaceLanguageSwitcher,
- ProfileSettingIndicator,
- ScopeSelector,
- Select
+ ProfileSettingIndicator
},
computed: {
- postFormats () {
- return this.$store.state.instance.postFormats || []
- },
- postContentOptions () {
- return this.postFormats.map(format => ({
- key: format,
- value: format,
- label: this.$t(`post_status.content_type["${format}"]`)
- }))
- },
language: {
get: function () { return this.$store.getters.mergedConfig.interfaceLanguage },
set: function (val) {
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
}
},
- instanceShoutboxPresent () { return this.$store.state.instance.shoutAvailable },
- instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
...SharedComputedObject(),
...mapState({
blockExpirationSupported: state => state.instance.blockExpiration,
})
},
methods: {
- changeDefaultScope (value) {
- this.$store.dispatch('setProfileOption', { name: 'defaultScope', value })
- },
- clearCache (key) {
- clearCache(key)
- .then(() => {
- this.$store.dispatch('settingsSaved', { success: true })
- })
- .catch(error => {
- this.$store.dispatch('settingsSaved', { error })
- })
- },
- clearAssetCache () {
- this.clearCache(cacheKey)
- },
- clearEmojiCache () {
- this.clearCache(emojiCacheKey)
- },
updateProfile () {
const params = {
language: localeService.internalToBackendLocaleMulti(this.emailLanguage)
}
this.$store.state.api.backendInteractor
.updateProfile({ params })
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
})
},
+ updateFont (key, value) {
+ this.$store.dispatch('setOption', {
+ name: 'theme3hacks',
+ value: {
+ ...this.mergedConfig.theme3hacks,
+ fonts: {
+ ...this.mergedConfig.theme3hacks.fonts,
+ [key]: value
+ }
+ }
+ })
+ },
}
}
export default GeneralTab
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
index 49fc57c791..2796c9a958 100644
--- a/src/components/settings_modal/tabs/general_tab.vue
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -1,562 +1,209 @@
<template>
- <div :label="$t('settings.general')">
+ <div>
<div class="setting-item">
- <h2>{{ $t('settings.interface') }}</h2>
+ <h3>{{ $t('settings.format_and_language') }}</h3>
<ul class="setting-list">
<li>
<interface-language-switcher
v-model="language"
@update="val => language = val"
>
{{ $t('settings.interfaceLanguage') }}
</interface-language-switcher>
</li>
<li>
<interface-language-switcher
v-model="emailLanguage"
:profile="true"
@update:model-value="updateProfile()"
>
{{ $t('settings.email_language') }}
</interface-language-switcher>
</li>
- <li v-if="instanceSpecificPanelPresent">
- <BooleanSetting path="hideISP">
- {{ $t('settings.hide_isp') }}
+ <li>
+ <BooleanSetting path="useAbsoluteTimeFormat">
+ {{ $t('settings.absolute_time_format') }}
</BooleanSetting>
</li>
<li>
- <BooleanSetting path="stopGifs">
- {{ $t('settings.stop_gifs') }}
- </BooleanSetting>
+ <ChoiceSetting
+ id="absoluteTime12h"
+ path="absoluteTime12h"
+ :options="absoluteTime12hOptions"
+ >
+ {{ $t('settings.absolute_time_format_12h') }}
+ </ChoiceSetting>
+ </li>
+ </ul>
+ <h3>{{ $t('settings.scale_and_font') }}</h3>
+ <ul class="setting-list">
+ <li>
+ <UnitSetting
+ path="textSize"
+ :step="0.1"
+ :units="['px', 'rem']"
+ :reset-default="{ 'px': 14, 'rem': 1 }"
+ timed-apply-mode
+ >
+ {{ $t('settings.text_size') }}
+ </UnitSetting>
+ <div>
+ <small>
+ <i18n-t
+ scope="global"
+ keypath="settings.text_size_tip"
+ tag="span"
+ >
+ <code>px</code>
+ <code>rem</code>
+ </i18n-t>
+ <br>
+ <i18n-t
+ scope="global"
+ keypath="settings.text_size_tip2"
+ tag="span"
+ >
+ <code>14px</code>
+ </i18n-t>
+ </small>
+ </div>
+ </li>
+ <li>
+ <FontControl
+ :model-value="mergedConfig.theme3hacks.fonts.interface"
+ name="ui"
+ :label="$t('settings.style.fonts.components_inline.interface')"
+ :fallback="{ family: 'sans-serif' }"
+ no-inherit="1"
+ @update:model-value="v => updateFont('interface', v)"
+ />
+ </li>
+ <li>
+ <FontControl
+ :model-value="mergedConfig.theme3hacks.fonts.input"
+ name="input"
+ :fallback="{ family: 'inherit' }"
+ :label="$t('settings.style.fonts.components_inline.input')"
+ @update:model-value="v => updateFont('input', v)"
+ />
+ </li>
+ <li>
+ <UnitSetting
+ path="emojiSize"
+ :step="0.1"
+ :units="['px', 'rem']"
+ :reset-default="{ 'px': 32, 'rem': 2.2 }"
+ >
+ {{ $t('settings.emoji_size') }}
+ </UnitSetting>
+ <ul
+ class="setting-list suboptions"
+ >
+ <li>
+ <FloatSetting
+ v-if="user"
+ path="emojiReactionsScale"
+ >
+ {{ $t('settings.emoji_reactions_scale') }}
+ </FloatSetting>
+ </li>
+ </ul>
</li>
+ </ul>
+ <h3>{{ $t('settings.timelines') }}</h3>
+ <ul class="setting-list">
<li>
<BooleanSetting path="streaming">
{{ $t('settings.streaming') }}
</BooleanSetting>
<ul class="setting-list suboptions">
<li>
<BooleanSetting
path="pauseOnUnfocused"
parent-path="streaming"
>
{{ $t('settings.pause_on_unfocused') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
<BooleanSetting
path="useStreamingApi"
expert="1"
>
{{ $t('settings.useStreamingApi') }}
</BooleanSetting>
</li>
- <li>
- <BooleanSetting
- path="virtualScrolling"
- expert="1"
- >
- {{ $t('settings.virtual_scrolling') }}
- </BooleanSetting>
- </li>
- <li>
- <ChoiceSetting
- id="userPopoverAvatarAction"
- path="userPopoverAvatarAction"
- :options="userPopoverAvatarActionOptions"
- expert="1"
- >
- {{ $t('settings.user_popover_avatar_action') }}
- </ChoiceSetting>
- </li>
- <li>
- <BooleanSetting
- path="userPopoverOverlay"
- expert="1"
- >
- {{ $t('settings.user_popover_avatar_overlay') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting
- path="userCardLeftJustify"
- expert="1"
- >
- {{ $t('settings.user_card_left_justify') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting
- path="userCardHidePersonalMarks"
- expert="1"
- >
- {{ $t('settings.user_card_hide_personal_marks') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting
- path="alwaysShowNewPostButton"
- expert="1"
- >
- {{ $t('settings.always_show_post_button') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting
- path="autohideFloatingPostButton"
- expert="1"
- >
- {{ $t('settings.autohide_floating_post_button') }}
- </BooleanSetting>
- </li>
- <li v-if="instanceShoutboxPresent">
- <BooleanSetting
- path="hideShoutbox"
- expert="1"
- >
- {{ $t('settings.hide_shoutbox') }}
- </BooleanSetting>
- </li>
+ </ul>
+ <h3 v-if="expertLevel > 0">
+ {{ $t('settings.confirmations') }}
+ </h3>
+ <ul
+ v-if="expertLevel > 0"
+ class="setting-list"
+ >
<li class="select-multiple">
<span class="label">{{ $t('settings.confirm_dialogs') }}</span>
<ul class="option-list">
<li>
<BooleanSetting path="modalOnRepeat">
{{ $t('settings.confirm_dialogs_repeat') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="modalOnUnfollow">
{{ $t('settings.confirm_dialogs_unfollow') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
v-if="!blockExpirationSupported"
path="modalOnBlock"
>
{{ $t('settings.confirm_dialogs_block') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="modalOnMuteConversation">
{{ $t('settings.confirm_dialogs_mute_conversation') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="modalOnMuteDomain">
{{ $t('settings.confirm_dialogs_mute_domain') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="modalOnDelete">
{{ $t('settings.confirm_dialogs_delete') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="modalOnLogout">
{{ $t('settings.confirm_dialogs_logout') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="modalOnApproveFollow">
{{ $t('settings.confirm_dialogs_approve_follow') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="modalOnDenyFollow">
{{ $t('settings.confirm_dialogs_deny_follow') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="modalOnRemoveUserFromFollowers">
{{ $t('settings.confirm_dialogs_remove_follower') }}
</BooleanSetting>
</li>
</ul>
</li>
</ul>
</div>
- <div class="setting-item">
- <h2>{{ $t('settings.post_look_feel') }}</h2>
- <ul class="setting-list">
- <li>
- <ChoiceSetting
- id="conversationDisplay"
- path="conversationDisplay"
- :options="conversationDisplayOptions"
- >
- {{ $t('settings.conversation_display') }}
- </ChoiceSetting>
- </li>
- <ul
- v-if="mergedConfig.conversationDisplay !== 'linear'"
- class="setting-list suboptions"
- >
- <li>
- <BooleanSetting path="conversationTreeAdvanced">
- {{ $t('settings.tree_advanced') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting
- path="conversationTreeFadeAncestors"
- :expert="1"
- >
- {{ $t('settings.tree_fade_ancestors') }}
- </BooleanSetting>
- </li>
- <li>
- <IntegerSetting
- path="maxDepthInThread"
- :min="3"
- :expert="1"
- >
- {{ $t('settings.max_depth_in_thread') }}
- </IntegerSetting>
- </li>
- <li>
- <ChoiceSetting
- id="conversationOtherRepliesButton"
- path="conversationOtherRepliesButton"
- :options="conversationOtherRepliesButtonOptions"
- :expert="1"
- >
- {{ $t('settings.conversation_other_replies_button') }}
- </ChoiceSetting>
- </li>
- </ul>
- <li>
- <BooleanSetting path="collapseMessageWithSubject">
- {{ $t('settings.collapse_subject') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting
- path="emojiReactionsOnTimeline"
- expert="1"
- >
- {{ $t('settings.emoji_reactions_on_timeline') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting
- v-if="user"
- source="profile"
- path="stripRichContent"
- expert="1"
- >
- {{ $t('settings.no_rich_text_description') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting
- path="useAbsoluteTimeFormat"
- expert="1"
- >
- {{ $t('settings.absolute_time_format') }}
- </BooleanSetting>
- </li>
- <ul
- v-if="mergedConfig.useAbsoluteTimeFormat"
- class="setting-list suboptions"
- >
- <li>
- <UnitSetting
- path="absoluteTimeFormatMinAge"
- unit-set="time"
- :units="['s', 'm', 'h', 'd']"
- :min="0"
- >
- {{ $t('settings.absolute_time_format_min_age') }}
- </UnitSetting>
- </li>
- <li>
- <ChoiceSetting
- id="absoluteTime12h"
- path="absoluteTime12h"
- :options="absoluteTime12hOptions"
- :expert="1"
- >
- {{ $t('settings.absolute_time_format_12h') }}
- </ChoiceSetting>
- </li>
- </ul>
- <h3>{{ $t('settings.attachments') }}</h3>
- <li>
- <BooleanSetting
- path="imageCompression"
- expert="1"
- >
- {{ $t('settings.image_compression') }}
- </BooleanSetting>
- </li>
- <ul class="setting-list suboptions">
- <li>
- <BooleanSetting
- path="alwaysUseJpeg"
- expert="1"
- parent-path="imageCompression"
- >
- {{ $t('settings.always_use_jpeg') }}
- </BooleanSetting>
- </li>
- </ul>
- <li>
- <BooleanSetting
- path="useContainFit"
- expert="1"
- >
- {{ $t('settings.use_contain_fit') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="hideNsfw">
- {{ $t('settings.nsfw_clickthrough') }}
- </BooleanSetting>
- </li>
- <ul class="setting-list suboptions">
- <li>
- <BooleanSetting
- path="preloadImage"
- expert="1"
- parent-path="hideNsfw"
- >
- {{ $t('settings.preload_images') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting
- path="useOneClickNsfw"
- expert="1"
- parent-path="hideNsfw"
- >
- {{ $t('settings.use_one_click_nsfw') }}
- </BooleanSetting>
- </li>
- </ul>
- <li>
- <BooleanSetting
- path="loopVideo"
- expert="1"
- >
- {{ $t('settings.loop_video') }}
- </BooleanSetting>
- <ul class="setting-list suboptions">
- <li>
- <BooleanSetting
- path="loopVideoSilentOnly"
- expert="1"
- parent-path="loopVideo"
- :disabled="!loopSilentAvailable"
- >
- {{ $t('settings.loop_video_silent_only') }}
- </BooleanSetting>
- <div
- v-if="!loopSilentAvailable"
- class="unavailable"
- >
- <FAIcon icon="globe" />! {{ $t('settings.limited_availability') }}
- </div>
- </li>
- </ul>
- </li>
- <li>
- <BooleanSetting
- path="playVideosInModal"
- expert="1"
- >
- {{ $t('settings.play_videos_in_modal') }}
- </BooleanSetting>
- </li>
- <h3>{{ $t('settings.mention_links') }}</h3>
- <li>
- <ChoiceSetting
- id="mentionLinkDisplay"
- path="mentionLinkDisplay"
- :options="mentionLinkDisplayOptions"
- >
- {{ $t('settings.mention_link_display') }}
- </ChoiceSetting>
- </li>
- <li>
- <BooleanSetting
- path="mentionLinkShowTooltip"
- expert="1"
- >
- {{ $t('settings.mention_link_use_tooltip') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="mentionLinkShowAvatar">
- {{ $t('settings.mention_link_show_avatar') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting
- path="mentionLinkFadeDomain"
- expert="1"
- >
- {{ $t('settings.mention_link_fade_domain') }}
- </BooleanSetting>
- </li>
- <li v-if="user">
- <BooleanSetting
- path="mentionLinkBoldenYou"
- expert="1"
- >
- {{ $t('settings.mention_link_bolden_you') }}
- </BooleanSetting>
- </li>
- <h3 v-if="expertLevel > 0">
- {{ $t('settings.fun') }}
- </h3>
- <li>
- <BooleanSetting
- path="greentext"
- expert="1"
- >
- {{ $t('settings.greentext') }}
- </BooleanSetting>
- </li>
- <li v-if="user">
- <BooleanSetting
- path="mentionLinkShowYous"
- expert="1"
- >
- {{ $t('settings.show_yous') }}
- </BooleanSetting>
- </li>
- </ul>
- </div>
-
- <div
- v-if="user"
- class="setting-item"
- >
- <h2>{{ $t('settings.composing') }}</h2>
- <ul class="setting-list">
- <li>
- <label for="default-vis">
- {{ $t('settings.default_vis') }} <ProfileSettingIndicator :is-profile="true" />
- <ScopeSelector
- class="scope-selector"
- :show-all="true"
- :user-default="$store.state.profileConfig.defaultScope"
- :initial-scope="$store.state.profileConfig.defaultScope"
- :on-scope-change="changeDefaultScope"
- />
- </label>
- </li>
- <li>
- <!-- <BooleanSetting source="profile" path="defaultNSFW"> -->
- <BooleanSetting path="sensitiveByDefault">
- {{ $t('settings.sensitive_by_default') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting
- path="scopeCopy"
- expert="1"
- >
- {{ $t('settings.scope_copy') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting
- path="alwaysShowSubjectInput"
- expert="1"
- >
- {{ $t('settings.subject_input_always_show') }}
- </BooleanSetting>
- </li>
- <li>
- <ChoiceSetting
- id="subjectLineBehavior"
- path="subjectLineBehavior"
- :options="subjectLineOptions"
- expert="1"
- >
- {{ $t('settings.subject_line_behavior') }}
- </ChoiceSetting>
- </li>
- <li v-if="postFormats.length > 0">
- <ChoiceSetting
- id="postContentType"
- path="postContentType"
- :options="postContentOptions"
- >
- {{ $t('settings.post_status_content_type') }}
- </ChoiceSetting>
- </li>
- <li>
- <BooleanSetting
- path="minimalScopesMode"
- expert="1"
- >
- {{ $t('settings.minimal_scopes_mode') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting
- path="padEmoji"
- expert="1"
- >
- {{ $t('settings.pad_emoji') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting
- path="autocompleteSelect"
- expert="1"
- >
- {{ $t('settings.autocomplete_select_first') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting
- path="autoSaveDraft"
- >
- {{ $t('settings.auto_save_draft') }}
- </BooleanSetting>
- </li>
- <li v-if="!mergedConfig.autoSaveDraft">
- <ChoiceSetting
- id="unsavedPostAction"
- path="unsavedPostAction"
- :options="unsavedPostActionOptions"
- >
- {{ $t('settings.unsaved_post_action') }}
- </ChoiceSetting>
- </li>
- </ul>
- </div>
- <div
- class="setting-item"
- >
- <h2>{{ $t('settings.cache') }}</h2>
- <ul class="setting-list">
- <li>
- <button
- class="btn button-default"
- @click="clearAssetCache"
- >
- {{ $t('settings.clear_asset_cache') }}
- </button>
- </li>
- <li>
- <button
- class="btn button-default"
- @click="clearEmojiCache"
- >
- {{ $t('settings.clear_emoji_cache') }}
- </button>
- </li>
- </ul>
- </div>
</div>
</template>
<script src="./general_tab.js"></script>
diff --git a/src/components/settings_modal/tabs/layout_tab.js b/src/components/settings_modal/tabs/layout_tab.js
new file mode 100644
index 0000000000..48b6eb9715
--- /dev/null
+++ b/src/components/settings_modal/tabs/layout_tab.js
@@ -0,0 +1,50 @@
+import BooleanSetting from '../helpers/boolean_setting.vue'
+import ChoiceSetting from '../helpers/choice_setting.vue'
+import UnitSetting from '../helpers/unit_setting.vue'
+import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
+
+import SharedComputedObject from '../helpers/shared_computed_object.js'
+
+const GeneralTab = {
+ props: {
+ parentCollapsed: {
+ required: true,
+ type: Boolean
+ }
+ },
+ data () {
+ return {
+ thirdColumnModeOptions: ['none', 'notifications', 'postform'].map(mode => ({
+ key: mode,
+ value: mode,
+ label: this.$t(`settings.third_column_mode_${mode}`)
+ }))
+ }
+ },
+ components: {
+ BooleanSetting,
+ ChoiceSetting,
+ UnitSetting,
+ ProfileSettingIndicator
+ },
+ computed: {
+ postFormats () {
+ return this.$store.state.instance.postFormats || []
+ },
+ instanceShoutboxPresent () { return this.$store.state.instance.shoutAvailable },
+ columns () {
+ const mode = this.$store.getters.mergedConfig.thirdColumnMode
+
+ const notif = mode === 'none' ? [] : ['notifs']
+
+ if (this.$store.getters.mergedConfig.sidebarRight || mode === 'postform') {
+ return [...notif, 'content', 'sidebar']
+ } else {
+ return ['sidebar', 'content', ...notif]
+ }
+ },
+ ...SharedComputedObject(),
+ }
+}
+
+export default GeneralTab
diff --git a/src/components/settings_modal/tabs/layout_tab.vue b/src/components/settings_modal/tabs/layout_tab.vue
new file mode 100644
index 0000000000..68e90d346b
--- /dev/null
+++ b/src/components/settings_modal/tabs/layout_tab.vue
@@ -0,0 +1,127 @@
+<template>
+ <div :label="$t('settings.layout')">
+ <div class="setting-item">
+ <h3>{{ $t('settings.general') }}</h3>
+ <ul class="setting-list">
+ <li>
+ <BooleanSetting path="modalMobileCenter">
+ {{ $t('settings.mobile_center_dialog') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="alwaysShowNewPostButton"
+ expert="1"
+ >
+ {{ $t('settings.always_show_post_button') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="autohideFloatingPostButton"
+ expert="1"
+ >
+ {{ $t('settings.autohide_floating_post_button') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="userPopoverOverlay"
+ expert="1"
+ >
+ {{ $t('settings.user_popover_avatar_overlay') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="userCardLeftJustify">
+ {{ $t('settings.user_card_left_justify') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <UnitSetting
+ path="themeEditorMinWidth"
+ :units="['px', 'rem']"
+ expert="1"
+ >
+ {{ $t('settings.theme_editor_min_width') }}
+ </UnitSetting>
+ </li>
+ <li>
+ <UnitSetting
+ path="navbarSize"
+ :step="0.1"
+ :units="['px', 'rem']"
+ :reset-default="{ 'px': 55, 'rem': 3.5 }"
+ >
+ {{ $t('settings.navbar_size') }}
+ </UnitSetting>
+ </li>
+ </ul>
+ <h3>{{ $t('settings.columns') }}</h3>
+ <ul class="setting-list">
+ <li>
+ <BooleanSetting path="disableStickyHeaders">
+ {{ $t('settings.disable_sticky_headers') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="showScrollbars">
+ {{ $t('settings.show_scrollbars') }}
+ </BooleanSetting>
+ </li>
+ <li v-if="instanceSpecificPanelPresent">
+ <BooleanSetting path="hideISP">
+ {{ $t('settings.hide_isp') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <UnitSetting
+ path="panelHeaderSize"
+ :step="0.1"
+ :units="['px', 'rem']"
+ :reset-default="{ 'px': 52, 'rem': 3.2 }"
+ timed-apply-mode
+ >
+ {{ $t('settings.panel_header_size') }}
+ </UnitSetting>
+ </li>
+ <li>
+ <BooleanSetting path="sidebarRight">
+ {{ $t('settings.right_sidebar') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="navbarColumnStretch">
+ {{ $t('settings.navbar_column_stretch') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <ChoiceSetting
+ v-if="user"
+ id="thirdColumnMode"
+ path="thirdColumnMode"
+ :options="thirdColumnModeOptions"
+ >
+ {{ $t('settings.third_column_mode') }}
+ </ChoiceSetting>
+ </li>
+ <li v-if="expertLevel > 0">
+ <h4> {{ $t('settings.column_sizes') }} </h4>
+ <div class="column-settings">
+ <UnitSetting
+ v-for="column in columns"
+ :key="column"
+ :path="column + 'ColumnWidth'"
+ :units="horizontalUnits"
+ expert="1"
+ >
+ {{ $t('settings.column_sizes_' + column) }}
+ </UnitSetting>
+ </div>
+ </li>
+ </ul>
+ </div>
+ </div>
+</template>
+
+<script src="./layout_tab.js"></script>
diff --git a/src/components/settings_modal/tabs/notifications_tab.vue b/src/components/settings_modal/tabs/notifications_tab.vue
index 10228888cd..351c1dd9c2 100644
--- a/src/components/settings_modal/tabs/notifications_tab.vue
+++ b/src/components/settings_modal/tabs/notifications_tab.vue
@@ -1,292 +1,290 @@
<template>
<div :label="$t('settings.notifications')">
<div class="setting-item">
- <h2>{{ $t('settings.notification_setting_annoyance') }}</h2>
+ <h3>{{ $t('settings.notification_setting_annoyance') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting path="closingDrawerMarksAsSeen">
{{ $t('settings.notification_setting_drawer_marks_as_seen') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="ignoreInactionableSeen">
{{ $t('settings.notification_setting_ignore_inactionable_seen') }}
</BooleanSetting>
<div>
<small>
{{ $t('settings.notification_setting_ignore_inactionable_seen_tip') }}
</small>
</div>
</li>
<li>
<BooleanSetting
path="unseenAtTop"
expert="1"
>
{{ $t('settings.notification_setting_unseen_at_top') }}
</BooleanSetting>
</li>
</ul>
</div>
<div class="setting-item">
- <h2>{{ $t('settings.notification_setting_filters') }}</h2>
+ <h3>{{ $t('settings.notification_setting_filters') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting
source="profile"
path="blockNotificationsFromStrangers"
>
{{ $t('settings.notification_setting_block_from_strangers') }}
</BooleanSetting>
</li>
<li>
- <h3> {{ $t('settings.notification_visibility') }}</h3>
+ <h4> {{ $t('settings.notification_visibility') }}</h4>
<p v-if="expertLevel > 0">
{{ $t('settings.notification_setting_filters_chrome_push') }}
</p>
<ul class="setting-list two-column">
<li>
- <h4> {{ $t('settings.notification_visibility_mentions') }}</h4>
+ <h5> {{ $t('settings.notification_visibility_mentions') }}</h5>
<ul class="setting-list">
<li>
<BooleanSetting path="notificationVisibility.mentions">
{{ $t('settings.notification_visibility_in_column') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationNative.mentions">
{{ $t('settings.notification_visibility_native_notifications') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
- <h4> {{ $t('settings.notification_visibility_statuses') }}</h4>
+ <h5> {{ $t('settings.notification_visibility_statuses') }}</h5>
<ul class="setting-list">
<li>
<BooleanSetting path="notificationVisibility.statuses">
{{ $t('settings.notification_visibility_in_column') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationNative.statuses">
{{ $t('settings.notification_visibility_native_notifications') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
- <h4> {{ $t('settings.notification_visibility_likes') }}</h4>
+ <h5> {{ $t('settings.notification_visibility_likes') }}</h5>
<ul class="setting-list">
<li>
<BooleanSetting path="notificationVisibility.likes">
{{ $t('settings.notification_visibility_in_column') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationNative.likes">
{{ $t('settings.notification_visibility_native_notifications') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
- <h4> {{ $t('settings.notification_visibility_repeats') }}</h4>
+ <h5> {{ $t('settings.notification_visibility_repeats') }}</h5>
<ul class="setting-list">
<li>
<BooleanSetting path="notificationVisibility.repeats">
{{ $t('settings.notification_visibility_in_column') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationNative.repeats">
{{ $t('settings.notification_visibility_native_notifications') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
- <h4> {{ $t('settings.notification_visibility_emoji_reactions') }}</h4>
+ <h5> {{ $t('settings.notification_visibility_emoji_reactions') }}</h5>
<ul class="setting-list">
<li>
<BooleanSetting path="notificationVisibility.emojiReactions">
{{ $t('settings.notification_visibility_in_column') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationNative.emojiReactions">
{{ $t('settings.notification_visibility_native_notifications') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
- <h4> {{ $t('settings.notification_visibility_follows') }}</h4>
+ <h5> {{ $t('settings.notification_visibility_follows') }}</h5>
<ul class="setting-list">
<li>
<BooleanSetting path="notificationVisibility.follows">
{{ $t('settings.notification_visibility_in_column') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationNative.follows">
{{ $t('settings.notification_visibility_native_notifications') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
- <h4> {{ $t('settings.notification_visibility_follow_requests') }}</h4>
+ <h5> {{ $t('settings.notification_visibility_follow_requests') }}</h5>
<ul class="setting-list">
<li>
<BooleanSetting path="notificationVisibility.followRequest">
{{ $t('settings.notification_visibility_in_column') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationNative.followRequest">
{{ $t('settings.notification_visibility_native_notifications') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
- <h4> {{ $t('settings.notification_visibility_moves') }}</h4>
+ <h5> {{ $t('settings.notification_visibility_moves') }}</h5>
<ul class="setting-list">
<li>
<BooleanSetting path="notificationVisibility.moves">
{{ $t('settings.notification_visibility_in_column') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationNative.moves">
{{ $t('settings.notification_visibility_native_notifications') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
- <h4> {{ $t('settings.notification_visibility_polls') }}</h4>
+ <h5> {{ $t('settings.notification_visibility_polls') }}</h5>
<ul class="setting-list">
<li>
<BooleanSetting path="notificationVisibility.polls">
{{ $t('settings.notification_visibility_in_column') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationNative.polls">
{{ $t('settings.notification_visibility_native_notifications') }}
</BooleanSetting>
</li>
</ul>
</li>
<li v-if="canReceiveReports">
- <h4> {{ $t('settings.notification_visibility_reports') }}</h4>
+ <h5> {{ $t('settings.notification_visibility_reports') }}</h5>
<ul class="setting-list">
<li>
<BooleanSetting path="notificationVisibility.reports">
{{ $t('settings.notification_visibility_in_column') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationNative.reports">
{{ $t('settings.notification_visibility_native_notifications') }}
</BooleanSetting>
</li>
</ul>
</li>
</ul>
</li>
<li>
<BooleanSetting path="showExtraNotifications">
{{ $t('settings.notification_show_extra') }}
</BooleanSetting>
- </li>
- <li>
<ul class="setting-list suboptions">
<li>
<BooleanSetting
path="showChatsInExtraNotifications"
:disabled="!mergedConfig.showExtraNotifications"
>
{{ $t('settings.notification_extra_chats') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="showAnnouncementsInExtraNotifications"
:disabled="!mergedConfig.showExtraNotifications"
>
{{ $t('settings.notification_extra_announcements') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="showFollowRequestsInExtraNotifications"
:disabled="!mergedConfig.showExtraNotifications"
>
{{ $t('settings.notification_extra_follow_requests') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="showExtraNotificationsTip"
:disabled="!mergedConfig.showExtraNotifications"
>
{{ $t('settings.notification_extra_tip') }}
</BooleanSetting>
</li>
</ul>
</li>
</ul>
</div>
<div
v-if="expertLevel > 0"
class="setting-item"
>
- <h2>{{ $t('settings.notification_setting_privacy') }}</h2>
+ <h3>{{ $t('settings.notification_setting_privacy') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting
path="webPushNotifications"
expert="1"
>
{{ $t('settings.enable_web_push_notifications') }}
</BooleanSetting>
<ul class="setting-list suboptions">
<li>
<BooleanSetting
path="webPushAlwaysShowNotifications"
:disabled="!mergedConfig.webPushNotifications"
>
{{ $t('settings.enable_web_push_always_show') }}
</BooleanSetting>
<div :class="{ faint: !mergedConfig.webPushNotifications }">
<small>
{{ $t('settings.enable_web_push_always_show_tip') }}
</small>
</div>
</li>
</ul>
</li>
<li>
<BooleanSetting
source="profile"
path="webPushHideContents"
expert="1"
>
{{ $t('settings.notification_setting_hide_notification_contents') }}
</BooleanSetting>
</li>
</ul>
</div>
<div class="setting-item">
<p>{{ $t('settings.notification_mutes') }}</p>
<p>{{ $t('settings.notification_blocks') }}</p>
</div>
</div>
</template>
<script src="./notifications_tab.js"></script>
<!-- <style lang="scss" src="./profile.scss"></style> -->
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.js
similarity index 100%
rename from src/components/settings_modal/tabs/theme_tab/theme_tab.js
rename to src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.js
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.scss b/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.scss
similarity index 99%
rename from src/components/settings_modal/tabs/theme_tab/theme_tab.scss
rename to src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.scss
index ae2364dccf..96a1b154f8 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.scss
+++ b/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.scss
@@ -1,257 +1,257 @@
-.theme-tab {
+.old-theme-tab {
min-width: var(--themeEditorMinWidth, fit-content);
.deprecation-warning {
padding: 0.5em;
margin: 2em;
}
padding-bottom: 2em;
.preset-switcher {
margin-right: 1em;
}
.btn {
margin-left: 0.25em;
margin-right: 0.25em;
}
.btn-group .btn {
margin: 0;
}
.style-control {
display: flex;
align-items: baseline;
margin-bottom: 5px;
.label {
margin-right: 1em;
flex: 1;
line-height: 2;
}
.opt {
margin: 0.5em;
}
.color-input {
flex: 0 0 0;
}
input,
select {
min-width: 3em;
margin: 0;
flex: 0;
&[type="number"] {
min-width: 9em;
&.-small {
min-width: 5em;
}
}
&[type="range"] {
flex: 1;
min-width: 9em;
align-self: center;
margin: 0 0.5em;
}
&[type="checkbox"] + i {
height: 1.1em;
align-self: center;
}
}
}
.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: 2.1em;
margin-bottom: 1em;
p {
flex: 1;
margin: 0;
margin-right: 0.5em;
}
}
.tab-header-buttons {
display: flex;
flex-direction: column;
.btn {
min-width: 1px;
flex: 0 auto;
padding: 0 1em;
margin-bottom: 0.5em;
}
}
.shadow-selector {
.override {
flex: 1;
margin-left: 0.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: 0.5em;
}
.import-export {
display: flex;
}
.override {
margin-left: 0.5em;
}
}
.save-load-options {
flex-wrap: wrap;
margin-top: 0.5em;
justify-content: center;
.keep-option {
margin: 0 0.5em 0.5em;
min-width: 25%;
}
}
.radius-item {
flex-basis: auto;
}
.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;
}
}
.theme-radius-rn,
.theme-color-cl {
border: 0;
box-shadow: none;
background: transparent;
color: var(--textFaint);
align-self: stretch;
}
.theme-color-cl,
.theme-radius-in,
.theme-color-in {
margin-left: 0.3em;
}
.theme-radius-in {
min-width: 1em;
max-width: 7em;
flex: 1;
}
.theme-radius-lb {
max-width: 50em;
}
.theme-warning {
display: flex;
align-items: baseline;
margin-bottom: 0.5em;
.buttons {
.btn {
margin-bottom: 0.5em;
}
}
}
}
.extra-content {
.apply-container {
display: flex;
flex-direction: row;
justify-content: space-around;
flex-grow: 1;
/* stylelint-disable-next-line no-descending-specificity */
.btn {
flex-grow: 1;
min-height: 2em;
min-width: 0;
max-width: 10em;
padding: 0;
}
}
}
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue b/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.vue
similarity index 99%
rename from src/components/settings_modal/tabs/theme_tab/theme_tab.vue
rename to src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.vue
index ec3f0cb5df..751e113a00 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
+++ b/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.vue
@@ -1,1025 +1,1025 @@
<template>
- <div class="theme-tab">
+ <div class="old-theme-tab">
<div class="alert warning deprecation-warning">
{{ $t("settings.style.themes2_outdated") }}
</div>
<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 button-default"
@click="forceLoad"
>
{{ $t('settings.style.switcher.use_source') }}
</button>
<button
class="btn button-default"
@click="forceSnapshot"
>
{{ $t('settings.style.switcher.use_snapshot') }}
</button>
</template>
<template v-else-if="themeWarning.noActionsPossible">
<button
class="btn button-default"
@click="dismissWarning"
>
{{ $t('general.dismiss') }}
</button>
</template>
<template v-else>
<button
class="btn button-default"
@click="forceLoad"
>
{{ $t('settings.style.switcher.load_theme') }}
</button>
<button
class="btn button-default"
@click="dismissWarning"
>
{{ $t('settings.style.switcher.keep_as_is') }}
</button>
</template>
</div>
</div>
<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"
>
<option
v-for="style in availableStyles"
:key="style.name"
:value="style.name || style[0]"
: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>
</label>
</div>
<div class="export-import">
<button
class="btn button-default"
@click="importTheme"
>
{{ $t(&quot;settings.import_theme&quot;) }}
</button>
<button
class="btn button-default"
@click="exportTheme"
>
{{ $t(&quot;settings.export_theme&quot;) }}
</button>
</div>
</div>
</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 id="theme-preview" />
<div>
<button
class="btn button-default"
@click="updateTheme3Preview"
>
{{ $t("settings.style.update_preview") }}
</button>
</div>
<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 button-default"
@click="clearOpacity"
>
{{ $t('settings.style.switcher.clear_opacity') }}
</button>
<button
class="btn button-default"
@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-checkbox="typeof linkColorLocal !== 'undefined'"
/>
<ColorInput
v-model="linkColorLocal"
name="linkColor"
:fallback="previewTheme.colors?.accent"
:label="$t('settings.links')"
:show-optional-checkbox="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 button-default"
@click="clearOpacity"
>
{{ $t('settings.style.switcher.clear_opacity') }}
</button>
<button
class="btn button-default"
@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
/>
<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
/>
<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
/>
<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
/>
</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
/>
<ColorInput
v-model="panelLinkColorLocal"
name="panelLinkColor"
:fallback="previewTheme.colors?.panelLink"
:label="$t('settings.links')"
/>
<ContrastRatio
:contrast="previewContrast.panelLink"
large
/>
</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.wallpaper') }}</h4>
<ColorInput
v-model="wallpaperColorLocal"
name="wallpaper"
:label="$t('settings.style.advanced_colors.wallpaper')"
:fallback="previewTheme.colors?.wallpaper"
/>
</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 class="color-item">
<h4>{{ $t('chats.chats') }}</h4>
<ColorInput
v-model="chatBgColorLocal"
name="chatBgColor"
:fallback="previewTheme.colors?.bg"
:label="$t('settings.background')"
/>
<h5>{{ $t('settings.style.advanced_colors.chat.incoming') }}</h5>
<ColorInput
v-model="chatMessageIncomingBgColorLocal"
name="chatMessageIncomingBgColor"
:fallback="previewTheme.colors?.bg"
:label="$t('settings.background')"
/>
<ColorInput
v-model="chatMessageIncomingTextColorLocal"
name="chatMessageIncomingTextColor"
:fallback="previewTheme.colors?.text"
:label="$t('settings.text')"
/>
<ColorInput
v-model="chatMessageIncomingLinkColorLocal"
name="chatMessageIncomingLinkColor"
:fallback="previewTheme.colors?.link"
:label="$t('settings.links')"
/>
<ColorInput
v-model="chatMessageIncomingBorderColorLocal"
name="chatMessageIncomingBorderLinkColor"
:fallback="previewTheme.colors?.fg"
:label="$t('settings.style.advanced_colors.chat.border')"
/>
<h5>{{ $t('settings.style.advanced_colors.chat.outgoing') }}</h5>
<ColorInput
v-model="chatMessageOutgoingBgColorLocal"
name="chatMessageOutgoingBgColor"
:fallback="previewTheme.colors?.bg"
:label="$t('settings.background')"
/>
<ColorInput
v-model="chatMessageOutgoingTextColorLocal"
name="chatMessageOutgoingTextColor"
:fallback="previewTheme.colors?.text"
:label="$t('settings.text')"
/>
<ColorInput
v-model="chatMessageOutgoingLinkColorLocal"
name="chatMessageOutgoingLinkColor"
:fallback="previewTheme.colors?.link"
:label="$t('settings.links')"
/>
<ColorInput
v-model="chatMessageOutgoingBorderColorLocal"
name="chatMessageOutgoingBorderLinkColor"
:fallback="previewTheme.colors?.bg"
:label="$t('settings.style.advanced_colors.chat.border')"
/>
</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 button-default"
@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"
/>
<RangeInput
v-model="chatMessageRadiusLocal"
name="chatMessageRadius"
:label="$t('settings.chatMessageRadius')"
:fallback="previewTheme.radii?.chatMessage || 2"
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') }}
{{ ' ' }}
<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>
</div>
<div class="override">
<Checkbox
id="override"
v-model="currentShadowOverriden"
name="override"
class="input-override"
>
{{ $t('settings.style.shadows.override') }}
</Checkbox>
</div>
<button
class="btn button-default"
@click="clearShadows"
>
{{ $t('settings.style.switcher.clear_all') }}
</button>
</div>
<ShadowControl
v-model="currentShadow"
:separate-inset="shadowSelected === 'avatar' || shadowSelected === 'avatarStatus'"
:fallback="currentShadowFallback"
:static-vars="previewTheme.colors"
:compact="true"
/>
</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 button-default"
@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>
<teleport
v-if="isActive"
to="#unscrolled-content"
>
<div class="apply-container">
<button
class="btn button-default submit"
:disabled="!themeValid"
@click="setCustomTheme"
>
{{ $t('general.apply') }}
</button>
<button
class="btn button-default"
@click="clearAll"
>
{{ $t('settings.style.switcher.reset') }}
</button>
</div>
</teleport>
</div>
</template>
-<script src="./theme_tab.js"></script>
+<script src="./old_theme_tab.js"></script>
-<style src="./theme_tab.scss" lang="scss"></style>
+<style src="./old_theme_tab.scss" lang="scss"></style>
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_preview.vue b/src/components/settings_modal/tabs/old_theme_tab/theme_preview.vue
similarity index 100%
rename from src/components/settings_modal/tabs/theme_tab/theme_preview.vue
rename to src/components/settings_modal/tabs/old_theme_tab/theme_preview.vue
diff --git a/src/components/settings_modal/tabs/posts_tab.js b/src/components/settings_modal/tabs/posts_tab.js
new file mode 100644
index 0000000000..094583c15e
--- /dev/null
+++ b/src/components/settings_modal/tabs/posts_tab.js
@@ -0,0 +1,78 @@
+import BooleanSetting from '../helpers/boolean_setting.vue'
+import ChoiceSetting from '../helpers/choice_setting.vue'
+import IntegerSetting from '../helpers/integer_setting.vue'
+import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
+import FontControl from 'src/components/font_control/font_control.vue'
+
+import SharedComputedObject from '../helpers/shared_computed_object.js'
+
+const GeneralTab = {
+ props: {
+ parentCollapsed: {
+ required: true,
+ type: Boolean
+ }
+ },
+ data () {
+ return {
+ conversationDisplayOptions: ['tree', 'linear'].map(mode => ({
+ key: mode,
+ value: mode,
+ label: this.$t(`settings.conversation_display_${mode}`)
+ })),
+ conversationOtherRepliesButtonOptions: ['below', 'inside'].map(mode => ({
+ key: mode,
+ value: mode,
+ label: this.$t(`settings.conversation_other_replies_button_${mode}`)
+ })),
+ mentionLinkDisplayOptions: ['short', 'full_for_remote', 'full'].map(mode => ({
+ key: mode,
+ value: mode,
+ label: this.$t(`settings.mention_link_display_${mode}`)
+ })),
+ userPopoverAvatarActionOptions: ['close', 'zoom', 'open'].map(mode => ({
+ key: mode,
+ value: mode,
+ label: this.$t(`settings.user_popover_avatar_action_${mode}`)
+ })),
+ unsavedPostActionOptions: ['save', 'discard', 'confirm'].map(mode => ({
+ key: mode,
+ value: mode,
+ label: this.$t(`settings.unsaved_post_action_${mode}`)
+ })),
+ 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: {
+ BooleanSetting,
+ ChoiceSetting,
+ IntegerSetting,
+ FontControl,
+ ProfileSettingIndicator
+ },
+ computed: {
+ ...SharedComputedObject(),
+ },
+ methods: {
+ updateFont (key, value) {
+ this.$store.dispatch('setOption', {
+ name: 'theme3hacks',
+ value: {
+ ...this.mergedConfig.theme3hacks,
+ fonts: {
+ ...this.mergedConfig.theme3hacks.fonts,
+ [key]: value
+ }
+ }
+ })
+ },
+ }
+}
+
+export default GeneralTab
diff --git a/src/components/settings_modal/tabs/posts_tab.scss b/src/components/settings_modal/tabs/posts_tab.scss
new file mode 100644
index 0000000000..84c3d0dd2e
--- /dev/null
+++ b/src/components/settings_modal/tabs/posts_tab.scss
@@ -0,0 +1,5 @@
+.posts-tab {
+ .greentext {
+ color: var(--funtextGreentext);
+ }
+}
diff --git a/src/components/settings_modal/tabs/posts_tab.vue b/src/components/settings_modal/tabs/posts_tab.vue
new file mode 100644
index 0000000000..19389600a4
--- /dev/null
+++ b/src/components/settings_modal/tabs/posts_tab.vue
@@ -0,0 +1,257 @@
+<template>
+ <div class="posts-tab">
+ <div class="setting-item">
+ <h3>{{ $t('settings.posts_appearance') }}</h3>
+ <ul class="setting-list">
+ <li>
+ <BooleanSetting path="collapseMessageWithSubject">
+ {{ $t('settings.collapse_subject') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <ChoiceSetting
+ id="conversationDisplay"
+ path="conversationDisplay"
+ :options="conversationDisplayOptions"
+ >
+ {{ $t('settings.conversation_display') }}
+ </ChoiceSetting>
+ <ul
+ v-if="mergedConfig.conversationDisplay !== 'linear'"
+ class="setting-list suboptions"
+ >
+ <li>
+ <BooleanSetting path="conversationTreeAdvanced">
+ {{ $t('settings.tree_advanced') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="conversationTreeFadeAncestors"
+ :expert="1"
+ >
+ {{ $t('settings.tree_fade_ancestors') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <IntegerSetting
+ path="maxDepthInThread"
+ :min="3"
+ :expert="1"
+ >
+ {{ $t('settings.max_depth_in_thread') }}
+ </IntegerSetting>
+ </li>
+ <li>
+ <ChoiceSetting
+ id="conversationOtherRepliesButton"
+ path="conversationOtherRepliesButton"
+ :options="conversationOtherRepliesButtonOptions"
+ :expert="1"
+ >
+ {{ $t('settings.conversation_other_replies_button') }}
+ </ChoiceSetting>
+ </li>
+ </ul>
+ </li>
+ <li>
+ <FontControl
+ :model-value="mergedConfig.theme3hacks.fonts.post"
+ name="post"
+ :fallback="{ family: 'inherit' }"
+ :label="$t('settings.style.fonts.components.post')"
+ @update:model-value="v => updateFont('post', v)"
+ />
+ </li>
+ <li>
+ <FontControl
+ :model-value="mergedConfig.theme3hacks.fonts.monospace"
+ name="postCode"
+ :fallback="{ family: 'monospace' }"
+ :label="$t('settings.style.fonts.components.monospace')"
+ @update:model-value="v => updateFont('monospace', v)"
+ />
+ </li>
+ <li>
+ <BooleanSetting path="greentext">
+ <i18n-t
+ keypath="settings.plaintext_quotes"
+ tag="span"
+ >
+ <span class="greentext">
+ {{ $t('settings.greentext_quotes') }}
+ </span>
+ </i18n-t>
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="emojiReactionsOnTimeline"
+ expert="1"
+ >
+ {{ $t('settings.emoji_reactions_on_timeline') }}
+ </BooleanSetting>
+ </li>
+ </ul>
+ <h3>{{ $t('settings.mention_links') }}</h3>
+ <ul class="setting-list">
+ <li>
+ <ChoiceSetting
+ id="mentionLinkDisplay"
+ path="mentionLinkDisplay"
+ :options="mentionLinkDisplayOptions"
+ >
+ {{ $t('settings.mention_link_display') }}
+ </ChoiceSetting>
+ <ul class="setting-list suboptions">
+ <li>
+ <BooleanSetting
+ v-if="mergedConfig.mentionLinkDisplay !== 'short'"
+ path="mentionLinkFadeDomain"
+ >
+ {{ $t('settings.mention_link_fade_domain') }}
+ </BooleanSetting>
+ </li>
+ </ul>
+ </li>
+ <li>
+ <BooleanSetting
+ path="mentionLinkShowTooltip"
+ expert="1"
+ >
+ {{ $t('settings.mention_link_use_tooltip') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="mentionLinkShowAvatar">
+ {{ $t('settings.mention_link_show_avatar') }}
+ </BooleanSetting>
+ </li>
+ <li v-if="user">
+ <BooleanSetting
+ path="mentionLinkBoldenYou"
+ expert="1"
+ >
+ {{ $t('settings.mention_link_bolden_you') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ v-if="user"
+ source="profile"
+ path="stripRichContent"
+ expert="1"
+ >
+ {{ $t('settings.no_rich_text_description') }}
+ </BooleanSetting>
+ <ul
+ v-if="mergedConfig.useAbsoluteTimeFormat"
+ class="setting-list suboptions"
+ >
+ <li>
+ <UnitSetting
+ path="absoluteTimeFormatMinAge"
+ unit-set="time"
+ :units="['s', 'm', 'h', 'd']"
+ :min="0"
+ >
+ {{ $t('settings.absolute_time_format_min_age') }}
+ </UnitSetting>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ <h3>{{ $t('settings.attachments') }}</h3>
+ <ul class="setting-list">
+ <li>
+ <BooleanSetting path="stopGifs">
+ {{ $t('settings.stop_gifs') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="hideNsfw">
+ {{ $t('settings.nsfw_clickthrough') }}
+ </BooleanSetting>
+ <ul class="setting-list suboptions">
+ <li>
+ <BooleanSetting
+ path="preloadImage"
+ expert="1"
+ parent-path="hideNsfw"
+ >
+ {{ $t('settings.preload_images') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="useOneClickNsfw"
+ expert="1"
+ parent-path="hideNsfw"
+ >
+ {{ $t('settings.use_one_click_nsfw') }}
+ </BooleanSetting>
+ </li>
+ </ul>
+ </li>
+ <li>
+ <BooleanSetting
+ path="loopVideo"
+ expert="1"
+ >
+ {{ $t('settings.loop_video') }}
+ </BooleanSetting>
+ <ul class="setting-list suboptions">
+ <li>
+ <BooleanSetting
+ path="loopVideoSilentOnly"
+ expert="1"
+ parent-path="loopVideo"
+ :disabled="!loopSilentAvailable"
+ >
+ {{ $t('settings.loop_video_silent_only') }}
+ </BooleanSetting>
+ <div
+ v-if="!loopSilentAvailable"
+ class="unavailable"
+ >
+ <FAIcon icon="globe" />! {{ $t('settings.limited_availability') }}
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li>
+ <BooleanSetting
+ path="playVideosInModal"
+ expert="1"
+ >
+ {{ $t('settings.play_videos_in_modal') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="useContainFit"
+ expert="1"
+ >
+ {{ $t('settings.use_contain_fit') }}
+ </BooleanSetting>
+ </li>
+ </ul>
+ <h3 v-if="expertLevel > 0">
+ {{ $t('settings.fun') }}
+ </h3>
+ <ul
+ v-if="expertLevel > 0"
+ class="setting-list"
+ >
+ <li v-if="user">
+ <BooleanSetting path="mentionLinkShowYous">
+ {{ $t('settings.show_yous') }}
+ </BooleanSetting>
+ </li>
+ </ul>
+ </div>
+ </div>
+</template>
+
+<script src="./posts_tab.js"></script>
+<style src="./posts_tab.scss"></style>
diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue
index 947be4a184..e98551f1c4 100644
--- a/src/components/settings_modal/tabs/profile_tab.vue
+++ b/src/components/settings_modal/tabs/profile_tab.vue
@@ -1,88 +1,87 @@
<template>
<div class="profile-tab">
<div class="setting-item profile-edit">
- <h2>{{ $t('settings.account_profile_edit') }}</h2>
<UserCard
:user-id="user.id"
:editable="true"
:switcher="false"
/>
</div>
<div class="setting-item">
- <h2>{{ $t('settings.account_privacy') }}</h2>
+ <h3>{{ $t('settings.account_privacy') }}</h3>
<ul class="setting-list">
<li>
<Checkbox v-model="locked">
{{ $t('settings.lock_account_description') }}
</Checkbox>
<ProfileSettingIndicator :is-profile="true" />
</li>
<li>
<BooleanSetting
source="profile"
path="discoverable"
>
{{ $t('settings.discoverable') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
source="profile"
path="allowFollowingMove"
>
{{ $t('settings.allow_following_move') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
source="profile"
path="hideFavorites"
>
{{ $t('settings.hide_favorites_description') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
source="profile"
path="hideFollowers"
>
{{ $t('settings.hide_followers_description') }}
</BooleanSetting>
<ul class="setting-list suboptions">
<li>
<BooleanSetting
source="profile"
path="hideFollowersCount"
parent-path="hideFollowers"
>
{{ $t('settings.hide_followers_count_description') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
<BooleanSetting
source="profile"
path="hideFollows"
>
{{ $t('settings.hide_follows_description') }}
</BooleanSetting>
<ul class="setting-list suboptions">
<li>
<BooleanSetting
source="profile"
path="hideFollowsCount"
parent-path="hideFollows"
>
{{ $t('settings.hide_follows_count_description') }}
</BooleanSetting>
</li>
</ul>
</li>
</ul>
</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/security_tab.vue b/src/components/settings_modal/tabs/security_tab/security_tab.vue
index 4431550aeb..d1e6e3c874 100644
--- a/src/components/settings_modal/tabs/security_tab/security_tab.vue
+++ b/src/components/settings_modal/tabs/security_tab/security_tab.vue
@@ -1,262 +1,275 @@
<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"
- class="input"
- >
- </div>
- <div>
- <p>{{ $t('settings.current_password') }}</p>
- <input
- v-model="changeEmailPassword"
- type="password"
- autocomplete="current-password"
- class="input"
- >
- </div>
- <button
- class="btn button-default"
- @click="changeEmail"
- >
- {{ $t('settings.save') }}
- </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"
- class="input"
- >
- </div>
- <div>
- <p>{{ $t('settings.new_password') }}</p>
- <input
- v-model="changePasswordInputs[1]"
- type="password"
- class="input"
- >
- </div>
- <div>
- <p>{{ $t('settings.confirm_new_password') }}</p>
- <input
- v-model="changePasswordInputs[2]"
- type="password"
- class="input"
- >
- </div>
- <button
- class="btn button-default"
- @click="changePassword"
- >
- {{ $t('settings.save') }}
- </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>
+ <h3>{{ $t('settings.change_email') }}</h3>
+ <ul class="setting-list">
+ <li>
+ <h4>{{ $t('settings.new_email') }}</h4>
+ <input
+ v-model="newEmail"
+ type="email"
+ autocomplete="email"
+ class="input"
+ >
+ </li>
+ <li>
+ <h4>{{ $t('settings.current_password') }}</h4>
+ <input
+ v-model="changeEmailPassword"
+ type="password"
+ autocomplete="current-password"
+ class="input"
+ >
+ </li>
+ <li>
+ <button
+ class="btn button-default"
+ @click="changeEmail"
+ >
+ {{ $t('settings.save') }}
+ </button>
+ </li>
+ <li>
+ <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>
+ </li>
+ </ul>
</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"
+ <h3>{{ $t('settings.change_password') }}</h3>
+ <ul class="setting-list">
+ <li>
+ <h4>{{ $t('settings.current_password') }}</h4>
+ <input
+ v-model="changePasswordInputs[0]"
+ type="password"
+ class="input"
>
- <td>{{ oauthToken.appName }}</td>
- <td>{{ oauthToken.validUntil }}</td>
- <td class="actions">
- <button
- class="btn button-default"
- @click="revokeToken(oauthToken.id)"
- >
- {{ $t('settings.revoke_token') }}
- </button>
- </td>
- </tr>
- </tbody>
- </table>
+ </li>
+ <li>
+ <h4>{{ $t('settings.new_password') }}</h4>
+ <input
+ v-model="changePasswordInputs[1]"
+ type="password"
+ class="input"
+ >
+ </li>
+ <li>
+ <h4>{{ $t('settings.confirm_new_password') }}</h4>
+ <input
+ v-model="changePasswordInputs[2]"
+ type="password"
+ class="input"
+ >
+ </li>
+ <li>
+ <button
+ class="btn button-default"
+ @click="changePassword"
+ >
+ {{ $t('settings.save') }}
+ </button>
+ </li>
+ <li>
+ <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>
+ </li>
+ </ul>
</div>
- <mfa />
<div class="setting-item">
- <h2>{{ $t('settings.account_alias') }}</h2>
+ <h3>{{ $t('settings.account_alias') }}</h3>
<table>
<thead>
<tr>
<th>{{ $t('settings.account_alias_table_head') }}</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="alias in aliases"
:key="alias"
>
<td>{{ alias }}</td>
<td class="actions">
<button
class="btn button-default"
@click="removeAlias(alias)"
>
{{ $t('settings.remove_alias') }}
</button>
</td>
</tr>
</tbody>
</table>
<div
v-if="listAliasesError"
class="alert error"
>
{{ $t('settings.list_aliases_error', { error }) }}
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
:title="$t('settings.hide_list_aliases_error_action')"
@click="listAliasesError = false"
/>
</div>
<div>
<i18n-t
scope="global"
keypath="settings.new_alias_target"
tag="p"
>
<code
place="example"
>
foo@example.org
</code>
</i18n-t>
<input
v-model="addAliasTarget"
class="input"
>
</div>
<button
class="btn button-default"
@click="addAlias"
>
{{ $t('settings.save') }}
</button>
<p v-if="addedAlias">
{{ $t('settings.added_alias') }}
</p>
<template v-if="addAliasError !== false">
<p>{{ $t('settings.add_alias_error', { error: addAliasError }) }}</p>
</template>
</div>
+
+ <div class="setting-item">
+ <h3>{{ $t('settings.oauth_tokens') }}</h3>
+ <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 button-default"
+ @click="revokeToken(oauthToken.id)"
+ >
+ {{ $t('settings.revoke_token') }}
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ <mfa />
+
<div class="setting-item">
- <h2>{{ $t('settings.move_account') }}</h2>
+ <h3>{{ $t('settings.move_account') }}</h3>
<p>{{ $t('settings.move_account_notes') }}</p>
<div>
<i18n-t
keypath="settings.move_account_target"
tag="p"
scope="global"
>
<template #example>
<code>
foo@example.org
</code>
</template>
</i18n-t>
<input
v-model="moveAccountTarget"
class="input"
>
</div>
<div>
<p>{{ $t('settings.current_password') }}</p>
<input
v-model="moveAccountPassword"
type="password"
autocomplete="current-password"
class="input"
>
</div>
<button
class="btn button-default"
@click="moveAccount"
>
{{ $t('settings.save') }}
</button>
<p v-if="movedAccount">
{{ $t('settings.moved_account') }}
</p>
<template v-if="moveAccountError !== false">
<p>{{ $t('settings.move_account_error', { error: moveAccountError }) }}</p>
</template>
</div>
<div class="setting-item">
- <h2>{{ $t('settings.delete_account') }}</h2>
+ <h3>{{ $t('settings.delete_account') }}</h3>
<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"
class="input"
>
<button
class="btn button-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 button-default"
@click="confirmDelete"
>
{{ $t('settings.delete_account') }}
</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/style_tab/style_tab.js b/src/components/settings_modal/tabs/style_tab/style_tab.js
index 5453da57bb..35626a103b 100644
--- a/src/components/settings_modal/tabs/style_tab/style_tab.js
+++ b/src/components/settings_modal/tabs/style_tab/style_tab.js
@@ -1,846 +1,846 @@
import { ref, reactive, computed, watch, provide, getCurrentInstance } from 'vue'
import { useInterfaceStore } from 'src/stores/interface'
import { get, set, unset, throttle } from 'lodash'
import Select from 'src/components/select/select.vue'
import SelectMotion from 'src/components/select/select_motion.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ComponentPreview from 'src/components/component_preview/component_preview.vue'
import StringSetting from '../../helpers/string_setting.vue'
import ShadowControl from 'src/components/shadow_control/shadow_control.vue'
import ColorInput from 'src/components/color_input/color_input.vue'
import PaletteEditor from 'src/components/palette_editor/palette_editor.vue'
import OpacityInput from 'src/components/opacity_input/opacity_input.vue'
import RoundnessInput from 'src/components/roundness_input/roundness_input.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import Tooltip from 'src/components/tooltip/tooltip.vue'
import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue'
-import Preview from '../theme_tab/theme_preview.vue'
+import Preview from '../old_theme_tab/theme_preview.vue'
import VirtualDirectivesTab from './virtual_directives_tab.vue'
import { createStyleSheet, adoptStyleSheets } from 'src/services/style_setter/style_setter.js'
import { init, findColor } from 'src/services/theme_data/theme_data_3.service.js'
import { getCssRules } from 'src/services/theme_data/css_utils.js'
import { serialize } from 'src/services/theme_data/iss_serializer.js'
import { deserializeShadow, deserialize } from 'src/services/theme_data/iss_deserializer.js'
import {
rgb2hex,
hex2rgb,
getContrastRatio
} from 'src/services/color_convert/color_convert.js'
import {
newImporter,
newExporter
} from 'src/services/export_import/export_import.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faFloppyDisk,
faFolderOpen,
faFile,
faArrowsRotate,
faCheck
} from '@fortawesome/free-solid-svg-icons'
// helper for debugging
// eslint-disable-next-line no-unused-vars
const toValue = (x) => JSON.parse(JSON.stringify(x === undefined ? 'null' : x))
// helper to make states comparable
const normalizeStates = (states) => ['normal', ...(states?.filter(x => x !== 'normal') || [])].join(':')
library.add(
faFile,
faFloppyDisk,
faFolderOpen,
faArrowsRotate,
faCheck
)
export default {
components: {
Select,
SelectMotion,
Checkbox,
Tooltip,
StringSetting,
ComponentPreview,
TabSwitcher,
ShadowControl,
ColorInput,
PaletteEditor,
OpacityInput,
RoundnessInput,
ContrastRatio,
Preview,
VirtualDirectivesTab
},
setup () {
const exports = {}
const interfaceStore = useInterfaceStore()
// All rules that are made by editor
const allEditedRules = ref(interfaceStore.styleDataUsed || {})
const styleDataUsed = computed(() => interfaceStore.styleDataUsed)
watch([styleDataUsed], () => {
onImport(interfaceStore.styleDataUsed)
}, { once: true })
exports.isActive = computed(() => {
const tabSwitcher = getCurrentInstance().parent.ctx
return tabSwitcher ? tabSwitcher.isActive('style') : false
})
// ## Meta stuff
exports.name = ref('')
exports.author = ref('')
exports.license = ref('')
exports.website = ref('')
const metaOut = computed(() => {
return [
'@meta {',
` name: ${exports.name.value};`,
` author: ${exports.author.value};`,
` license: ${exports.license.value};`,
` website: ${exports.website.value};`,
'}'
].join('\n')
})
const metaRule = computed(() => ({
component: '@meta',
directives: {
name: exports.name.value,
author: exports.author.value,
license: exports.license.value,
website: exports.website.value
}
}))
// ## Palette stuff
const palettes = reactive([
{
name: 'default',
bg: '#121a24',
fg: '#182230',
text: '#b9b9ba',
link: '#d8a070',
accent: '#d8a070',
cRed: '#FF0000',
cBlue: '#0095ff',
cGreen: '#0fa00f',
cOrange: '#ffa500'
},
{
name: 'light',
bg: '#f2f6f9',
fg: '#d6dfed',
text: '#304055',
underlay: '#5d6086',
accent: '#f55b1b',
cBlue: '#0095ff',
cRed: '#d31014',
cGreen: '#0fa00f',
cOrange: '#ffa500',
border: '#d8e6f9'
}
])
exports.palettes = palettes
// This is kinda dumb but you cannot "replace" reactive() object
// and so v-model simply fails when you try to chage (increase only?)
// length of the array. Since linter complains about mutating modelValue
// inside SelectMotion, the next best thing is to just wipe existing array
// and replace it with new one.
const onPalettesUpdate = (e) => {
palettes.splice(0, palettes.length)
palettes.push(...e)
}
exports.onPalettesUpdate = onPalettesUpdate
const selectedPaletteId = ref(0)
const selectedPalette = computed({
get () {
return palettes[selectedPaletteId.value]
},
set (newPalette) {
palettes[selectedPaletteId.value] = newPalette
}
})
exports.selectedPaletteId = selectedPaletteId
exports.selectedPalette = selectedPalette
provide('selectedPalette', selectedPalette)
watch([selectedPalette], () => updateOverallPreview())
exports.getNewPalette = () => ({
name: 'new palette',
bg: '#121a24',
fg: '#182230',
text: '#b9b9ba',
link: '#d8a070',
accent: '#d8a070',
cRed: '#FF0000',
cBlue: '#0095ff',
cGreen: '#0fa00f',
cOrange: '#ffa500'
})
// Raw format
const palettesRule = computed(() => {
return palettes.map(palette => {
const { name, ...rest } = palette
return {
component: '@palette',
variant: name,
directives: Object
.entries(rest)
.filter(([k, v]) => v && k)
.reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {})
}
})
})
// Text format
const palettesOut = computed(() => {
return palettes.map(({ name, ...palette }) => {
const entries = Object
.entries(palette)
.filter(([k, v]) => v && k)
.map(([slot, data]) => ` ${slot}: ${data};`)
.join('\n')
return `@palette.${name} {\n${entries}\n}`
}).join('\n\n')
})
// ## Components stuff
// Getting existing components
const componentsContext = import.meta.glob(
['/src/**/*.style.js', '/src/**/*.style.json'],
{ eager: true }
)
const componentKeysAll = Object.keys(componentsContext)
const componentsMap = new Map(
componentKeysAll
.map(
key => [key, componentsContext[key].default]
).filter(([, component]) => !component.virtual && !component.notEditable)
)
exports.componentsMap = componentsMap
const componentKeys = [...componentsMap.keys()]
exports.componentKeys = componentKeys
// Component list and selection
const selectedComponentKey = ref(componentsMap.keys().next().value)
exports.selectedComponentKey = selectedComponentKey
const selectedComponent = computed(() => componentsMap.get(selectedComponentKey.value))
const selectedComponentName = computed(() => selectedComponent.value.name)
// Selection basis
exports.selectedComponentVariants = computed(() => {
return Object.keys({ normal: null, ...(selectedComponent.value.variants || {}) })
})
exports.selectedComponentStates = computed(() => {
const all = Object.keys({ normal: null, ...(selectedComponent.value.states || {}) })
return all.filter(x => x !== 'normal')
})
// selection
const selectedVariant = ref('normal')
exports.selectedVariant = selectedVariant
const selectedState = reactive(new Set())
exports.selectedState = selectedState
exports.updateSelectedStates = (state, v) => {
if (v) {
selectedState.add(state)
} else {
selectedState.delete(state)
}
}
// Reset variant and state on component change
const updateSelectedComponent = () => {
selectedVariant.value = 'normal'
selectedState.clear()
}
watch(
selectedComponentName,
updateSelectedComponent
)
// ### Rules stuff aka meat and potatoes
// The native structure of separate rules and the child -> parent
// relation isn't very convenient for editor, we replace the array
// and child -> parent structure with map and parent -> child structure
const rulesToEditorFriendly = (rules, root = {}) => rules.reduce((acc, rule) => {
const { parent: rParent, component: rComponent } = rule
const parent = rParent ?? rule
const hasChildren = !!rParent
const child = hasChildren ? rule : null
const {
component: pComponent,
variant: pVariant = 'normal',
state: pState = [] // no relation to Intel CPUs whatsoever
} = parent
const pPath = `${hasChildren ? pComponent : rComponent}.${pVariant}.${normalizeStates(pState)}`
let output = get(acc, pPath)
if (!output) {
set(acc, pPath, {})
output = get(acc, pPath)
}
if (hasChildren) {
output._children = output._children ?? {}
const {
component: cComponent,
variant: cVariant = 'normal',
state: cState = [],
directives
} = child
const cPath = `${cComponent}.${cVariant}.${normalizeStates(cState)}`
set(output._children, cPath, { directives })
} else {
output.directives = parent.directives
}
return acc
}, root)
const editorFriendlyFallbackStructure = computed(() => {
const root = {}
componentKeys.forEach((componentKey) => {
const componentValue = componentsMap.get(componentKey)
const { defaultRules, name } = componentValue
rulesToEditorFriendly(
defaultRules.map((rule) => ({ ...rule, component: name })),
root
)
})
return root
})
// Checking whether component can support some "directives" which
// are actually virtual subcomponents, i.e. Text, Link etc
exports.componentHas = (subComponent) => {
return !!selectedComponent.value.validInnerComponents?.find(x => x === subComponent)
}
// Path for lodash's get and set
const getPath = (component, directive) => {
const pathSuffix = component ? `._children.${component}.normal.normal` : ''
const path = `${selectedComponentName.value}.${selectedVariant.value}.${normalizeStates([...selectedState])}${pathSuffix}.directives.${directive}`
return path
}
// Templates for directives
const isElementPresent = (component, directive, defaultValue = '') => computed({
get () {
return get(allEditedRules.value, getPath(component, directive)) != null
},
set (value) {
if (value) {
const fallback = get(
editorFriendlyFallbackStructure.value,
getPath(component, directive)
)
set(allEditedRules.value, getPath(component, directive), fallback ?? defaultValue)
} else {
unset(allEditedRules.value, getPath(component, directive))
}
exports.updateOverallPreview()
}
})
const getEditedElement = (component, directive, postProcess = x => x) => computed({
get () {
let usedRule
const fallback = editorFriendlyFallbackStructure.value
const real = allEditedRules.value
const path = getPath(component, directive)
usedRule = get(real, path) // get real
if (usedRule === '') {
return usedRule
}
if (!usedRule) {
usedRule = get(fallback, path)
}
return postProcess(usedRule)
},
set (value) {
if (value != null) {
set(allEditedRules.value, getPath(component, directive), value)
} else {
unset(allEditedRules.value, getPath(component, directive))
}
exports.updateOverallPreview()
}
})
// All the editable stuff for the component
exports.editedBackgroundColor = getEditedElement(null, 'background')
exports.isBackgroundColorPresent = isElementPresent(null, 'background', '#FFFFFF')
exports.editedOpacity = getEditedElement(null, 'opacity')
exports.isOpacityPresent = isElementPresent(null, 'opacity', 1)
exports.editedRoundness = getEditedElement(null, 'roundness')
exports.isRoundnessPresent = isElementPresent(null, 'roundness', '0')
exports.editedTextColor = getEditedElement('Text', 'textColor')
exports.isTextColorPresent = isElementPresent('Text', 'textColor', '#000000')
exports.editedTextAuto = getEditedElement('Text', 'textAuto')
exports.isTextAutoPresent = isElementPresent('Text', 'textAuto', '#000000')
exports.editedLinkColor = getEditedElement('Link', 'textColor')
exports.isLinkColorPresent = isElementPresent('Link', 'textColor', '#000080')
exports.editedIconColor = getEditedElement('Icon', 'textColor')
exports.isIconColorPresent = isElementPresent('Icon', 'textColor', '#909090')
exports.editedBorderColor = getEditedElement('Border', 'textColor')
exports.isBorderColorPresent = isElementPresent('Border', 'textColor', '#909090')
const getContrast = (bg, text) => {
try {
const bgRgb = hex2rgb(bg)
const textRgb = hex2rgb(text)
const ratio = getContrastRatio(bgRgb, textRgb)
return {
// TODO this ideally should be part of <ContractRatio />
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
}
} catch (e) {
console.warn('Failure computing contrast', e)
return { error: e }
}
}
const normalizeShadows = (shadows) => {
return shadows?.map(shadow => {
if (typeof shadow === 'object') {
return shadow
}
if (typeof shadow === 'string') {
try {
return deserializeShadow(shadow)
} catch (e) {
console.warn(e)
return shadow
}
}
return null
})
}
provide('normalizeShadows', normalizeShadows)
// Shadow is partially edited outside the ShadowControl
// for better space utilization
const editedShadow = getEditedElement(null, 'shadow', normalizeShadows)
exports.editedShadow = editedShadow
const editedSubShadowId = ref(null)
exports.editedSubShadowId = editedSubShadowId
const editedSubShadow = computed(() => {
if (editedShadow.value == null || editedSubShadowId.value == null) return null
return editedShadow.value[editedSubShadowId.value]
})
exports.editedSubShadow = editedSubShadow
exports.isShadowPresent = isElementPresent(null, 'shadow', [])
exports.onSubShadow = (id) => {
if (id != null) {
editedSubShadowId.value = id
} else {
editedSubShadow.value = null
}
}
exports.updateSubShadow = (axis, value) => {
if (!editedSubShadow.value || editedSubShadowId.value == null) return
const newEditedShadow = [...editedShadow.value]
newEditedShadow[editedSubShadowId.value] = {
...newEditedShadow[editedSubShadowId.value],
[axis]: value
}
editedShadow.value = newEditedShadow
}
exports.isShadowTabOpen = ref(false)
exports.onTabSwitch = (tab) => {
exports.isShadowTabOpen.value = tab === 'shadow'
}
// component preview
exports.editorHintStyle = computed(() => {
const editorHint = selectedComponent.value.editor
const styles = []
if (editorHint && Object.keys(editorHint).length > 0) {
if (editorHint.aspect != null) {
styles.push(`aspect-ratio: ${editorHint.aspect} !important;`)
}
if (editorHint.border != null) {
styles.push(`border-width: ${editorHint.border}px !important;`)
}
}
return styles.join('; ')
})
const editorFriendlyToOriginal = computed(() => {
const resultRules = []
const convert = (component, data = {}, parent) => {
const variants = Object.entries(data || {})
variants.forEach(([variant, variantData]) => {
const states = Object.entries(variantData)
states.forEach(([jointState, stateData]) => {
const state = jointState.split(/:/g)
const result = {
component,
variant,
state,
directives: stateData.directives || {}
}
if (parent) {
result.parent = {
component: parent
}
}
resultRules.push(result)
// Currently we only support single depth for simplicity's sake
if (!parent) {
Object.entries(stateData._children || {}).forEach(([cName, child]) => convert(cName, child, component))
}
})
})
}
[...componentsMap.values()].forEach(({ name }) => {
convert(name, allEditedRules.value[name])
})
return resultRules
})
const allCustomVirtualDirectives = [...componentsMap.values()]
.map(c => {
return c
.defaultRules
.filter(c => c.component === 'Root')
.map(x => Object.entries(x.directives))
.flat()
})
.filter(x => x)
.flat()
.map(([name, value]) => {
const [valType, valVal] = value.split('|')
return {
name: name.substring(2),
valType: valType?.trim(),
value: valVal?.trim()
}
})
const virtualDirectives = ref(allCustomVirtualDirectives)
exports.virtualDirectives = virtualDirectives
exports.updateVirtualDirectives = (value) => {
virtualDirectives.value = value
}
// Raw format
const virtualDirectivesRule = computed(() => ({
component: 'Root',
directives: Object.fromEntries(
virtualDirectives.value.map(vd => [`--${vd.name}`, `${vd.valType} | ${vd.value}`])
)
}))
// Text format
const virtualDirectivesOut = computed(() => {
return [
'Root {',
...virtualDirectives.value
.filter(vd => vd.name && vd.valType && vd.value)
.map(vd => ` --${vd.name}: ${vd.valType} | ${vd.value};`),
'}'
].join('\n')
})
exports.computeColor = (color) => {
let computedColor
try {
computedColor = findColor(color, { dynamicVars: dynamicVars.value, staticVars: staticVars.value })
if (computedColor) {
return rgb2hex(computedColor)
}
} catch (e) {
console.warn(e)
}
return null
}
provide('computeColor', exports.computeColor)
exports.contrast = computed(() => {
return getContrast(
exports.computeColor(previewColors.value.background),
exports.computeColor(previewColors.value.text)
)
})
// ## Export and Import
const styleExporter = newExporter({
filename: () => exports.name.value ?? 'pleroma_theme',
mime: 'text/plain',
extension: 'iss',
getExportedObject: () => exportStyleData.value
})
const onImport = parsed => {
const editorComponents = parsed.filter(x => x.component.startsWith('@'))
const rootComponent = parsed.find(x => x.component === 'Root')
const rules = parsed.filter(x => !x.component.startsWith('@') && x.component !== 'Root')
const metaIn = editorComponents.find(x => x.component === '@meta').directives
const palettesIn = editorComponents.filter(x => x.component === '@palette')
exports.name.value = metaIn.name
exports.license.value = metaIn.license
exports.author.value = metaIn.author
exports.website.value = metaIn.website
const newVirtualDirectives = Object
.entries(rootComponent.directives)
.map(([name, value]) => {
const [valType, valVal] = value.split('|').map(x => x.trim())
return { name: name.substring(2), valType, value: valVal }
})
virtualDirectives.value = newVirtualDirectives
onPalettesUpdate(palettesIn.map(x => ({ name: x.variant, ...x.directives })))
allEditedRules.value = rulesToEditorFriendly(rules)
exports.updateOverallPreview()
}
const styleImporter = newImporter({
accept: '.iss',
parser (string) { return deserialize(string) },
onImportFailure (result) {
console.error('Failure importing style:', result)
useInterfaceStore().pushGlobalNotice({ messageKey: 'settings.invalid_theme_imported', level: 'error' })
},
onImport
})
// Raw format
const exportRules = computed(() => [
metaRule.value,
...palettesRule.value,
virtualDirectivesRule.value,
...editorFriendlyToOriginal.value
])
// Text format
const exportStyleData = computed(() => {
return [
metaOut.value,
palettesOut.value,
virtualDirectivesOut.value,
serialize(editorFriendlyToOriginal.value)
].join('\n\n')
})
exports.clearStyle = () => {
onImport(interfaceStore.styleDataUsed)
}
exports.exportStyle = () => {
styleExporter.exportData()
}
exports.importStyle = () => {
styleImporter.importData()
}
exports.applyStyle = () => {
useInterfaceStore().setStyleCustom(exportRules.value)
}
const overallPreviewRules = ref([])
exports.overallPreviewRules = overallPreviewRules
watch([overallPreviewRules], () => {
let css = null
try {
css = getCssRules(overallPreviewRules.value).map(r => r.replace('html', '&'))
} catch (e) {
console.error(e)
return
}
const sheet = createStyleSheet('style-tab-overall-preview', 90)
sheet.clear()
sheet.addRule([
'#edited-style-preview {\n',
css.join('\n'),
'\n}'
].join(''))
sheet.ready = true
adoptStyleSheets()
})
const updateOverallPreview = throttle(() => {
try {
overallPreviewRules.value = init({
inputRuleset: [
...exportRules.value,
{
component: 'Root',
directives: Object.fromEntries(
Object
.entries(selectedPalette.value)
.filter(([k, v]) => k && v && k !== 'name')
.map(([k, v]) => [`--${k}`, `color | ${v}`])
)
}
],
ultimateBackgroundColor: '#000000',
debug: true
}).eager
} catch (e) {
console.error('Could not compile preview theme', e)
return null
}
}, 1000)
//
// Apart from "hover" we can't really show how component looks like in
// certain states, so we have to fake them.
const simulatePseudoSelectors = (css, prefix) => css
.replace(prefix, '.preview-block')
.replace(':active', '.preview-active')
.replace(':hover', '.preview-hover')
.replace(':active', '.preview-active')
.replace(':focus', '.preview-focus')
.replace(':focus-within', '.preview-focus-within')
.replace(':disabled', '.preview-disabled')
const previewRules = computed(() => {
const filtered = overallPreviewRules.value.filter(r => {
const componentMatch = r.component === selectedComponentName.value
const parentComponentMatch = r.parent?.component === selectedComponentName.value
if (!componentMatch && !parentComponentMatch) return false
const rule = parentComponentMatch ? r.parent : r
if (rule.component !== selectedComponentName.value) return false
if (rule.variant !== selectedVariant.value) return false
const ruleState = new Set(rule.state.filter(x => x !== 'normal'))
const differenceA = [...ruleState].filter(x => !selectedState.has(x))
const differenceB = [...selectedState].filter(x => !ruleState.has(x))
return (differenceA.length + differenceB.length) === 0
})
const sorted = [...filtered]
.filter(x => x.component === selectedComponentName.value)
.sort((a, b) => {
const aSelectorLength = a.selector.split(/ /g).length
const bSelectorLength = b.selector.split(/ /g).length
return aSelectorLength - bSelectorLength
})
const prefix = sorted[0].selector
return filtered.filter(x => x.selector.startsWith(prefix))
})
exports.previewClass = computed(() => {
const selectors = []
if (!!selectedComponent.value.variants?.normal || selectedVariant.value !== 'normal') {
selectors.push(selectedComponent.value.variants[selectedVariant.value])
}
if (selectedState.size > 0) {
selectedState.forEach(state => {
const original = selectedComponent.value.states[state]
selectors.push(simulatePseudoSelectors(original))
})
}
return selectors.map(x => x.substring(1)).join('')
})
exports.previewCss = computed(() => {
try {
const prefix = previewRules.value[0].selector
const scoped = getCssRules(previewRules.value).map(x => simulatePseudoSelectors(x, prefix))
return scoped.join('\n')
} catch (e) {
console.error('Invalid ruleset', e)
return null
}
})
const dynamicVars = computed(() => {
return previewRules.value[0].dynamicVars
})
const staticVars = computed(() => {
const rootComponent = overallPreviewRules.value.find(r => {
return r.component === 'Root'
})
const rootDirectivesEntries = Object.entries(rootComponent.directives)
const directives = {}
rootDirectivesEntries
.filter(([k, v]) => k.startsWith('--') && v.startsWith('color | '))
.map(([k, v]) => [k.substring(2), v.substring('color | '.length)])
.forEach(([k, v]) => {
directives[k] = findColor(v, { dynamicVars: {}, staticVars: directives })
})
return directives
})
provide('staticVars', staticVars)
exports.staticVars = staticVars
const previewColors = computed(() => {
const stacked = dynamicVars.value.stacked
const background = typeof stacked === 'string' ? stacked : rgb2hex(stacked)
return {
text: previewRules.value.find(r => r.component === 'Text')?.virtualDirectives['--text'],
link: previewRules.value.find(r => r.component === 'Link')?.virtualDirectives['--link'],
border: previewRules.value.find(r => r.component === 'Border')?.virtualDirectives['--border'],
icon: previewRules.value.find(r => r.component === 'Icon')?.virtualDirectives['--icon'],
background
}
})
exports.previewColors = previewColors
exports.updateOverallPreview = updateOverallPreview
updateOverallPreview()
watch(
[
allEditedRules.value,
palettes,
selectedPalette,
selectedState,
selectedVariant
],
updateOverallPreview
)
return exports
}
}
diff --git a/src/components/settings_modal/tabs/style_tab/style_tab.scss b/src/components/settings_modal/tabs/style_tab/style_tab.scss
index 545f6b117a..57d81cf70f 100644
--- a/src/components/settings_modal/tabs/style_tab/style_tab.scss
+++ b/src/components/settings_modal/tabs/style_tab/style_tab.scss
@@ -1,266 +1,290 @@
.StyleTab {
min-width: var(--themeEditorMinWidth, fit-content);
.style-control {
- display: flex;
- flex-wrap: wrap;
align-items: baseline;
margin-bottom: 0.5em;
.label {
+ display: inline-block;
margin-right: 0.5em;
- flex: 1 1 0;
+ flex: 1 1 auto;
line-height: 2;
min-height: 2em;
}
&.suboption {
margin-left: 1em;
}
- .color-input {
- flex: 0 0 0;
- }
-
input,
select {
min-width: 3em;
margin: 0;
flex: 0;
&[type="number"] {
min-width: 9em;
&.-small {
min-width: 5em;
}
}
&[type="range"] {
flex: 1;
min-width: 9em;
align-self: center;
margin: 0 0.25em;
}
&[type="checkbox"] + i {
height: 1.1em;
align-self: center;
}
}
}
+ .setting-item.heading {
+ padding: 0;
+ }
+
.meta-preview {
display: grid;
grid-template:
- "meta meta preview preview"
- "meta meta preview preview"
- "meta meta preview preview"
- "meta meta preview preview";
- grid-gap: 0.5em;
- grid-template-columns: min-content min-content 6fr max-content;
+ "meta preview";
+ grid-gap: 1em;
+ grid-template-columns: min-content 6fr;
+
+ .theme-preview-container {
+ margin: 0;
+ }
ul.setting-list {
padding: 0;
margin: 0;
- display: grid;
- grid-template-rows: subgrid;
grid-area: meta;
> li {
margin: 0;
}
.meta-field {
margin: 0;
.setting-label {
display: inline-block;
margin-bottom: 0.5em;
}
}
}
#edited-style-preview {
grid-area: preview;
}
}
.setting-item {
padding-bottom: 0;
.btn {
padding: 0 0.5em;
}
&:not(:first-child) {
margin-top: 0.5em;
}
&:not(:last-child) {
margin-bottom: 0.5em;
}
}
.list-editor {
display: grid;
grid-template-areas:
"label editor"
"selector editor"
"movement editor";
grid-template-columns: 10em 1fr;
grid-template-rows: auto 1fr auto;
grid-gap: 0.5em;
.list-edit-area {
grid-area: editor;
+ align-items: baseline;
}
.list-select {
grid-area: selector;
margin: 0;
&-label {
font-weight: bold;
grid-area: label;
margin: 0;
align-self: baseline;
}
&-movement {
grid-area: movement;
margin: 0;
}
}
}
- .palette-editor {
- width: min-content;
-
- .list-edit-area {
- display: grid;
- align-self: baseline;
- grid-template-rows: subgrid;
- grid-template-columns: 1fr;
- }
-
- .palette-editor-single {
- grid-row: 2 / span 2;
- }
- }
-
.variables-editor {
.variable-selector {
display: grid;
grid-template-columns: auto 1fr auto 10em;
grid-template-rows: subgrid;
align-items: baseline;
grid-gap: 0 0.5em;
}
.list-edit-area {
display: grid;
grid-template-rows: subgrid;
}
.shadow-control {
grid-row: 2 / span 2;
}
}
.component-editor {
display: grid;
grid-template-columns: 6fr 3fr 4fr;
grid-template-rows: auto auto 1fr;
grid-gap: 0.5em;
grid-template-areas:
"component component variant"
"state state state"
"preview settings settings";
.component-selector {
grid-area: component;
align-self: center;
}
.component-selector,
.state-selector,
.variant-selector {
display: grid;
grid-template-columns: 1fr minmax(10em, 1fr);
grid-template-rows: auto;
grid-auto-flow: column;
grid-gap: 0.5em;
align-items: baseline;
> label:not(.Select) {
font-weight: bold;
justify-self: right;
}
}
.state-selector {
grid-area: state;
grid-template-columns: minmax(min-content, 7em) 1fr;
}
.variant-selector {
grid-area: variant;
}
.state-selector-list {
display: grid;
list-style: none;
grid-auto-flow: dense;
grid-template-columns: repeat(5, minmax(min-content, 1fr));
grid-auto-rows: 1fr;
grid-gap: 0.5em;
padding: 0;
margin: 0;
}
.preview-container {
--border: none;
--shadow: none;
--roundness: none;
grid-area: preview;
}
.component-settings {
grid-area: settings;
}
.editor-tab {
display: grid;
grid-template-columns: 1fr 2em;
grid-column-gap: 0.5em;
align-items: center;
grid-auto-rows: min-content;
grid-auto-flow: dense;
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
padding: 0.5em;
}
.shadow-tab {
grid-template-columns: 1fr;
justify-items: center;
}
}
+
+ .-mobile & {
+ .meta-preview {
+ grid-template:
+ "meta"
+ "preview"
+ }
+
+ .list-editor {
+ display: grid;
+ grid-template-areas:
+ "label"
+ "selector"
+ "movement"
+ "editor"
+ "editor"
+ "editor"
+ "editor";
+ grid-template-columns: 1fr;
+ grid-template-rows: auto 1fr auto;
+ }
+
+ .component-editor {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto;
+ grid-template-areas:
+ "component"
+ "variant"
+ "state"
+ "preview"
+ "settings";
+ }
+
+ .variable-selector {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto;
+ grid-auto-flow: row;
+ grid-gap: 0.5em;
+ }
+ }
}
.extra-content {
.style-actions-container {
width: 100%;
display: flex;
justify-content: end;
.style-actions {
display: grid;
grid-template-columns: repeat(4, minmax(7em, 1fr));
grid-gap: 0.25em;
}
}
}
diff --git a/src/components/settings_modal/tabs/style_tab/style_tab.vue b/src/components/settings_modal/tabs/style_tab/style_tab.vue
index a386822ee9..0ced0baf4f 100644
--- a/src/components/settings_modal/tabs/style_tab/style_tab.vue
+++ b/src/components/settings_modal/tabs/style_tab/style_tab.vue
@@ -1,388 +1,390 @@
<script src="./style_tab.js">
</script>
<template>
<div class="StyleTab">
<div class="setting-item heading">
- <h2> {{ $t('settings.style.themes3.editor.title') }} </h2>
<div class="meta-preview">
<Preview id="edited-style-preview" />
<teleport
v-if="isActive"
to="#unscrolled-content"
>
<div class="style-actions-container">
<div class="style-actions">
<button
class="btn button-default button-new"
@click="clearStyle"
>
<FAIcon icon="arrows-rotate" />
{{ $t('settings.style.themes3.editor.reset_style') }}
</button>
<button
class="btn button-default button-load"
@click="importStyle"
>
<FAIcon icon="folder-open" />
{{ $t('settings.style.themes3.editor.load_style') }}
</button>
<button
class="btn button-default button-save"
@click="exportStyle"
>
<FAIcon icon="floppy-disk" />
{{ $t('settings.style.themes3.editor.save_style') }}
</button>
<button
class="btn button-default button-apply"
@click="applyStyle"
>
<FAIcon icon="check" />
{{ $t('settings.style.themes3.editor.apply_preview') }}
</button>
</div>
</div>
</teleport>
<ul class="setting-list style-metadata">
<li>
<StringSetting
v-model="name"
class="meta-field"
>
{{ $t('settings.style.themes3.editor.style_name') }}
</StringSetting>
</li>
<li>
<StringSetting
v-model="author"
class="meta-field"
>
{{ $t('settings.style.themes3.editor.style_author') }}
</StringSetting>
</li>
<li>
<StringSetting
v-model="license"
class="meta-field"
>
{{ $t('settings.style.themes3.editor.style_license') }}
</StringSetting>
</li>
<li>
<StringSetting
v-model="website"
class="meta-field"
>
{{ $t('settings.style.themes3.editor.style_website') }}
</StringSetting>
</li>
</ul>
</div>
</div>
<tab-switcher>
<div
key="component"
class="setting-item component-editor"
:label="$t('settings.style.themes3.editor.component_tab')"
+ :full-width="true"
>
<div class="component-selector">
<label for="component-selector">
{{ $t('settings.style.themes3.editor.component_selector') }}
{{ ' ' }}
</label>
<Select
id="component-selector"
v-model="selectedComponentKey"
>
<option
v-for="key in componentKeys"
:key="'component-' + key"
:value="key"
>
{{ componentsMap.get(key).name }}
</option>
</Select>
</div>
<div
v-if="selectedComponentVariants.length > 1"
class="variant-selector"
>
<label for="variant-selector">
{{ $t('settings.style.themes3.editor.variant_selector') }}
</label>
<Select
v-model="selectedVariant"
>
<option
v-for="variant in selectedComponentVariants"
:key="'component-variant-' + variant"
:value="variant"
>
{{ variant }}
</option>
</Select>
</div>
<div
v-if="selectedComponentStates.length > 0"
class="state-selector"
>
<label>
{{ $t('settings.style.themes3.editor.states_selector') }}
</label>
<ul
class="state-selector-list"
>
<li
v-for="state in selectedComponentStates"
:key="'component-state-' + state"
>
<Checkbox
:value="selectedState.has(state)"
@update:model-value="(v) => updateSelectedStates(state, v)"
>
{{ state }}
</Checkbox>
</li>
</ul>
</div>
<div class="preview-container">
<ComponentPreview
class="component-preview"
:show-text="componentHas('Text')"
:shadow-control="isShadowTabOpen"
:preview-class="previewClass"
:preview-style="editorHintStyle"
:preview-css="previewCss"
:disabled="!editedSubShadow && typeof editedShadow !== 'string'"
:shadow="editedSubShadow"
:no-color-control="true"
@update:shadow="({ axis, value }) => updateSubShadow(axis, value)"
/>
</div>
<tab-switcher
ref="tabSwitcher"
class="component-settings"
:on-switch="onTabSwitch"
>
<div
key="main"
class="editor-tab"
:label="$t('settings.style.themes3.editor.main_tab')"
>
<ColorInput
v-model="editedBackgroundColor"
name="component-background-color"
:fallback="computeColor(editedBackgroundColor) ?? previewColors.background"
:disabled="!isBackgroundColorPresent"
:label="$t('settings.style.themes3.editor.background')"
:hide-optional-checkbox="true"
/>
<Tooltip :text="$t('settings.style.themes3.editor.include_in_rule')">
<Checkbox v-model="isBackgroundColorPresent" />
</Tooltip>
<ColorInput
v-if="componentHas('Text')"
v-model="editedTextColor"
name="component-text-color"
:fallback="computeColor(editedTextColor) ?? previewColors.text"
:label="$t('settings.style.themes3.editor.text_color')"
:disabled="!isTextColorPresent"
:hide-optional-checkbox="true"
/>
<Tooltip
v-if="componentHas('Text')"
:text="$t('settings.style.themes3.editor.include_in_rule')"
>
<Checkbox v-model="isTextColorPresent" />
</Tooltip>
<div
v-if="componentHas('Text')"
class="style-control suboption"
>
<label
for="textAuto"
class="label"
:class="{ faint: !isTextAutoPresent }"
>
{{ $t('settings.style.themes3.editor.text_auto.label') }}
</label>
<Select
id="textAuto"
v-model="editedTextAuto"
:disabled="!isTextAutoPresent"
>
<option value="no-preserve">
{{ $t('settings.style.themes3.editor.text_auto.no-preserve') }}
</option>
<option value="no-auto">
{{ $t('settings.style.themes3.editor.text_auto.no-auto') }}
</option>
<option value="preserve">
{{ $t('settings.style.themes3.editor.text_auto.preserve') }}
</option>
</Select>
</div>
<Tooltip
v-if="componentHas('Text')"
:text="$t('settings.style.themes3.editor.include_in_rule')"
>
<Checkbox v-model="isTextAutoPresent" />
</Tooltip>
<div
v-if="componentHas('Text')"
class="style-control suboption"
>
<label class="label">
{{ $t('settings.style.themes3.editor.contrast') }}
</label>
<ContrastRatio
:show-ratio="true"
:contrast="contrast"
/>
</div>
<div v-if="componentHas('Text')" />
<ColorInput
v-if="componentHas('Link')"
v-model="editedLinkColor"
name="component-link-color"
:fallback="computeColor(editedLinkColor) ?? previewColors.link"
:label="$t('settings.style.themes3.editor.link_color')"
:disabled="!isLinkColorPresent"
:hide-optional-checkbox="true"
/>
<Tooltip
v-if="componentHas('Link')"
:text="$t('settings.style.themes3.editor.include_in_rule')"
>
<Checkbox v-model="isLinkColorPresent" />
</Tooltip>
<ColorInput
v-if="componentHas('Icon')"
v-model="editedIconColor"
name="component-icon-color"
:fallback="computeColor(editedIconColor) ?? previewColors.icon"
:label="$t('settings.style.themes3.editor.icon_color')"
:disabled="!isIconColorPresent"
:hide-optional-checkbox="true"
/>
<Tooltip
v-if="componentHas('Icon')"
:text="$t('settings.style.themes3.editor.include_in_rule')"
>
<Checkbox v-model="isIconColorPresent" />
</Tooltip>
<ColorInput
v-if="componentHas('Border')"
v-model="editedBorderColor"
name="component-border-color"
:fallback="computeColor(editedBorderColor) ?? previewColors.border"
:label="$t('settings.style.themes3.editor.border_color')"
:disabled="!isBorderColorPresent"
:hide-optional-checkbox="true"
/>
<Tooltip
v-if="componentHas('Border')"
:text="$t('settings.style.themes3.editor.include_in_rule')"
>
<Checkbox v-model="isBorderColorPresent" />
</Tooltip>
<OpacityInput
v-model="editedOpacity"
name="component-opacity"
:disabled="!isOpacityPresent"
:label="$t('settings.style.themes3.editor.opacity')"
/>
<Tooltip :text="$t('settings.style.themes3.editor.include_in_rule')">
<Checkbox v-model="isOpacityPresent" />
</Tooltip>
<RoundnessInput
v-model="editedRoundness"
name="component-roundness"
:disabled="!isRoundnessPresent"
:label="$t('settings.style.themes3.editor.roundness')"
/>
<Tooltip :text="$t('settings.style.themes3.editor.include_in_rule')">
<Checkbox v-model="isRoundnessPresent" />
</Tooltip>
</div>
<div
key="shadow"
class="editor-tab shadow-tab"
:label="$t('settings.style.themes3.editor.shadows_tab')"
>
<Checkbox
v-model="isShadowPresent"
class="style-control"
>
{{ $t('settings.style.themes3.editor.include_in_rule') }}
</checkbox>
<ShadowControl
v-model="editedShadow"
:disabled="!isShadowPresent"
:no-preview="true"
:compact="true"
:static-vars="staticVars"
@sub-shadow-selected="onSubShadow"
/>
</div>
</tab-switcher>
</div>
<div
key="palette"
:label="$t('settings.style.themes3.editor.palette_tab')"
class="setting-item list-editor palette-editor"
+ :full-width="true"
>
<label
class="list-select-label"
for="palette-selector"
>
{{ $t('settings.style.themes3.palette.label') }}
{{ ' ' }}
</label>
<Select
id="palette-selector"
v-model="selectedPaletteId"
class="list-select"
size="4"
>
<option
v-for="(p, index) in palettes"
:key="p.name"
:value="index"
>
{{ p.name }}
</option>
</Select>
<SelectMotion
class="list-select-movement"
:model-value="palettes"
:selected-id="selectedPaletteId"
:get-add-value="getNewPalette"
@update:model-value="onPalettesUpdate"
@update:selected-id="e => selectedPaletteId = e"
/>
<div class="list-edit-area">
<StringSetting
v-model="selectedPalette.name"
class="palette-name-input"
>
{{ $t('settings.style.themes3.palette.name_label') }}
</StringSetting>
<PaletteEditor
v-model="selectedPalette"
class="palette-editor-single"
/>
</div>
</div>
<VirtualDirectivesTab
key="variables"
:label="$t('settings.style.themes3.editor.variables_tab')"
:model-value="virtualDirectives"
+ :full-width="true"
@update:model-value="updateVirtualDirectives"
/>
</tab-switcher>
</div>
</template>
<style src="./style_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
deleted file mode 100644
index 20a671735c..0000000000
--- a/src/components/settings_modal/tabs/version_tab.js
+++ /dev/null
@@ -1,19 +0,0 @@
-const pleromaFeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma-fe/commit/'
-
-const VersionTab = {
- data () {
- const instance = this.$store.state.instance
- return {
- backendVersion: instance.backendVersion,
- backendRepository: instance.backendRepository,
- frontendVersion: instance.frontendVersion
- }
- },
- computed: {
- frontendVersionLink () {
- return pleromaFeCommitUrl + this.frontendVersion
- }
- }
-}
-
-export default VersionTab
diff --git a/src/components/settings_modal/tabs/version_tab.vue b/src/components/settings_modal/tabs/version_tab.vue
deleted file mode 100644
index 917a618e63..0000000000
--- a/src/components/settings_modal/tabs/version_tab.vue
+++ /dev/null
@@ -1,31 +0,0 @@
-<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="backendRepository"
- target="_blank"
- >{{ backendVersion }}</a>
- </li>
- </ul>
- </li>
- <li>
- <p>{{ $t('settings.version.frontend_version') }}</p>
- <ul class="option-list">
- <li>
- <a
- :href="frontendVersionLink"
- target="_blank"
- >{{ frontendVersion }}</a>
- </li>
- </ul>
- </li>
- </ul>
- </div>
- </div>
-</template>
-<script src="./version_tab.js" />
diff --git a/src/components/shadow_control/shadow_control.scss b/src/components/shadow_control/shadow_control.scss
index d169ea234c..0fb7386ec5 100644
--- a/src/components/shadow_control/shadow_control.scss
+++ b/src/components/shadow_control/shadow_control.scss
@@ -1,121 +1,124 @@
.ShadowControl {
display: grid;
grid-template-columns: 10em 1fr 1fr;
grid-template-rows: 1fr;
grid-template-areas: "selector preview tweak";
grid-gap: 0.5em;
justify-content: stretch;
+ .style-control {
+ display: flex;
+ }
+
&.-compact {
grid-template-columns: 10em 1fr;
grid-template-rows: auto auto;
grid-template-areas:
"selector preview"
"tweak tweak";
&.-no-preview {
grid-template-columns: 1fr;
grid-template-rows: 10em 1fr;
grid-template-areas:
"selector"
"tweak";
}
}
.shadow-switcher {
grid-area: selector;
order: 1;
flex: 1 0 6em;
min-width: 6em;
margin-right: 0.125em;
display: flex;
flex-direction: column;
.shadow-list {
flex: 1 0 auto;
}
}
.shadow-tweak {
grid-area: tweak;
order: 3;
flex: 2 0 10em;
min-width: 10em;
margin-left: 0.125em;
margin-right: 0.125em;
display: grid;
grid-template-rows: auto 1fr;
grid-gap: 0.25em;
/* hack */
.input-boolean {
flex: 1;
display: flex;
.label {
flex: 1;
}
}
.input-string {
flex: 1 0 5em;
}
.shadow-expression {
width: 100%;
height: 100%;
}
.id-control {
align-items: stretch;
.shadow-switcher,
.btn {
min-width: 1px;
margin-right: 5px;
}
.btn {
padding: 0 0.4em;
margin: 0 0.1em;
}
}
}
&.-no-preview {
grid-template-columns: 10em 1fr;
grid-template-rows: 1fr;
grid-template-areas: "selector tweak";
.shadow-tweak {
order: 0;
flex: 2 0 8em;
max-width: 100%;
}
.input-range {
min-width: 5em;
}
}
.inset-alert {
padding: 0.25em 0.5em;
}
&.disabled {
.inset-alert {
opacity: 0.2;
}
}
.shadow-preview {
grid-area: preview;
- min-width: 25em;
margin-left: 0.125em;
place-self: start center;
}
}
.inset-tooltip {
max-width: 30em;
}
diff --git a/src/components/shadow_control/shadow_control.vue b/src/components/shadow_control/shadow_control.vue
index f34741797d..4fdae42076 100644
--- a/src/components/shadow_control/shadow_control.vue
+++ b/src/components/shadow_control/shadow_control.vue
@@ -1,238 +1,239 @@
<template>
<div
class="ShadowControl label shadow-control"
:class="{ disabled: disabled || !present, '-no-preview': noPreview, '-compact': compact }"
>
<ComponentPreview
v-if="!noPreview"
:invalid="invalid"
class="shadow-preview"
:shadow-control="true"
:shadow="selected"
:preview-style="style"
:disabled="disabled || !present"
@update:shadow="({ axis, value }) => updateProperty(axis, value)"
/>
<div class="shadow-switcher">
<Select
id="shadow-list"
v-model="selectedId"
class="shadow-list"
size="4"
:disabled="disabled || shadowsAreNull"
>
<option
v-for="(shadow, index) in cValue"
:key="index"
:value="index"
:class="{ '-active': index === Number(selectedId) }"
>
{{ getSubshadowLabel(shadow, index) }}
</option>
</Select>
<SelectMotion
v-model="cValue"
:selected-id="selectedId"
:get-add-value="getNewSubshadow"
:disabled="disabled"
@update:selected-id="onSelectChange"
/>
</div>
<div class="shadow-tweak">
<Select
v-model="selectedType"
:disabled="disabled || !present"
>
<option value="object">
{{ $t('settings.style.shadows.raw') }}
</option>
<option value="string">
{{ $t('settings.style.shadows.expression') }}
</option>
</Select>
<template v-if="selectedType === 'string'">
<textarea
v-model="selected"
class="input shadow-expression"
:disabled="disabled || shadowsAreNull"
:class="{disabled: disabled || shadowsAreNull}"
/>
</template>
<template v-else-if="selectedType === 'object'">
<div
:class="{ disabled: disabled || !present }"
class="name-control style-control"
>
<label
for="name"
class="label"
:class="{ faint: disabled || !present }"
>
{{ $t('settings.style.shadows.name') }}
</label>
<input
id="name"
:value="selected?.name"
:disabled="disabled || !present"
:class="{ disabled: disabled || !present }"
name="name"
class="input input-string"
@input="e => updateProperty('name', e.target.value)"
>
</div>
<div
:disabled="disabled || !present"
class="inset-control style-control"
>
<Checkbox
id="inset"
:value="selected?.inset"
:disabled="disabled || !present"
name="inset"
class="input-inset input-boolean"
@input="e => updateProperty('inset', e.target.checked)"
>
<template #before>
{{ $t('settings.style.shadows.inset') }}
</template>
</Checkbox>
</div>
<div
:disabled="disabled || !present"
:class="{ disabled: disabled || !present }"
class="blur-control style-control"
>
<label
for="blur"
class="label"
:class="{ faint: disabled || !present }"
>
{{ $t('settings.style.shadows.blur') }}
</label>
<input
id="blur"
:value="selected?.blur"
:disabled="disabled || !present"
:class="{ disabled: disabled || !present }"
name="blur"
class="input input-range"
type="range"
max="20"
min="0"
@input="e => updateProperty('blur', e.target.value)"
>
<input
:value="selected?.blur"
class="input input-number -small"
:disabled="disabled || !present"
:class="{ disabled: disabled || !present }"
type="number"
min="0"
@input="e => updateProperty('blur', e.target.value)"
>
</div>
<div
class="spread-control style-control"
:class="{ disabled: disabled || !present || (separateInset && !selected?.inset) }"
>
<label
for="spread"
class="label"
:class="{ faint: disabled || !present || (separateInset && !selected?.inset) }"
>
{{ $t('settings.style.shadows.spread') }}
</label>
<input
id="spread"
:value="selected?.spread"
:class="{ disabled: disabled || !present || (separateInset && !selected?.inset) }"
:disabled="disabled || !present || (separateInset && !selected?.inset)"
name="spread"
class="input input-range"
type="range"
max="20"
min="-20"
@input="e => updateProperty('spread', e.target.value)"
>
<input
:value="selected?.spread"
class="input input-number -small"
:class="{ disabled: disabled || !present || (separateInset && !selected?.inset) }"
:disabled="disabled || !present || (separateInset && !selected?.inset)"
type="number"
@input="e => updateProperty('spread', e.target.value)"
>
</div>
<ColorInput
:model-value="selected?.color"
:disabled="disabled || !present"
:label="$t('settings.style.common.color')"
:fallback="getColorFallback"
+ :compact="true"
:show-optional-checkbox="false"
name="shadow"
@update:model-value="e => updateProperty('color', e)"
/>
<OpacityInput
:model-value="selected?.alpha"
:disabled="disabled || !present"
@update:model-value="e => updateProperty('alpha', e)"
/>
<i18n-t
scope="global"
keypath="settings.style.shadows.hintV3"
:class="{ faint: disabled || !present }"
tag="p"
>
<code>--variable,mod</code>
</i18n-t>
<Popover
v-if="separateInset"
trigger="hover"
>
<template #trigger>
<div
class="inset-alert alert warning"
>
<FAIcon icon="exclamation-triangle" />
&nbsp;
{{ $t('settings.style.shadows.filter_hint.avatar_inset_short') }}
</div>
</template>
<template #content>
<div class="inset-tooltip tooltip">
<i18n-t
scope="global"
keypath="settings.style.shadows.filter_hint.always_drop_shadow"
tag="p"
>
<code>filter: drop-shadow()</code>
</i18n-t>
<p>{{ $t('settings.style.shadows.filter_hint.avatar_inset') }}</p>
<i18n-t
scope="global"
keypath="settings.style.shadows.filter_hint.drop_shadow_syntax"
tag="p"
>
<code>drop-shadow</code>
<code>spread-radius</code>
<code>inset</code>
</i18n-t>
<i18n-t
scope="global"
keypath="settings.style.shadows.filter_hint.inset_classic"
tag="p"
>
<code>box-shadow</code>
</i18n-t>
<p>{{ $t('settings.style.shadows.filter_hint.spread_zero') }}</p>
</div>
</template>
</Popover>
</template>
</div>
</div>
</template>
<script src="./shadow_control.js"></script>
<style src="./shadow_control.scss" lang="scss"></style>
diff --git a/src/components/tab_switcher/tab_switcher.jsx b/src/components/tab_switcher/tab_switcher.jsx
index 02a0b47c04..af30084596 100644
--- a/src/components/tab_switcher/tab_switcher.jsx
+++ b/src/components/tab_switcher/tab_switcher.jsx
@@ -1,189 +1,186 @@
// eslint-disable-next-line no-unused
import { h, Fragment } from 'vue'
import { mapState } from 'pinia'
import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome'
import './tab_switcher.scss'
import { useInterfaceStore } from 'src/stores/interface'
const findFirstUsable = (slots) => slots.findIndex(_ => _.props)
export default {
name: 'TabSwitcher',
props: {
renderOnlyFocused: {
required: false,
type: Boolean,
default: false
},
onSwitch: {
required: false,
type: Function,
default: undefined
},
activeTab: {
required: false,
type: String,
default: undefined
},
scrollableTabs: {
required: false,
type: Boolean,
default: false
},
- sideTabBar: {
- required: false,
- type: Boolean,
- default: false
- },
bodyScrollLock: {
required: false,
type: Boolean,
default: false
}
},
data () {
return {
active: findFirstUsable(this.slots())
}
},
computed: {
activeIndex () {
// In case of controlled component
if (this.activeTab) {
return this.slots().findIndex(slot => slot && slot.props && this.activeTab === slot.props.key)
} else {
return this.active
}
},
isActive () {
return tabName => {
const isWanted = slot => slot.props && slot.props['data-tab-name'] === tabName
return this.$slots.default().findIndex(isWanted) === this.activeIndex
}
}
},
beforeUpdate () {
const currentSlot = this.slots()[this.active]
if (!currentSlot.props) {
this.active = findFirstUsable(this.slots())
}
},
methods: {
clickTab (index) {
return (e) => {
e.preventDefault()
this.setTab(index)
}
},
// DO NOT put it to computed, it doesn't work (caching?)
slots () {
if (this.$slots.default()[0].type === Fragment) {
return this.$slots.default()[0].children
}
return this.$slots.default()
},
setTab (index) {
if (typeof this.onSwitch === 'function') {
this.onSwitch.call(null, this.slots()[index].key)
}
this.active = index
if (this.scrollableTabs) {
this.$refs.contents.scrollTop = 0
}
}
},
render () {
const tabs = this.slots()
.map((slot, index) => {
const props = slot.props
if (!props) return
const classesTab = ['tab']
const classesWrapper = ['tab-wrapper']
if (this.activeIndex === index) {
classesTab.push('active')
classesWrapper.push('active')
}
if (props.image) {
return (
<div class={classesWrapper.join(' ')}>
<button
disabled={props.disabled}
onClick={this.clickTab(index)}
class={classesTab.join(' ')}
type="button"
role="tab"
>
<img src={props.image} title={props['image-tooltip']}/>
{props.label ? '' : props.label}
</button>
</div>
)
}
return (
<div class={classesWrapper.join(' ')}>
<button
disabled={props.disabled}
onClick={this.clickTab(index)}
class={classesTab.join(' ')}
type="button"
role="tab"
>
{!props.icon ? '' : (<FAIcon class="tab-icon" size="2x" fixed-width icon={props.icon}/>)}
<span class="text">
{props.label}
</span>
</button>
</div>
)
})
const contents = this.slots().map((slot, index) => {
const props = slot.props
if (!props) return
const active = this.activeIndex === index
const classes = [ active ? 'active' : 'hidden' ]
- if (props.fullHeight) {
- classes.push('full-height')
+ if (props.fullHeight || props['full-height']) {
+ classes.push('-full-height')
+ }
+ if (props.fullWidth || props['full-width']) {
+ classes.push('-full-width')
}
let delayRender = slot.props['delay-render']
if (delayRender && active) {
slot.props['delay-render'] = false
delayRender = false
}
const renderSlot = (!delayRender && (!this.renderOnlyFocused || active))
? slot
: ''
return (
<div class={classes}>
- {
- this.sideTabBar
- ? <h1 class="mobile-label">{props.label}</h1>
- : ''
- }
{renderSlot}
</div>
)
})
return (
- <div class={'tab-switcher ' + (this.sideTabBar ? 'side-tabs' : 'top-tabs')}>
+ <div
+ class="tab-switcher top-tabs"
+ ref="root"
+ >
<div
class="tabs"
role="tablist"
+ ref="nav"
>
{tabs}
</div>
<div
- ref="contents"
role="tabpanel"
class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}
v-body-scroll-lock={this.bodyScrollLock}
+ ref="content"
>
{contents}
</div>
</div>
)
}
}
diff --git a/src/components/tab_switcher/tab_switcher.scss b/src/components/tab_switcher/tab_switcher.scss
index b7274d86d6..f788ea30ef 100644
--- a/src/components/tab_switcher/tab_switcher.scss
+++ b/src/components/tab_switcher/tab_switcher.scss
@@ -1,250 +1,161 @@
/* stylelint-disable no-descending-specificity */
.tab-switcher {
display: flex;
.tab-icon {
margin: 0.2em auto;
display: block;
}
&.top-tabs {
flex-direction: column;
> .tabs {
width: 100%;
overflow: auto hidden;
padding-top: 5px;
flex-direction: row;
flex: 0 0 auto;
&::after,
&::before {
content: "";
flex: 1 1 auto;
border-bottom: 1px solid;
border-bottom-color: var(--border);
}
.tab-wrapper {
height: 2em;
&:not(.active)::after {
left: 0;
right: 0;
bottom: 0;
border-bottom: 1px solid;
border-bottom-color: var(--border);
}
}
.tab {
width: 100%;
min-width: 1px;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
padding-bottom: 99px;
margin-bottom: calc(6px - 99px);
}
}
.contents.scrollable-tabs {
flex-basis: 0;
}
}
- &.side-tabs {
- flex-direction: row;
-
- @media all and (width <= 800px) {
- overflow-x: auto;
- }
-
- > .contents {
- flex: 1 1 auto;
- }
-
- > .tabs {
- flex: 0 0 auto;
- overflow: hidden auto;
- flex-direction: column;
-
- &::after,
- &::before {
- flex-shrink: 0;
- flex-basis: 0.5em;
- content: "";
- border-right: 1px solid;
- border-right-color: var(--border);
- }
-
- &::after {
- flex-grow: 1;
- }
-
- &::before {
- flex-grow: 0;
- }
-
- .tab-wrapper {
- min-width: 10em;
- display: flex;
- flex-direction: column;
-
- @media all and (width <= 800px) {
- min-width: 4em;
- }
-
- &:not(.active)::after {
- top: 0;
- right: 0;
- bottom: 0;
- border-right: 1px solid;
- border-right-color: var(--border);
- }
-
- &::before {
- flex: 0 0 6px;
- content: "";
- border-right: 1px solid;
- border-right-color: var(--border);
- }
-
- &:last-child .tab {
- margin-bottom: 0;
- }
- }
-
- .tab {
- flex: 1;
- box-sizing: content-box;
- max-width: 9em;
- min-width: 1px;
- border-top-right-radius: 0;
- border-bottom-right-radius: 0;
- padding-left: 1em;
- padding-right: calc(1em + 200px);
- margin-right: -200px;
- margin-left: 1em;
-
- &:not(.active) {
- margin-top: 0;
- margin-left: 1.5em;
- }
-
- @media all and (width <= 800px) {
- padding-left: 0.25em;
- padding-right: calc(0.25em + 200px);
- margin-right: calc(0.25em - 200px);
- margin-left: 0.25em;
-
- &:not(.active) {
- margin-top: 0;
- margin-left: 0.5em;
- }
-
- .text {
- display: none;
- }
- }
- }
- }
- }
-
.contents {
flex: 1 0 auto;
min-height: 0;
.hidden {
display: none;
}
- .full-height:not(.hidden) {
+ .-full-height:not(.hidden) {
height: 100%;
display: flex;
flex-direction: column;
> *:not(.mobile-label) {
flex: 1;
}
}
+ .-full-width:not(.hidden) {
+ display: flex;
+ flex-direction: column;
+
+ > *:not(.mobile-label) {
+ width: auto;
+ }
+ }
+
&.scrollable-tabs {
overflow-y: auto;
}
}
.tab {
user-select: none;
color: var(--text);
border: none;
cursor: pointer;
box-shadow: var(--shadow);
font-size: 1em;
font-family: var(--font);
border-radius: var(--roundness);
background-color: var(--background);
position: relative;
white-space: nowrap;
padding: 6px 1em;
&:not(.active) {
z-index: 4;
margin-top: 0.25em;
&:hover {
z-index: 6;
}
}
&.active {
background: transparent;
z-index: 5;
}
img {
max-height: 1.9em;
vertical-align: top;
margin-top: -0.3em;
}
}
.tabs {
display: flex;
position: relative;
box-sizing: border-box;
&::after,
&::before {
display: block;
flex: 1 1 auto;
}
}
.tab-wrapper {
position: relative;
display: flex;
flex: 0 0 auto;
&:not(.active) {
&::after {
content: "";
position: absolute;
z-index: 7;
}
}
}
.mobile-label {
padding-left: 0.3em;
padding-bottom: 0.25em;
margin-top: 0.5em;
margin-left: 0.2em;
margin-bottom: 0.25em;
border-bottom: 1px solid var(--border);
@media all and (width >= 800px) {
display: none;
}
}
}
/* stylelint-enable no-descending-specificity */
diff --git a/src/i18n/en.json b/src/i18n/en.json
index bef24b7e58..e1f5fb23df 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -1,1667 +1,1698 @@
{
"about": {
"mrf": {
"federation": "Federation",
"keyword": {
"keyword_policies": "Keyword policies",
"ftl_removal": "Removal from \"The Whole Known Network\" Timeline",
"reject": "Reject",
"replace": "Replace",
"is_replaced_by": "→"
},
"mrf_policies": "Enabled MRF policies",
"mrf_policies_desc": "MRF policies manipulate the federation behaviour of the instance. The following policies are enabled:",
"simple": {
"simple_policies": "Instance-specific policies",
"instance": "Instance",
"reason": "Reason",
"not_applicable": "N/A",
"accept": "Accept",
"accept_desc": "This instance only accepts messages from the following instances:",
"reject": "Reject",
"reject_desc": "This instance will not accept messages from the following instances:",
"quarantine": "Quarantine",
"quarantine_desc": "This instance will send only public posts to the following instances:",
"ftl_removal": "Removal from \"Known Network\" Timeline",
"ftl_removal_desc": "This instance removes these instances from \"Known Network\" timeline:",
"media_removal": "Media Removal",
"media_removal_desc": "This instance removes media from posts on the following instances:",
"media_nsfw": "Media force-set as sensitive",
"media_nsfw_desc": "This instance forces media to be set sensitive in posts on the following instances:"
}
},
"staff": "Staff",
"terms": "Terms of Service"
},
"announcements": {
"page_header": "Announcements",
"title": "Announcement",
"mark_as_read_action": "Mark as read",
"post_form_header": "Post announcement",
"post_placeholder": "Type your announcement content here...",
"post_action": "Post",
"post_error": "Error: {error}",
"close_error": "Close",
"delete_action": "Delete",
"start_time_prompt": "Start time: ",
"end_time_prompt": "End time: ",
"all_day_prompt": "This is an all-day event",
"published_time_display": "Published at {time}",
"start_time_display": "Starts at {time}",
"end_time_display": "Ends at {time}",
"edit_action": "Edit",
"submit_edit_action": "Submit",
"cancel_edit_action": "Cancel",
"inactive_message": "This announcement is inactive"
},
"shoutbox": {
"title": "Shoutbox"
},
"domain_mute_card": {
"mute": "Mute",
"mute_progress": "Muting…",
"unmute": "Unmute",
"unmute_progress": "Unmuting…"
},
"exporter": {
"export": "Export",
"processing": "Processing, you'll soon be asked to download your file"
},
"features_panel": {
"shout": "Shoutbox",
"pleroma_chat_messages": "Pleroma Chat",
"gopher": "Gopher",
"media_proxy": "Media proxy",
"scope_options": "Scope options",
"text_limit": "Text limit",
"title": "Features",
"who_to_follow": "Who to follow",
"upload_limit": "Upload limit"
},
"finder": {
"error_fetching_user": "Error fetching user",
"find_user": "Find user"
},
"general": {
"apply": "Apply",
"submit": "Submit",
"more": "More",
"loading": "Loading…",
"generic_error": "An error occured",
"generic_error_message": "An error occured: {0}",
"error_retry": "Please try again",
"retry": "Try again",
"optional": "optional",
"show_more": "Show more",
"show_less": "Show less",
"never_show_again": "Never show again",
"dismiss": "Dismiss",
"cancel": "Cancel",
"disable": "Disable",
"enable": "Enable",
"confirm": "Confirm",
"verify": "Verify",
"close": "Close",
"undo": "Undo",
"yes": "Yes",
"no": "No",
"peek": "Peek",
"scroll_to_top": "Scroll to top",
"role": {
"admin": "Admin",
"moderator": "Moderator"
},
"unpin": "Unpin item",
"pin": "Pin item",
"flash_content": "Click to show Flash content using Ruffle (Experimental, may not work).",
"flash_security": "Note that this can be potentially dangerous since Flash content is still arbitrary code.",
"flash_fail": "Failed to load flash content, see console for details.",
"scope_in_timeline": {
"local": "Non-federated",
"direct": "Direct",
"private": "Followers-only",
"public": "Public",
"unlisted": "Unlisted"
}
},
"image_cropper": {
"crop_picture": "Crop picture",
"save": "Save",
"save_without_cropping": "Save without cropping",
"cancel": "Cancel"
},
"importer": {
"submit": "Submit",
+ "import": "Import",
"success": "Imported successfully.",
"error": "An error occured while importing this file."
},
"login": {
"login": "Log in",
"description": "Log in with OAuth",
"logout": "Log out",
"logout_confirm_title": "Logout confirmation",
"logout_confirm": "Do you really want to logout?",
"logout_confirm_accept_button": "Logout",
"logout_confirm_cancel_button": "Do not logout",
"password": "Password",
"placeholder": "e.g. lain",
"register": "Register",
"username": "Username",
"hint": "Log in to join the discussion",
"authentication_code": "Authentication code",
"enter_recovery_code": "Enter a recovery code",
"enter_two_factor_code": "Enter a two-factor code",
"recovery_code": "Recovery code",
"heading": {
"totp": "Two-factor authentication",
"recovery": "Two-factor recovery"
}
},
"media_modal": {
"previous": "Previous",
"next": "Next",
"counter": "{current} / {total}",
"hide": "Close media viewer"
},
"nav": {
"about": "About",
"administration": "Administration",
"back": "Back",
"friend_requests": "Follow requests",
"mentions": "Mentions",
"interactions": "Interactions",
"dms": "Direct messages",
"public_tl": "Public timeline",
"bubble": "Bubble timeline",
"timeline": "Timeline",
"home_timeline": "Home timeline",
"twkn": "Known Network",
"bookmarks": "Bookmarks",
"all_bookmarks": "All bookmarks",
"bookmark_folders": "Bookmark folders",
"user_search": "User Search",
"search": "Search",
"search_close": "Close search bar",
"who_to_follow": "Who to follow",
"preferences": "Preferences",
"timelines": "Timelines",
"chats": "Chats",
"lists": "Lists",
"edit_nav_mobile": "Customize navigation bar",
"edit_pinned": "Edit pinned items",
"edit_finish": "Done editing",
"mobile_sidebar": "Toggle mobile sidebar",
"mobile_notifications": "Open notifications (there are unread ones)",
"mobile_notifications_close": "Close notifications",
"mobile_notifications_mark_as_seen": "Mark all as seen",
"announcements": "Announcements",
"quotes": "Quotes",
"drafts": "Drafts"
},
"notifications": {
"broken_favorite": "Unknown status, searching for it…",
"error": "Error fetching notifications: {0}",
"favorited_you": "favorited your status",
"followed_you": "followed you",
"follow_request": "wants to follow you",
"load_older": "Load older notifications",
"notifications": "Notifications",
"read": "Read!",
"repeated_you": "repeated your status",
"no_more_notifications": "No more notifications",
"migrated_to": "migrated to",
"reacted_with": "reacted with {0}",
"submitted_report": "submitted a report",
"poll_ended": "poll has ended",
"unread_announcements": "{num} unread announcement | {num} unread announcements",
"unread_chats": "{num} unread chat | {num} unread chats",
"unread_follow_requests": "{num} new follow request | {num} new follow requests",
"configuration_tip": "You can customize what to display here in {theSettings}. {dismiss}",
"configuration_tip_settings": "the settings",
"configuration_tip_dismiss": "Do not show again",
"subscribed_status": "posted"
},
"polls": {
"add_poll": "Add poll",
"add_option": "Add option",
"option": "Option",
"votes": "votes",
"people_voted_count": "{count} person voted | {count} people voted",
"votes_count": "{count} vote | {count} votes",
"vote": "Vote",
"type": "Poll type",
"single_choice": "Single choice",
"multiple_choices": "Multiple choices",
"expiry": "Poll age",
"expires_at": "Poll ends {0}",
"expires_in": "Poll ends in {0}",
"expired": "Poll ended {0} ago",
"expired_at": "Poll ended {0}",
"not_enough_options": "Too few unique options in poll",
"non_anonymous": "Public poll",
"non_anonymous_title": "Other instances may display the options you voted for"
},
"emoji": {
"stickers": "Stickers",
"emoji": "Emoji",
"keep_open": "Keep picker open",
"search_emoji": "Search for an emoji",
"add_emoji": "Insert emoji",
"custom": "Custom emoji",
"hide_custom_emoji": "Hide custom emojis",
"unpacked": "Unpacked emoji",
"unicode": "Unicode emoji",
"unicode_groups": {
"activities": "Activities",
"animals-and-nature": "Animals & Nature",
"flags": "Flags",
"food-and-drink": "Food & Drink",
"objects": "Objects",
"people-and-body": "People & Body",
"smileys-and-emotion": "Smileys & Emotion",
"symbols": "Symbols",
"travel-and-places": "Travel & Places"
},
"load_all_hint": "Loaded first {saneAmount} emoji, loading all emoji may cause performance issues.",
"load_all": "Loading all {emojiAmount} emoji",
"regional_indicator": "Regional indicator {letter}"
},
"errors": {
"storage_unavailable": "Pleroma could not access browser storage. Your login or your local settings won't be saved and you might encounter unexpected issues. Try enabling cookies."
},
"interactions": {
"favs_repeats": "Repeats and favorites",
"follows": "New follows",
"emoji_reactions": "Emoji Reactions",
"reports": "Reports",
"moves": "User migrates",
"load_older": "Load older interactions",
"statuses": "Subscriptions"
},
"post_status": {
"edit_status": "Edit status",
"new_status": "Post new status",
"reply_option": "Reply to this status",
"quote_option": "Quote this status",
"account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.",
"account_not_locked_warning_link": "locked",
"attachments_sensitive": "Mark attachments as sensitive",
"media_description": "Media description",
"content_type": {
"text/plain": "Plain text",
"text/html": "HTML",
"text/markdown": "Markdown",
"text/bbcode": "BBCode",
"text/x.misskeymarkdown": "MFM"
},
"content_type_selection": "Post format",
"content_warning": "Subject (optional)",
"default": "Just landed in L.A.",
"direct_warning_to_all": "This post will be visible to all the mentioned users.",
"direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.",
"edit_remote_warning": "Other remote instances may not support editing and unable to receive the latest version of your post.",
"edit_unsupported_warning": "Pleroma does not support editing mentions or polls.",
"posting": "Posting",
"post": "Post",
"preview": "Preview",
"preview_empty": "Empty",
"empty_status_error": "Can't post an empty status with no files",
"media_description_error": "Failed to update media, try again",
"scope_notice": {
"public": "This post will be visible to everyone",
"private": "This post will be visible to your followers only",
"unlisted": "This post will not be visible in Public Timeline and The Whole Known Network"
},
"scope_notice_dismiss": "Close this notice",
"scope": {
"direct": "Direct - post to mentioned users only",
"private": "Followers-only - post to followers only",
"public": "Public - post to public timelines",
"unlisted": "Unlisted - do not post to public timelines"
},
"close_confirm_title": "Closing post form",
"close_confirm": "What do you want to do with your current writing?",
"close_confirm_save_button": "Save",
"close_confirm_discard_button": "Discard",
"close_confirm_continue_composing_button": "Continue composing",
"auto_save_nothing_new": "Nothing new to save.",
"auto_save_saved": "Saved.",
"auto_save_saving": "Saving...",
"save_to_drafts_button": "Save to drafts",
"save_to_drafts_and_close_button": "Save to drafts and close",
"more_post_actions": "More post actions..."
},
"registration": {
"bio_optional": "Bio (optional)",
"email": "Email",
"email_optional": "Email (optional)",
"fullname": "Display name",
"password_confirm": "Password confirmation",
"registration": "Registration",
"token": "Invite token",
"captcha": "CAPTCHA",
"new_captcha": "Click the image to get a new captcha",
"username_placeholder": "e.g. lain",
"fullname_placeholder": "e.g. Lain Iwakura",
"bio_placeholder": "e.g.\nHi, I'm Lain.\nI’m an anime girl living in suburban Japan. You may know me from the Wired.",
"reason": "Reason to register",
"reason_placeholder": "This instance approves registrations manually.\nLet the administration know why you want to register.",
"register": "Register",
"validations": {
"username_required": "cannot be left blank",
"fullname_required": "cannot be left blank",
"email_required": "cannot be left blank",
"password_required": "cannot be left blank",
"password_confirmation_required": "cannot be left blank",
"password_confirmation_match": "should be the same as password",
"birthday_required": "cannot be left blank",
"birthday_min_age": "must be on or before {date}"
},
"email_language": "In which language do you want to receive emails from the server?",
"birthday": "Birthday:",
"birthday_optional": "Birthday (optional):"
},
"remote_user_resolver": {
"remote_user_resolver": "Remote user resolver",
"searching_for": "Searching for",
"error": "Not found."
},
"report": {
"reporter": "Reporter:",
"reported_user": "Reported user:",
"reported_statuses": "Reported statuses:",
"notes": "Notes:",
"state": "State:",
"state_open": "Open",
"state_closed": "Closed",
"state_resolved": "Resolved"
},
"selectable_list": {
"select_all": "Select all"
},
"settings": {
"add_language": "Add fallback language",
"remove_language": "Remove",
"primary_language": "Primary language:",
"fallback_language": "Fallback language {index}:",
"actor_type": "This account is:",
"actor_type_description": "Marking your account as a group will make it automatically repeat statuses that mention it.",
"actor_type_Person": "a normal user",
"actor_type_person_proper": "a person",
"actor_type_Service": "a bot",
"actor_type_Group": "a group",
"mobile_center_dialog": "Vertically center dialogs on mobile",
"app_name": "App name",
"expert_mode": "Show advanced",
"save": "Save changes",
"reset": "Reset changes",
"security": "Security",
"toggle_edit": "Edit",
"change_banner": "Change banner",
"change_avatar": "Change avatar",
"setting_changed": "Setting is different from default",
"setting_server_side": "This setting is tied to your profile and affects all sessions and clients",
"enter_current_password_to_confirm": "Enter your current password to confirm your identity",
"post_look_feel": "Posts Look & Feel",
- "mention_links": "Mention links",
+ "posts": "Posts",
+ "developer": "Developer",
+ "debug": "Debug",
+ "mention_links": "Mention Links",
"appearance": "Appearance",
"confirm_new_setting": "Confirm new setting?",
"confirm_new_question": "Does this look ok? Setting will be reverted in 10 seconds.",
"revert": "Revert",
"confirm": "Confirm",
"text_size": "Text and interface size",
"text_size_tip": "Use {0} for absolute values, {1} will scale with browser default text size.",
"text_size_tip2": "Values other than {0} might break some things and themes",
"emoji_size": "Emoji size",
"navbar_size": "Top bar size",
"panel_header_size": "Panel header size",
"visual_tweaks": "Minor visual tweaks",
- "theme_debug": "Show what background theme engine assumes when dealing with transparancy (DEBUG)",
+ "theme_debug": "Show what background theme engine assumes when dealing with transparancy",
"scale_and_layout": "Interface scale and layout",
+ "timelines": "Timelines",
+ "format_and_language": "Format and Language",
+ "confirmations": "Confirmations",
+ "layout": "Layout",
"enabled": "Enabled",
+ "clutter": "Clutter",
"filter": {
"clutter": "Remove clutter",
"mute_filter": "Mute Filters",
"type": "Filter type",
"regexp": "RegExp",
"plain": "Simple",
"user": "User (Simple)",
"user_regexp": "User (RegExp)",
"hide": "Hide completely",
"name": "Name",
"value": "Value",
"expires": "Expires",
"expired": "Expired",
"copy": "Duplicate",
"save": "Save",
"delete": "Remove",
"new": "Create new",
"import": "Import",
"export": "Export",
"regexp_error": "Invalid Regular Expression",
"never_expires": "Never",
"total_count": "Total {count} custom filter|Total {count} custom filters",
"expired_count": "{count} expired filter|{count} expired filters",
"custom_filters": "Custom filters",
"purge_expired": "Remove expired filters",
"import_failure": "The selected file is not a supported Pleroma filter.",
"help": {
"word": "Simple and RegExp filters test against post's content and subject.",
"user": "User filter matches full user handle (user{'@'}domain) in the following: author, reply-to and mentions",
"regexp": "Regex variants are more advanced and use {link} to match instead of simple substring search.",
"regexp_link": "Regular Expressions",
"regexp_url": "https://en.wikipedia.org/wiki/Regular_expression"
}
},
"mfa": {
"otp": "OTP",
"setup_otp": "Setup OTP",
"wait_pre_setup_otp": "presetting OTP",
"confirm_and_enable": "Confirm & enable OTP",
"title": "Two-factor Authentication",
"generate_new_recovery_codes": "Generate new recovery codes",
"warning_of_generate_new_codes": "When you generate new recovery codes, your old codes won’t work anymore.",
"recovery_codes": "Recovery codes.",
"waiting_a_recovery_codes": "Receiving backup codes…",
"recovery_codes_warning": "Write the codes down or save them somewhere secure - otherwise you won't see them again. If you lose access to your 2FA app and recovery codes you'll be locked out of your account.",
"authentication_methods": "Authentication methods",
"scan": {
"title": "Scan",
"desc": "Using your two-factor app, scan this QR code or enter text key:",
"secret_code": "Key"
},
"verify": {
"desc": "To enable two-factor authentication, enter the code from your two-factor app:"
}
},
"units": {
"time": {
"m": "minutes",
"s": "seconds",
"h": "hours",
"d": "days"
}
},
"lists_navigation": "Show lists in navigation",
"allow_following_move": "Allow auto-follow when following account moves",
"attachmentRadius": "Attachments",
"attachments": "Attachments",
"image_compression": "Compress images before uploading",
"always_use_jpeg": "Always convert images to JPEG format",
"avatar": "Avatar",
"avatarAltRadius": "Avatars (notifications)",
"avatarRadius": "Avatars",
"background": "Background",
"bio": "Bio",
"profile_other": "Other",
"email_language": "Language for receiving emails from the server",
"block_export": "Block export",
"block_export_button": "Export your blocks to a csv file",
"block_import": "Block import",
"block_import_error": "Error importing blocks",
"blocks_imported": "Blocks imported! Processing them will take a while.",
"mute_export": "Mute export",
"mute_export_button": "Export your mutes to a csv file",
"mute_import": "Mute import",
"mute_import_error": "Error importing mutes",
"mutes_imported": "Mutes imported! Processing them will take a while.",
"import_mutes_from_a_csv_file": "Import mutes from a csv file",
"account_backup": "Account backup",
"account_backup_description": "This allows you to download an archive of your account information and your posts, but they cannot yet be imported into a Pleroma account.",
"account_backup_table_head": "Backup",
"download_backup": "Download",
"backup_not_ready": "This backup is not ready yet.",
"backup_running": "This backup is in progress, processed {number} record. | This backup is in progress, processed {number} records.",
"backup_failed": "This backup has failed.",
"remove_backup": "Remove",
"list_backups_error": "Error fetching backup list: {error}",
"add_backup": "Create a new backup",
"added_backup": "Added a new backup.",
"add_backup_error": "Error adding a new backup: {error}",
"blocks_tab": "Blocks",
"btnRadius": "Buttons",
"cBlue": "Blue (Reply, follow)",
"cGreen": "Green (Retweet)",
"cOrange": "Orange (Favorite)",
"cRed": "Red (Cancel)",
"change_email": "Change email",
"change_email_error": "There was an issue changing your email.",
"changed_email": "Email changed successfully!",
"change_password": "Change password",
"change_password_error": "There was an issue changing your password.",
"changed_password": "Password changed successfully!",
"chatMessageRadius": "Chat message",
"collapse_subject": "Collapse posts with subjects",
"composing": "Composing",
+ "replies": "Replying",
"confirm_new_password": "Confirm new password",
"current_password": "Current password",
"confirm_dialogs": "Ask for confirmation when",
"confirm_dialogs_repeat": "repeating a status",
"confirm_dialogs_unfollow": "unfollowing a user",
"confirm_dialogs_block": "blocking a user",
"confirm_dialogs_mute": "muting a user",
"confirm_dialogs_mute_domain": "muting domains",
"confirm_dialogs_mute_conversation": "muting conversations",
"confirm_dialogs_delete": "deleting a status",
"confirm_dialogs_logout": "logging out",
"confirm_dialogs_approve_follow": "approving a follower",
"confirm_dialogs_deny_follow": "denying a follower",
"confirm_dialogs_remove_follower": "removing a follower",
"mutes_and_blocks": "Mutes and Blocks",
"data_import_export_tab": "Data import / export",
"default_vis": "Default visibility scope",
"delete_account": "Delete account",
"delete_account_description": "Permanently delete your data and deactivate your account.",
"delete_account_error": "There was an issue deleting your account. If this persists please contact your instance administrator.",
"delete_account_instructions": "Type your password in the input below to confirm account deletion.",
"account_alias": "Account aliases",
"account_alias_table_head": "Alias",
"list_aliases_error": "Error fetching aliases: {error}",
"hide_list_aliases_error_action": "Close",
"remove_alias": "Remove this alias",
"new_alias_target": "Add a new alias (e.g. {example})",
"added_alias": "Alias is added.",
"add_alias_error": "Error adding alias: {error}",
"move_account": "Move account",
"move_account_notes": "If you want to move the account somewhere else, you must go to your target account and add an alias pointing here.",
"move_account_target": "Target account (e.g. {example})",
"moved_account": "Account is moved.",
"move_account_error": "Error moving account: {error}",
"discoverable": "Allow discovery of this account in search results and other services",
"domain_mutes": "Domains",
"avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels. Recommended aspect ratio is 1:1",
"banner_size_instruction": "The recommended minimum size for banner images is 450x150 pixels. Recommended aspect ratio is 3:1",
"pad_emoji": "Pad emoji with spaces when adding from picker",
"autocomplete_select_first": "Automatically select the first candidate when autocomplete results are available",
"unsaved_post_action": "When you try to close an unsaved posting form",
"unsaved_post_action_save": "Save it to drafts",
"unsaved_post_action_discard": "Discard it",
"unsaved_post_action_confirm": "Ask every time",
"auto_save_draft": "Save drafts as you compose",
"emoji_reactions_on_timeline": "Show emoji reactions on timeline",
"emoji_reactions_scale": "Reactions scale factor",
"absolute_time_format": "Use absolute time format",
"absolute_time_format_min_age": "Only use for time older than this amount of time",
"absolute_time_format_12h": "Time format",
"absolute_time_format_12h_12h": "12 hour format (i.e. 10:00 PM)",
"absolute_time_format_12h_24h": "24 hour format (i.e. 22:00)",
"export_theme": "Save preset",
"filtering": "Filtering",
"wordfilter": "Wordfilter",
"filtering_explanation": "All statuses containing these words will be muted, one per line",
"word_filter_and_more": "Word filter and more...",
"follow_export": "Follow export",
"follow_export_button": "Export your follows to a csv file",
"follow_import": "Follow import",
"follow_import_error": "Error importing followers",
+ "import_export": {
+ "title": "Import / Export",
+ "follows": "List of users you follow",
+ "blocks": "List of users you block",
+ "mutes": "List of users you mute"
+ },
"follows_imported": "Follows imported! Processing them will take a while.",
"accent": "Accent",
"foreground": "Foreground",
"general": "General",
"hide_attachments_in_convo": "Hide attachments in conversations",
"hide_attachments_in_tl": "Hide attachments in timeline",
"hide_media_previews": "Hide media previews",
"hide_muted_posts": "Hide posts of muted users",
"mute_bot_posts": "Mute bot posts",
"hide_actor_type_indication": "Hide actor type (bots, groups, etc.) indication in posts",
"hide_scrobbles": "Hide scrobbles",
"hide_scrobbles_after": "Hide scrobbles older than",
"mute_sensitive_posts": "Mute sensitive posts",
"hide_all_muted_posts": "Hide muted posts",
"max_thumbnails": "Maximum amount of thumbnails per post (empty = no limit)",
"hide_isp": "Hide instance-specific panel",
"hide_shoutbox": "Hide instance shoutbox",
"right_sidebar": "Reverse order of columns",
"navbar_column_stretch": "Stretch navbar to columns width",
"always_show_post_button": "Always show floating New Post button",
"hide_wallpaper": "Hide instance wallpaper",
"preload_images": "Preload images",
"use_one_click_nsfw": "Open NSFW attachments with just one click",
"hide_post_stats": "Hide post statistics (e.g. the number of favorites)",
"hide_user_stats": "Hide user statistics (e.g. the number of followers)",
"hide_filtered_statuses": "Hide all filtered posts",
"hide_muted_statuses": "Completely hide all muted posts",
"hide_wordfiltered_statuses": "Hide word-filtered statuses",
"hide_muted_threads": "Hide muted threads",
"import_blocks_from_a_csv_file": "Import blocks from a csv file",
"import_followers_from_a_csv_file": "Import follows from a csv file",
"import_theme": "Load preset",
"inputRadius": "Input fields",
"checkboxRadius": "Checkboxes",
"instance_default": "(default: {value})",
"instance_default_simple": "(default)",
"interface": "Interface",
"interfaceLanguage": "Interface language",
"invalid_theme_imported": "The selected file is not a supported Pleroma theme. No changes to your theme were made.",
"limited_availability": "Unavailable in your browser",
"links": "Links",
"lock_account_description": "Restrict your account to approved followers only",
"loop_video": "Loop videos",
"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",
"url": "URL",
"preview": "Preview",
"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",
"name": "Label",
"value": "Content"
},
"birthday": {
"label": "Birthday",
"show_birthday": "Show my birthday"
},
"account_profile_edit": "Edit Profile",
"account_privacy": "Privacy",
"use_contain_fit": "Don't crop the attachment in thumbnails",
"name": "Name",
"name_bio": "Name & bio",
"new_email": "New email",
"new_password": "New password",
"posts": "Posts",
"user_profiles": "User Profiles",
"notification_visibility": "Types of notifications to show",
"notification_visibility_in_column": "Show in notifications column/drawer",
"notification_visibility_native_notifications": "Show a native notification",
"notification_visibility_follows": "Follows",
"notification_visibility_follow_requests": "Follow requests",
"notification_visibility_likes": "Favorites",
"notification_visibility_mentions": "Mentions",
"notification_visibility_repeats": "Repeats",
"notification_visibility_reports": "Reports",
"notification_visibility_moves": "User Migrates",
"notification_visibility_emoji_reactions": "Reactions",
"notification_visibility_polls": "Ends of polls you voted in",
"notification_visibility_statuses": "Subscriptions",
"notification_show_extra": "Show extra notifications in the notifications column",
"notification_extra_chats": "Show unread chats",
"notification_extra_announcements": "Show unread announcements",
"notification_extra_follow_requests": "Show new follow requests",
"notification_extra_tip": "Show the customization tip for extra notifications",
"no_rich_text_description": "Strip rich text formatting from all posts",
"no_blocks": "No blocks",
"no_mutes": "No mutes",
"hide_favorites_description": "Don't show list of my favorites (people still get notified)",
"hide_follows_description": "Don't show who I'm following",
"hide_followers_description": "Don't show who's following me",
"hide_follows_count_description": "Don't show follow count",
"hide_followers_count_description": "Don't show follower count",
"show_admin_badge": "Show \"Admin\" badge in my profile",
"show_moderator_badge": "Show \"Moderator\" badge in my profile",
"nsfw_clickthrough": "Hide sensitive/NSFW media",
"oauth_tokens": "OAuth tokens",
"token": "Token",
"refresh_token": "Refresh token",
"valid_until": "Valid until",
"revoke_token": "Revoke",
"panelRadius": "Panels",
"pause_on_unfocused": "Pause when tab is not focused",
"presets": "Presets",
"profile_background": "Profile background",
"profile_banner": "Profile banner",
"profile_tab": "Profile",
"radii_help": "Set up interface edge rounding (in pixels)",
"replies_in_timeline": "Replies in timeline",
"reply_visibility_all": "Show all replies",
"reply_visibility_following": "Only show replies directed at me or users I'm following",
"reply_visibility_self": "Only show replies directed at me",
"reply_visibility_following_short": "Show replies to my follows",
"reply_visibility_self_short": "Show replies to self only",
"autohide_floating_post_button": "Automatically hide New Post button (mobile)",
"saving_err": "Error saving settings",
"saving_ok": "Settings saved",
"search_user_to_block": "Search whom you want to block",
"search_user_to_mute": "Search whom you want to mute",
"security_tab": "Security",
"scope_copy": "Copy scope when replying (DMs are always copied)",
"minimal_scopes_mode": "Minimize post scope selection options",
"set_new_avatar": "Set new avatar",
"set_new_profile_background": "Set new profile background",
"set_new_background": "Set new background",
"set_new_profile_banner": "Set new profile banner",
"reset_avatar": "Reset avatar",
"reset_banner": "Reset banner",
"reset_profile_background": "Reset profile background",
"reset_profile_banner": "Reset profile banner",
"reset_avatar_confirm": "Do you really want to reset the avatar?",
"reset_banner_confirm": "Do you really want to reset the banner?",
"reset_background_confirm": "Do you really want to reset the background?",
"settings": "Settings",
"subject_input_always_show": "Always show subject field",
"subject_line_behavior": "Copy subject when replying",
"subject_line_email": "Like email: \"re: subject\"",
"subject_line_mastodon": "Like mastodon: copy as is",
"subject_line_noop": "Do not copy",
- "force_theme_recompilation_debug": "Disable theme cahe, force recompile on each boot (DEBUG)",
+ "force_theme_recompilation_debug": "Disable theme cahe, force recompile on each boot",
"conversation_display": "Conversation display style",
"conversation_display_tree": "Tree-style",
"conversation_display_tree_quick": "Tree view",
"disable_sticky_headers": "Don't stick column headers to top of the screen",
"show_scrollbars": "Show side column's scrollbars",
"third_column_mode": "When there's enough space, show third column containing",
"third_column_mode_none": "Don't show third column at all",
"third_column_mode_notifications": "Notifications column",
"third_column_mode_postform": "Main post form and navigation",
"columns": "Columns",
"column_sizes": "Column sizes",
"column_sizes_sidebar": "Sidebar",
"column_sizes_content": "Content",
"column_sizes_notifs": "Notifications",
+ "layout": "Layout",
+ "scale_and_font": "Scale and Font",
"theme_editor_min_width": "Minimum width of theme editor (0 for \"fit-content\")",
"tree_advanced": "Allow more flexible navigation in tree view",
"tree_fade_ancestors": "Display ancestors of the current status in faint text",
"conversation_display_linear": "Linear-style",
"conversation_display_linear_quick": "Linear view",
"conversation_other_replies_button": "Show the \"other replies\" button",
"conversation_other_replies_button_below": "Below statuses",
"conversation_other_replies_button_inside": "Inside statuses",
"max_depth_in_thread": "Maximum number of levels in thread to display by default",
"post_status_content_type": "Post status content type",
+ "default_post_status_content_type": "Default post status content type",
"sensitive_by_default": "Mark posts as sensitive by default",
"stop_gifs": "Pause animated images until you hover on them",
"streaming": "Automatically show new posts when scrolled to the top",
"auto_update": "Show new posts automatically",
"user_mutes": "Users",
"useStreamingApi": "Receive posts and notifications real-time",
"use_websockets": "Use websockets (Realtime updates)",
"text": "Text",
"theme": "Theme",
"theme_old": "Theme editor (old)",
"theme_help": "Use hex color codes (#rrggbb) to customize your color theme.",
"theme_help_v2_1": "You can also override certain component's colors and opacity by toggling the checkbox, use \"Clear all\" button to clear all overrides.",
"theme_help_v2_2": "Icons underneath some entries are background/text contrast indicators, hover over for detailed info. Please keep in mind that when using transparency contrast indicators show the worst possible case.",
"tooltipRadius": "Tooltips/alerts",
"type_domains_to_mute": "Search domains to mute",
"upload_a_photo": "Upload a photo",
"upload_picture": "Upload picture",
"select_picture": "Select picture",
"user_settings": "User Settings",
"values": {
"false": "no",
"true": "yes"
},
"virtual_scrolling": "Optimize timeline rendering",
"use_at_icon": "Display {'@'} symbol as an icon instead of text",
"mention_link_display": "Display mention links",
"mention_link_display_short": "always as short names (e.g. {'@'}foo)",
"mention_link_display_full_for_remote": "as full names only for remote users (e.g. {'@'}foo{'@'}example.org)",
"mention_link_display_full": "always as full names (e.g. {'@'}foo{'@'}example.org)",
"mention_link_use_tooltip": "Show user card when clicking mention links",
"mention_link_show_avatar": "Show user avatar beside the link",
"mention_link_show_avatar_quick": "Show user avatar next to mentions",
"mention_link_fade_domain": "Fade domains (e.g. {'@'}example.org in {'@'}foo{'@'}example.org)",
"mention_link_bolden_you": "Highlight mention of you when you are mentioned",
"user_popover_avatar_action": "Popover avatar click action",
"user_popover_avatar_action_zoom": "Zoom the avatar",
"user_popover_avatar_action_close": "Close the popover",
"user_popover_avatar_action_open": "Open profile",
"user_popover_avatar_overlay": "Show user popover over user avatar",
"user_card_left_justify": "Justify user bio to the left",
"user_card_hide_personal_marks": "Hide personal marks (highlight/note) in user profiles",
+ "posts_appearance": "Posts Appearance",
"fun": "Fun",
"greentext": "Meme arrows",
+ "plaintext_quotes": "Highlight plaintext {0}",
+ "greentext_quotes": ">quotes",
"show_yous": "Show (You)s",
"notifications": "Notifications",
"notification_setting_annoyance": "Annoyance",
"notification_setting_drawer_marks_as_seen": "Closing drawer (mobile) marks all notifications as read",
"notification_setting_ignore_inactionable_seen": "Ignore read state of inactionable notifications (likes, repeats etc)",
"notification_setting_ignore_inactionable_seen_tip": "This will not actually mark those notifications as read, and you'll still get desktop notifications about them if you chose so",
"notification_setting_unseen_at_top": "Show unread notifications above others",
"notification_setting_filters": "Filters",
"notification_setting_filters_chrome_push": "On some browsers (chrome) it might be impossible to completely filter out notifications by type when they arrive by Push",
"notification_setting_block_from_strangers": "Block notifications from users who you do not follow",
"notification_setting_privacy": "Privacy",
"notification_setting_hide_notification_contents": "Hide the sender and contents of push notifications",
"notification_mutes": "To stop receiving notifications from a specific user, use a mute.",
"notification_blocks": "Blocking a user stops all notifications as well as unsubscribes them.",
"enable_web_push_notifications": "Enable web push notifications",
"enable_web_push_always_show": "Always show web push notifications",
"enable_web_push_always_show_tip": "Some browsers (Chromium, Chrome) require that push messages always result in a notification, otherwise generic 'Website was updated in background' is shown, enable this to prevent this notification from showing, as Chrome seem to hide push notifications if tab is in focus. Can result in showing duplicate notifications on other browsers.",
"more_settings": "More settings",
"style": {
+ "style_section": "Style",
"custom_theme_used": "(Custom theme)",
"custom_style_used": "(Custom style)",
"stock_theme_used": "(Stock theme)",
"themes2_outdated": "Editor for Themes V2 is being phased out and will eventually be replaced with a new one that takes advantage of new Themes V3 engine. It should still work but experience might be degraded and inconsistent.",
"appearance_tab_note": "Changes on this tab do not affect the theme used, so exported theme will be different from what seen in the UI",
+ "visual_tweaks_section_note": "Changes in this section do not affect the theme used, exported theme will be different from what seen in the UI",
"update_preview": "Update preview",
"themes3": {
"define": "Override",
"palette": {
"label": "Color schemes",
"name_label": "Color scheme name",
"import": "Import palette",
"export": "Export palette",
"apply": "Apply palette",
"bg": "Panel background",
"fg": "Buttons etc.",
"text": "Text",
"link": "Links",
"accent": "Accent color",
"cRed": "Red color",
"cBlue": "Blue color",
"cGreen": "Green color",
"cOrange": "Orange color",
"wallpaper": "Wallpaper",
"v2_unsupported": "Older v2 themes don't support palettes. Switch to v3 theme to make use of palettes",
"bundled": "Bundled palettes",
"style": "Palettes provided by selected style",
"user": "Custom palette",
"imported": "Imported"
},
"editor": {
"title": "Style editor",
"reset_style": "Reset",
"load_style": "Open from file",
"save_style": "Save",
"style_name": "Stylesheet name",
"style_author": "Made by",
"style_license": "License",
"style_website": "Website",
"component_selector": "Component",
"variant_selector": "Variant",
"states_selector": "States",
"main_tab": "Main",
"shadows_tab": "Shadows",
"background": "Background color",
"text_color": "Text color",
"icon_color": "Icon color",
"link_color": "Link color",
"contrast": "Text contrast",
"roundness": "Roundness",
"opacity": "Opacity",
"border_color": "Border color",
"include_in_rule": "Add to rule",
"test_string": "TEST",
"invalid": "Invalid",
"refresh_preview": "Refresh preview",
"apply_preview": "Apply",
"text_auto": {
"label": "Auto-contrast",
"no-preserve": "Black or White",
"preserve": "Keep color",
"no-auto": "Disabled"
},
"component_tab": "Components style",
"palette_tab": "Color schemes",
"variables_tab": "Variables (Advanced)",
"variables": {
"label": "Variables",
"name_label": "Name:",
"type_label": "Type:",
"type_shadow": "Shadow",
"type_color": "Color",
"type_generic": "Generic",
"virtual_color": "Variable color value"
}
},
"hacks": {
"underlay_overrides": "Change underlay",
"underlay_override_mode_none": "Theme default",
"underlay_override_mode_opaque": "Replace with solid color",
"underlay_override_mode_transparent": "Remove entirely (might break some themes)",
"force_interface_roundness": "Override interface roundness/sharpness",
"forced_roundness_mode_disabled": "Use theme defaults",
"forced_roundness_mode_sharp": "Force sharp edges",
"forced_roundness_mode_nonsharp": "Force not-so-sharp (1px roundness) edges",
"forced_roundness_mode_round": "Force round edges"
},
"font": {
"group-builtin": "Browser default fonts",
"builtin" : {
"serif": "Serif",
"sans-serif": "Sans-serif",
"monospace": "Monospace",
"inherit": "Unchanged"
},
"group-local": "Locally installed fonts",
"local-unavailable1": "List of locally installed fonts unavailalbe",
"local-unavailable2": "Use manual entry to specify custom font",
"font_list_unavailable": "Couldn't get locally installed fonts: {error}",
"lookup_local_fonts": "Load list of fonts installed on this computer",
"enter_manually": "Enter font name family manually",
"entry": "Enter {fontFamily}",
"select": "Select font",
"label": "{label} font"
}
},
"interface_font_user_override": "Override theme/browser font used",
"switcher": {
"keep_color": "Keep colors",
"keep_shadows": "Keep shadows",
"keep_opacity": "Keep opacity",
"keep_roundness": "Keep roundness",
"keep_fonts": "Keep fonts",
"save_load_hint": "\"Keep\" options preserve currently set options when selecting or loading themes, it also stores said options when exporting a theme. When all checkboxes unset, exporting theme will save everything.",
"reset": "Reset",
"clear_all": "Clear all",
"clear_opacity": "Clear opacity",
"load_theme": "Load theme",
"keep_as_is": "Keep as is",
"use_snapshot": "Old version",
"use_source": "New version",
"help": {
"upgraded_from_v2": "PleromaFE has been upgraded, theme could look a little bit different than you remember.",
"v2_imported": "File you imported was made for older FE. We try to maximize compatibility but there still could be inconsistencies.",
"future_version_imported": "File you imported was made in newer version of FE.",
"older_version_imported": "File you imported was made in older version of FE.",
"snapshot_present": "Theme snapshot is loaded, so all values are overriden. You can load theme's actual data instead.",
"snapshot_missing": "No theme snapshot was in the file so it could look different than originally envisioned.",
"fe_upgraded": "PleromaFE's theme engine upgraded after version update.",
"fe_downgraded": "PleromaFE's version rolled back.",
"migration_snapshot_ok": "Just to be safe, theme snapshot loaded. You can try loading theme data.",
"migration_napshot_gone": "For whatever reason snapshot was missing, some stuff could look different than you remember.",
"snapshot_source_mismatch": "Versions conflict: most likely FE was rolled back and updated again, if you changed theme using older version of FE you most likely want to use old version, otherwise use new version."
}
},
"common": {
"color": "Color",
"opacity": "Opacity",
"contrast": {
"hint": "Contrast ratio is {ratio}, it {level} {context}",
"level": {
"aa": "meets Level AA guideline (minimal)",
"aaa": "meets Level AAA guideline (recommended)",
"bad": "doesn't meet any accessibility guidelines"
},
"context": {
"18pt": "for large (18pt+) text",
"text": "for text"
}
}
},
"common_colors": {
"_tab_label": "Common",
"main": "Common colors",
"foreground_hint": "See \"Advanced\" tab for more detailed control",
"rgbo": "Icons, accents, badges"
},
"advanced_colors": {
"_tab_label": "Advanced",
"alert": "Alert background",
"alert_error": "Error",
"alert_warning": "Warning",
"alert_neutral": "Neutral",
"post": "Posts/User bios",
"badge": "Badge background",
"popover": "Tooltips, menus, popovers",
"badge_notification": "Notification",
"panel_header": "Panel header",
"top_bar": "Top bar",
"borders": "Borders",
"buttons": "Buttons",
"inputs": "Input fields",
"faint_text": "Faded text",
"underlay": "Underlay",
"wallpaper": "Wallpaper",
"poll": "Poll graph",
"icons": "Icons",
"highlight": "Highlighted elements",
"pressed": "Pressed",
"selectedPost": "Selected post",
"selectedMenu": "Selected menu item",
"disabled": "Disabled",
"toggled": "Toggled",
"tabs": "Tabs",
"chat": {
"incoming": "Incoming",
"outgoing": "Outgoing",
"border": "Border"
}
},
"radii": {
"_tab_label": "Roundness"
},
"shadows": {
"_tab_label": "Shadow and lighting",
"component": "Component",
"override": "Override",
"shadow_id": "Shadow #{value}",
"offset": "Shadow offset",
"zoom": "Zoom",
"offset-x": "x:",
"offset-y": "y:",
"light_grid": "Use light checkerboard",
"color_override": "Use different color",
"name": "Name",
"blur": "Blur",
"spread": "Spread",
"inset": "Inset",
"raw": "Plain shadow",
"expression": "Expression (advanced)",
"empty_expression": "Empty expression",
"hintV3": "For shadows you can also use the {0} notation to use other color slot.",
"filter_hint": {
"always_drop_shadow": "Warning, this shadow always uses {0} when browser supports it.",
"drop_shadow_syntax": "{0} does not support {1} parameter and {2} keyword.",
"avatar_inset_short": "Separate inset shadow",
"avatar_inset": "Please note that combining both inset and non-inset shadows on avatars might give unexpected results with transparent avatars.",
"spread_zero": "Shadows with spread > 0 will appear as if it was set to zero",
"inset_classic": "Inset shadows will be using {0}"
},
"components": {
"panel": "Panel",
"panelHeader": "Panel header",
"topBar": "Top bar",
"avatar": "User avatar (in profile view)",
"avatarStatus": "User avatar (in post display)",
"popup": "Popups and tooltips",
"button": "Button",
"buttonHover": "Button (hover)",
"buttonPressed": "Button (pressed)",
"buttonPressedHover": "Button (pressed+hover)",
"input": "Input field"
}
},
"fonts": {
"_tab_label": "Fonts",
"help": "Select font to use for elements of UI. For \"custom\" you have to enter exact font name as it appears in system.",
"components": {
"interface": "Interface",
"input": "Input fields",
"post": "Post text",
"monospace": "Monospaced text"
},
+ "components_inline": {
+ "interface": "interface",
+ "input": "input fields",
+ "post": "post text",
+ "monospace": "monospaced text"
+ },
+ "override": "Override {0} font",
"family": "Font name",
"size": "Size (in px)",
"weight": "Weight (boldness)",
"custom": "Custom"
},
"preview": {
"header": "Preview",
"content": "Content",
"error": "Example error",
"button": "Button",
"text": "A bunch of more {0} and {1}",
"mono": "content",
"input": "Just landed in L.A.",
"faint_link": "helpful manual",
"fine_print": "Read our {0} to learn nothing useful!",
"header_faint": "This is fine",
"checkbox": "I have skimmed over terms and conditions",
"link": "a nice lil' link"
}
},
"version": {
"title": "Version",
"backend_version": "Backend version",
"frontend_version": "Frontend version"
},
"commit_value": "Save",
"commit_value_tooltip": "Value is not saved, press this button to commit your changes",
"reset_value": "Reset",
"reset_value_tooltip": "Reset draft",
"hard_reset_value": "Hard reset",
"hard_reset_value_tooltip": "Remove setting from storage, forcing use of default value",
"cache": "Cache",
"clear_asset_cache": "Clear asset cache",
"clear_emoji_cache": "Clear emoji cache"
},
"admin_dash": {
"window_title": "Administration",
"wip_notice": "This admin dashboard is experimental and WIP, {adminFeLink}.",
"old_ui_link": "old admin UI available here",
"reset_all": "Reset all",
"commit_all": "Save all",
"tabs": {
"nodb": "No DB Config",
"instance": "Instance",
"limits": "Limits",
"frontends": "Front-ends",
"emoji": "Emoji"
},
"nodb": {
"heading": "Database config is disabled",
"text": "You need to change backend config files so that {property} is set to {value}, see more in {documentation}.",
"documentation": "documentation",
"text2": "Most configuration options will be unavailable."
},
"captcha": {
"native": "Native",
"kocaptcha": "KoCaptcha"
},
"instance": {
"instance": "Instance information",
"registrations": "User sign-ups",
"captcha_header": "CAPTCHA",
"kocaptcha": "KoCaptcha settings",
"access": "Instance access",
"restrict": {
"header": "Restrict access for anonymous visitors",
"description": "Detailed setting for allowing/disallowing access to certain aspects of API. By default (indeterminate state) it will disallow if instance is not public, ticked checkbox means disallow access even if instance is public, unticked means allow access even if instance is private. Please note that unexpected behavior might happen if some settings are set, i.e. if profile access is disabled posts will show without profile information.",
"timelines": "Timelines access",
"profiles": "User profiles access",
"activities": "Statuses/activities access"
}
},
"limits": {
"arbitrary_limits": "Arbitrary limits",
"posts": "Post limits",
"uploads": "Attachments limits",
"users": "User profile limits",
"profile_fields": "Profile fields limits",
"user_uploads": "Profile media limits"
},
"frontend": {
"repository": "Repository link",
"versions": "Available versions",
"build_url": "Build URL",
"reinstall": "Reinstall",
"is_default": "(Default)",
"is_default_custom": "(Default, version: {version})",
"install": "Install",
"install_version": "Install version {version}",
"more_install_options": "More install options",
"more_default_options": "More default setting options",
"set_default": "Set default",
"set_default_version": "Set version {version} as default",
"wip_notice": "Please note that this section is a WIP and lacks certain features as backend implementation of front-end management is incomplete.",
"default_frontend": "Default frontend",
"default_frontend_tip": "Default frontend will be shown to all users. Currently there's no way to for a user to select personal frontend. If you switch away from PleromaFE you'll most likely have to use old and buggy AdminFE to do instance configuration until we replace it.",
"default_frontend_unavail": "Default frontend settings are not available, as this requires configuration in the database",
"available_frontends": "Available for install",
"failure_installing_frontend": "Failed to install frontend {version}: {reason}",
"success_installing_frontend": "Frontend {version} successfully installed"
},
"emoji": {
"global_actions": "Global actions",
"reload": "Reload emoji",
"importFS": "Import emoji from filesystem",
"error": "Error: {0}",
"create_pack": "Create pack",
"delete_pack": "Delete pack",
"new_pack_name": "New pack name",
"create": "Create",
"emoji_packs": "Emoji packs",
"remote_packs": "Remote packs",
"do_list": "List",
"remote_pack_instance": "Remote pack instance",
"emoji_pack": "Emoji pack",
"edit_pack": "Edit pack",
"description": "Description",
"homepage": "Homepage",
"fallback_src": "Fallback source",
"fallback_sha256": "Fallback SHA256",
"share": "Share",
"save": "Save",
"save_meta": "Save metadata",
"revert_meta": "Revert metadata",
"delete": "Delete",
"revert": "Revert",
"add_file": "Add file",
"adding_new": "Adding new emoji",
"shortcode": "Shortcode",
"filename": "Filename",
"emoji_source": "Emoji file source",
"upload_url": "Upload from URL",
"new_shortcode": "Shortcode, leave blank to infer",
"new_filename": "Filename, leave blank to infer",
"delete_confirm": "Are you sure you want to delete {0}?",
"download_pack": "Download pack",
"downloading_pack": "Downloading {0}",
"download": "Download",
"download_as_name": "New name",
"download_as_name_full": "New name, leave blank to reuse",
"files": "Files",
"editing": "Editing {0}",
"copying": "Copying {0}",
"copy_to": "Copy to",
"copy_to_pack": "Copy to local pack",
"delete_title": "Delete?",
"metadata_changed": "Metadata different from saved",
"emoji_changed": "Unsaved emoji file changes, check highlighted emoji",
"replace_warning": "This will REPLACE the local pack of the same name",
"copied_successfully": "Successfully copied emoji \"{0}\" to pack \"{1}\""
},
"temp_overrides": {
":pleroma": {
":instance": {
":public": {
"label": "Instance is public",
"description": "Disabling this will make all API accessible only for logged-in users, this will make Public and Federated timelines inaccessible to anonymous visitors."
},
":limit_to_local_content": {
"label": "Limit search to local content",
"description": "Disables global network search for unauthenticated (default), all users or none"
},
":description_limit": {
"label": "Limit",
"description": "Character limit for attachment descriptions"
},
":background_image": {
"label": "Background image",
"description": "Background image (primarily used by PleromaFE)"
}
}
}
}
},
"time": {
"unit": {
"days": "{0} day | {0} days",
"days_short": "{0}d",
"days_suffix": "day(s)",
"hours": "{0} hour | {0} hours",
"hours_short": "{0}h",
"hours_suffix": "hour(s)",
"minutes": "{0} minute | {0} minutes",
"minutes_short": "{0}min",
"minutes_suffix": "minute(s)",
"months": "{0} month | {0} months",
"months_short": "{0}mo",
"months_suffix": "month(s)",
"seconds": "{0} second | {0} seconds",
"seconds_short": "{0}s",
"seconds_suffix": "second(s)",
"weeks": "{0} week | {0} weeks",
"weeks_short": "{0}w",
"weeks_suffix": "week(s)",
"years": "{0} year | {0} years",
"years_short": "{0}y",
"years": "year(s)"
},
"in_future": "in {0}",
"in_past": "{0} ago",
"now": "just now",
"now_short": "now"
},
"timeline": {
"collapse": "Collapse",
"conversation": "Conversation",
"error": "Error fetching timeline: {0}",
"load_older": "Load older statuses",
"no_retweet_hint": "Post is marked as followers-only or direct and cannot be repeated",
"repeated": "repeated",
"show_new": "Show new",
"reload": "Reload",
"up_to_date": "Up-to-date",
"no_more_statuses": "No more statuses",
"no_statuses": "No statuses",
"socket_reconnected": "Realtime connection established",
"socket_broke": "Realtime connection lost: CloseEvent code {0}",
"quick_view_settings": "Quick view settings",
"quick_filter_settings": "Quick filter settings",
"filter_settings": "Filter"
},
"status": {
"favorites": "Favorites",
"repeats": "Repeats",
"quotes": "Quotes",
"repeat_confirm": "Do you really want to repeat this status?",
"repeat_confirm_title": "Repeat confirmation",
"repeat_confirm_accept_button": "Repeat",
"repeat_confirm_cancel_button": "Do not repeat",
"delete": "Delete status",
"delete_error": "Error deleting status: {0}",
"edit": "Edit status",
"edited_at": "(last edited {time})",
"pin": "Pin on profile",
"unpin": "Unpin from profile",
"pinned": "Pinned",
"bookmark": "Bookmark",
"unbookmark": "Unbookmark",
"delete_confirm": "Do you really want to delete this status?",
"delete_confirm_title": "Delete confirmation",
"delete_confirm_accept_button": "Delete",
"delete_confirm_cancel_button": "Keep",
"reply_to": "Reply to",
"reply_to_with_icon": "{icon} {replyTo}",
"reply_to_with_arg": "{replyToWithIcon} {user}",
"mentions": "Mentions",
"replies_list": "Replies:",
"replies_list_with_others": "Replies (+{numReplies} other): | Replies (+{numReplies} others):",
"mute_ellipsis": "Mute…",
"mute_user": "Mute user",
"unmute_user": "Unmute user",
"mute_domain": "Mute domain",
"unmute_domain": "Unmute domain",
"mute_conversation": "Mute conversation",
"unmute_conversation": "Unmute conversation",
"status_unavailable": "Status unavailable",
"copy_link": "Copy link to status",
"external_source": "External source",
"muted_words": "Wordfiltered: {word} | Wordfiltered: {word} and {numWordsMore} more words",
"muted_filters": "Filtered: {name} | Wordfiltered: {name} and {filtersMore} more words",
"multi_reason_mute": "{main} + one more reason | {main} + {numReasonsMore} more reasons",
"muted_user": "User muted",
"thread_muted": "Thread muted",
"thread_muted_and_words": ", has words:",
"sensitive_muted": "Muting sensitive content",
"bot_muted": "Muting bot content",
"show_full_subject": "Show full subject",
"hide_full_subject": "Hide full subject",
"show_content": "Show content",
"hide_content": "Hide content",
"status_deleted": "This post was deleted",
"unknown_user": "unknown user",
"unknown_user_info": "Unable to fetch information about this user",
"nsfw": "NSFW",
"expand": "Expand",
"you": "(You)",
"plus_more": "+{number} more",
"many_attachments": "Post has {number} attachment(s)",
"collapse_attachments": "Collapse attachments",
"show_all_attachments": "Show all attachments",
"show_attachment_in_modal": "Show in media modal",
"show_attachment_description": "Preview description (open attachment for full description)",
"hide_attachment": "Hide attachment",
"remove_attachment": "Remove attachment",
"attachment_stop_flash": "Stop Flash player",
"move_up": "Shift attachment left",
"move_down": "Shift attachment right",
"open_gallery": "Open gallery",
"thread_hide": "Hide this thread",
"thread_show": "Show this thread",
"thread_show_full": "Show everything under this thread ({numStatus} status in total, max depth {depth}) | Show everything under this thread ({numStatus} statuses in total, max depth {depth})",
"thread_show_full_with_icon": "{icon} {text}",
"thread_follow": "See the remaining part of this thread ({numStatus} status in total) | See the remaining part of this thread ({numStatus} statuses in total)",
"thread_follow_with_icon": "{icon} {text}",
"ancestor_follow": "See {numReplies} other reply under this status | See {numReplies} other replies under this status",
"ancestor_follow_with_icon": "{icon} {text}",
"show_all_conversation_with_icon": "{icon} {text}",
"show_all_conversation": "Show full conversation ({numStatus} other status) | Show full conversation ({numStatus} other statuses)",
"show_only_conversation_under_this": "Only show replies to this status",
"status_history": "Status history",
"reaction_count_label": "{num} person reacted | {num} people reacted",
"hide_quote": "Hide the quoted status",
"display_quote": "Display the quoted status",
"invisible_quote": "Quoted status unavailable: {link}",
"more_actions": "More actions on this status",
"loading": "Loading...",
"load_error": "Unable to load status: {error}"
},
"user_card": {
"approve": "Approve",
"approve_confirm_title": "Approve confirmation",
"approve_confirm_accept_button": "Approve",
"approve_confirm_cancel_button": "Do not approve",
"approve_confirm": "Do you want to approve {user}'s follow request?",
"block": "Block",
"blocked": "Blocked!",
"block_confirm_title": "Block confirmation",
"block_confirm": "Do you really want to block {user}?",
"block_confirm_accept_button": "Block",
"block_confirm_cancel_button": "Do not block",
"deactivated": "Deactivated",
"deny": "Deny",
"deny_confirm_title": "Deny confirmation",
"deny_confirm_accept_button": "Deny",
"deny_confirm_cancel_button": "Do not deny",
"deny_confirm": "Do you want to deny {user}'s follow request?",
"edit_profile": "Edit profile",
"favorites": "Favorites",
"follow": "Follow",
"follow_cancel": "Cancel request",
"follow_sent": "Request sent!",
"follow_progress": "Requesting…",
"follow_unfollow": "Unfollow",
"unfollow_confirm_title": "Unfollow confirmation",
"unfollow_confirm": "Do you really want to unfollow {user}?",
"unfollow_confirm_accept_button": "Unfollow",
"unfollow_confirm_cancel_button": "Do not unfollow",
"followees": "Following",
"followers": "Followers",
"following": "Following!",
"follows_you": "Follows you!",
"hidden": "Hidden",
"its_you": "It's you!",
"media": "Media",
"mention": "Mention",
"message": "Message",
"mute": "Mute",
"muted": "Muted",
"mute_confirm_title": "Mute confirmation",
"mute_confirm": "Do you really want to mute {user}?",
"mute_confirm_accept_button": "Mute",
"mute_confirm_cancel_button": "Do not mute",
"mute_or": "or",
"expire_in": "Expire in",
"expire_mute_message": "Are you sure you want to mute {0}?",
"expire_block_message": "Are you sure you want to block {0}?",
"dont_ask_again_mute": "Always mute users this way",
"dont_ask_again_block": "Always block users this way",
"mute_block_temporarily": "Temporarily",
"mute_block_forever": "Forever",
"mute_block_never": "Never",
"mute_block_ask": "Ask",
"default_mute_expiration": "Always mute users",
"default_block_expiration": "Always block users",
"default_expiration_time": "Expire in",
"mute_expires_forever": "Muted forever",
"mute_expires_at": "Muted until {0}",
"block_expires_forever": "Blocked forever",
"block_expires_at": "Blocked until {0}",
"mute_duration_prompt": "Mute this user for (0 for indefinite time):",
"statuses_per_day": "Statuses per day",
"remote_follow": "Remote follow",
"remove_follower": "Remove follower",
"remove_follower_confirm_title": "Remove follower confirmation",
"remove_follower_confirm_accept_button": "Remove",
"remove_follower_confirm_cancel_button": "Keep",
"remove_follower_confirm": "Do you really want to remove {user} from your followers?",
"report": "Report",
"statuses": "Statuses",
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe",
"unblock": "Unblock",
"unblock_progress": "Unblocking…",
"block_progress": "Blocking…",
"unmute": "Unmute",
"unmute_progress": "Unmuting…",
"mute_progress": "Muting…",
"hide_repeats": "Hide repeats",
"show_repeats": "Show repeats",
"bot": "Bot",
"group": "Group",
"birthday": "Born {birthday}",
"admin_menu": {
"moderation": "Moderation",
"grant_admin": "Grant Admin",
"revoke_admin": "Revoke Admin",
"grant_moderator": "Grant Moderator",
"revoke_moderator": "Revoke Moderator",
"activate_account": "Activate account",
"deactivate_account": "Deactivate account",
"delete_account": "Delete account",
"force_nsfw": "Mark all posts as NSFW",
"strip_media": "Remove media from posts",
"force_unlisted": "Force posts to be unlisted",
"sandbox": "Force posts to be followers-only",
"disable_remote_subscription": "Disallow following user from remote instances",
"disable_any_subscription": "Disallow following user at all",
"quarantine": "Disallow user posts from federating",
"delete_user": "Delete user",
"delete_user_data_and_deactivate_confirmation": "This will permanently delete the data from this account and deactivate it. Are you absolutely sure?"
},
"highlight_new": {
"disabled": "Don't highlight",
"solid": "Solid background",
"striped": "Striped background",
"side": "Side stripe"
},
"personal_note": "Personal note",
"note_blank_click": "Click to add note",
"highlight_header": "Highlight user's posts and mentions"
},
"user_profile": {
"timeline_title": "User timeline",
"profile_does_not_exist": "Sorry, this profile does not exist.",
"profile_loading_error": "Sorry, there was an error loading this profile."
},
"user_reporting": {
"title": "Reporting {0}",
"add_comment_description": "The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:",
"additional_comments": "Additional comments",
"forward_description": "The account is from another server. Send a copy of the report there as well?",
"forward_to": "Forward to {0}",
"submit": "Submit",
"generic_error": "An error occurred while processing your request."
},
"who_to_follow": {
"more": "More",
"who_to_follow": "Who to follow"
},
"tool_tip": {
"media_upload": "Upload media",
"mentions": "Mentions",
"repeat": "Repeat",
"unrepeat": "Unrepeat",
"reply": "Reply",
"add_reaction": "Add reaction",
"favorite": "Favorite",
"unfavorite": "Unfavorite",
"add_reaction": "Add Reaction",
"user_settings": "User Settings",
"accept_follow_request": "Accept follow request",
"reject_follow_request": "Reject follow request",
"bookmark": "Bookmark",
"toggle_expand": "Expand or collapse notification to show post in full",
"toggle_mute": "Expand or collapse notification to reveal muted content",
"autocomplete_available": "{number} result is available. Use up and down keys to navigate through them. | {number} results are available. Use up and down keys to navigate through them."
},
"upload": {
"error": {
"base": "Upload failed.",
"message": "Upload failed: {0}",
"file_too_big": "File too big [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Try again later"
},
"file_size_units": {
"B": "B",
"KiB": "KiB",
"MiB": "MiB",
"GiB": "GiB",
"TiB": "TiB"
}
},
"search": {
"people": "People",
"hashtags": "Hashtags",
"person_talking": "{count} person talking",
"people_talking": "{count} people talking",
"no_results": "No results",
"no_more_results": "No more results",
"load_more": "Load more results"
},
"password_reset": {
"forgot_password": "Forgot password?",
"password_reset": "Password reset",
"instruction": "Enter your email address or username. We will send you a link to reset your password.",
"placeholder": "Your email or username",
"check_email": "Check your email for a link to reset your password.",
"return_home": "Return to the home page",
"too_many_requests": "You have reached the limit of attempts, try again later.",
"password_reset_disabled": "Password reset is disabled. Please contact your instance administrator.",
"password_reset_required": "You must reset your password to log in.",
"password_reset_required_but_mailer_is_disabled": "You must reset your password, but password reset is disabled. Please contact your instance administrator."
},
"chats": {
"you": "You:",
"message_user": "Message {nickname}",
"delete": "Delete",
"chats": "Chats",
"new": "New Chat",
"empty_message_error": "Cannot post empty message",
"more": "More",
"delete_confirm": "Do you really want to delete this message?",
"error_loading_chat": "Something went wrong when loading the chat.",
"error_sending_message": "Something went wrong when sending the message.",
"empty_chat_list_placeholder": "You don't have any chats yet. Start a new chat!"
},
"bookmarks": {
"manage_bookmark_folders": "Manage bookmark folders"
},
"lists": {
"lists": "Lists",
"new": "New List",
"title": "List title",
"search": "Search users",
"create": "Create",
"save": "Save changes",
"delete": "Delete list",
"following_only": "Limit to Following",
"manage_lists": "Manage lists",
"manage_members": "Manage list members",
"add_members": "Search for more users",
"remove_from_list": "Remove from list",
"add_to_list": "Add to list",
"is_in_list": "Already in list",
"editing_list": "Editing list {listTitle}",
"creating_list": "Creating new list",
"update_title": "Save Title",
"really_delete": "Really delete list?",
"error": "Error manipulating lists: {0}"
},
"file_type": {
"audio": "Audio",
"video": "Video",
"image": "Image",
"file": "File"
},
"display_date": {
"today": "Today"
},
"update": {
"big_update_title": "Please bear with us",
"big_update_content": "We haven't had a release in a while, so things might look and feel different than what you're used to.",
"update_bugs": "Please report any issues and bugs on {pleromaGitlab}, as we have changed a lot, and although we test thoroughly and use development versions ourselves, we may have missed some things. We welcome your feedback and suggestions on issues you might encounter, or how to improve Pleroma and Pleroma-FE.",
"update_bugs_gitlab": "Pleroma GitLab",
"update_changelog": "For more details on what's changed, see {theFullChangelog}.",
"update_changelog_here": "the full changelog",
"art_by": "Art by {linkToArtist}"
},
"unicode_domain_indicator": {
"tooltip": "This domain contains non-ascii characters."
},
"drafts": {
"drafts": "Drafts",
"no_drafts": "You have no drafts",
"empty": "(No content)",
"poll_tooltip": "Draft contains a poll",
"continue": "Continue composing",
"save": "Save without posting",
"abandon": "Abandon draft",
"abandon_confirm_title": "Abandon confirmation",
"abandon_confirm": "Do you really want to abandon this draft?",
"abandon_confirm_accept_button": "Abandon",
"abandon_confirm_cancel_button": "Keep",
"replying": "Replying to {statusLink}",
"editing": "Editing {statusLink}",
"unavailable": "(unavailable)"
},
"splash": {
"loading": "Loading...",
"theme": "Applying theme, please wait warmly...",
"fun_1": "Drink more water",
"fun_2": "Take it easy!",
"fun_3": "Suya...",
"fun_4": "My Pleroma machine is full power!",
"error": "Something went wrong"
},
"bookmark_folders": {
"select_folder": "Select bookmark folder",
"creating_folder": "Creating bookmark folder",
"editing_folder": "Editing folder {folderName}",
"emoji": "Emoji",
"name": "Folder name",
"new": "New Folder",
"create": "Create folder",
"delete": "Delete folder",
"update_folder": "Save changes",
"really_delete": "Do you really want to delete the folder?",
"error": "Error manipulating bookmark folders: {0}"
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Aug 29, 4:28 AM (1 d, 10 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1736787
Default Alt Text
(433 KB)

Event Timeline