diff --git a/changelog.d/custom.add b/changelog.d/custom.add new file mode 100644 index 00000000..97848d7e --- /dev/null +++ b/changelog.d/custom.add @@ -0,0 +1 @@ +Added support for fetching /{resource}.custom.ext to allow adding instance-specific themes without altering sourcetree diff --git a/changelog.d/tabs.change b/changelog.d/tabs.change new file mode 100644 index 00000000..e716ad42 --- /dev/null +++ b/changelog.d/tabs.change @@ -0,0 +1 @@ +Tabs now have indentation for better visibility of which tab is currently active diff --git a/changelog.d/themes3.add b/changelog.d/themes3.add new file mode 100644 index 00000000..040957ce --- /dev/null +++ b/changelog.d/themes3.add @@ -0,0 +1 @@ +UI for making v3 themes and palettes, support for bundling v3 themes diff --git a/package.json b/package.json index 403a2b23..fa08593f 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "hash-sum": "^2.0.0", "js-cookie": "3.0.5", "localforage": "1.10.0", + "pako": "^2.1.0", "parse-link-header": "2.0.0", "phoenix": "1.7.7", "punycode.js": "2.3.0", diff --git a/src/App.js b/src/App.js index e87108dd..befcece8 100644 --- a/src/App.js +++ b/src/App.js @@ -67,6 +67,9 @@ export default { themeApplied () { return this.$store.state.interface.themeApplied }, + layoutModalClass () { + return '-' + this.layoutType + }, classes () { return [ { diff --git a/src/App.vue b/src/App.vue index 9d7ad912..57c32cbd 100644 --- a/src/App.vue +++ b/src/App.vue @@ -70,7 +70,7 @@ - + diff --git a/src/boot/after_store.js b/src/boot/after_store.js index fa837f25..b1304989 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -123,6 +123,8 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => { } copyInstanceOption('theme') + copyInstanceOption('style') + copyInstanceOption('palette') copyInstanceOption('nsfwCensorImage') copyInstanceOption('background') copyInstanceOption('hidePostStats') @@ -351,7 +353,7 @@ const afterStoreSetup = async ({ store, i18n }) => { await setConfig({ store }) document.querySelector('#status').textContent = i18n.global.t('splash.theme') try { - await store.dispatch('setTheme').catch((e) => { console.error('Error setting theme', e) }) + await store.dispatch('applyTheme').catch((e) => { console.error('Error setting theme', e) }) } catch (e) { return Promise.reject(e) } @@ -391,6 +393,13 @@ const afterStoreSetup = async ({ store, i18n }) => { app.use(store) app.use(i18n) + // Little thing to get out of invalid theme state + window.resetThemes = () => { + store.dispatch('resetThemeV3') + store.dispatch('resetThemeV3Palette') + store.dispatch('resetThemeV2') + } + app.use(vClickOutside) app.use(VBodyScrollLock) app.use(VueVirtualScroller) diff --git a/src/components/alert.style.js b/src/components/alert.style.js index abbeb5ba..86851476 100644 --- a/src/components/alert.style.js +++ b/src/components/alert.style.js @@ -14,6 +14,10 @@ export default { warning: '.warning', success: '.success' }, + editor: { + border: 1, + aspect: '3 / 1' + }, defaultRules: [ { directives: { diff --git a/src/components/attachment/attachment.style.js b/src/components/attachment/attachment.style.js index 5fb4701c..fde8a50a 100644 --- a/src/components/attachment/attachment.style.js +++ b/src/components/attachment/attachment.style.js @@ -1,6 +1,7 @@ export default { name: 'Attachment', selector: '.Attachment', + notEditable: true, validInnerComponents: [ 'Border', 'ButtonUnstyled', diff --git a/src/components/border.style.js b/src/components/border.style.js index a87ee9c8..7f2c3016 100644 --- a/src/components/border.style.js +++ b/src/components/border.style.js @@ -5,7 +5,7 @@ export default { defaultRules: [ { directives: { - textColor: '$mod(--parent, 10)', + textColor: '$mod(--parent 10)', textAuto: 'no-auto' } } diff --git a/src/components/button.style.js b/src/components/button.style.js index 1423d5c7..747cfd5e 100644 --- a/src/components/button.style.js +++ b/src/components/button.style.js @@ -9,9 +9,9 @@ export default { // However, cascading still works, so resulting state will be result of merging of all relevant states/variants // normal: '' // normal state is implicitly added, it is always included toggled: '.toggled', - pressed: ':active', + focused: ':focus-visible', + pressed: ':focus:active', hover: ':hover:not(:disabled)', - focused: ':focus-within', disabled: ':disabled' }, // Variants are mutually exclusive, each component implicitly has "normal" variant, and all other variants inherit from it. @@ -22,6 +22,9 @@ export default { // Overall the compuation difficulty is N*((1/6)M^3+M) where M is number of distinct states and N is number of variants. // This (currently) is further multipled by number of places where component can exist. }, + editor: { + aspect: '2 / 1' + }, // This lists all other components that can possibly exist within one. Recursion is currently not supported (and probably won't be supported ever). validInnerComponents: [ 'Text', @@ -32,10 +35,11 @@ export default { { component: 'Root', directives: { - '--defaultButtonHoverGlow': 'shadow | 0 0 4 --text', - '--defaultButtonShadow': 'shadow | 0 0 2 #000000', - '--defaultButtonBevel': 'shadow | $borderSide(#FFFFFF, top, 0.2), $borderSide(#000000, bottom, 0.2)', - '--pressedButtonBevel': 'shadow | $borderSide(#FFFFFF, bottom, 0.2), $borderSide(#000000, top, 0.2)' + '--buttonDefaultHoverGlow': 'shadow | 0 0 4 --text / 0.5', + '--buttonDefaultFocusGlow': 'shadow | 0 0 4 4 --link / 0.5', + '--buttonDefaultShadow': 'shadow | 0 0 2 #000000', + '--buttonDefaultBevel': 'shadow | $borderSide(#FFFFFF top 0.2 2), $borderSide(#000000 bottom 0.2 2)', + '--buttonPressedBevel': 'shadow | $borderSide(#FFFFFF bottom 0.2 2), $borderSide(#000000 top 0.2 2)' } }, { @@ -43,47 +47,60 @@ export default { // like within it directives: { background: '--fg', - shadow: ['--defaultButtonShadow', '--defaultButtonBevel'], + shadow: ['--buttonDefaultShadow', '--buttonDefaultBevel'], roundness: 3 } }, { state: ['hover'], directives: { - shadow: ['--defaultButtonHoverGlow', '--defaultButtonBevel'] + shadow: ['--buttonDefaultHoverGlow', '--buttonDefaultBevel'] + } + }, + { + state: ['focused'], + directives: { + shadow: ['--buttonDefaultFocusGlow', '--buttonDefaultBevel'] } }, { state: ['pressed'], directives: { - shadow: ['--defaultButtonShadow', '--pressedButtonBevel'] + shadow: ['--buttonDefaultShadow', '--buttonPressedBevel'] } }, { - state: ['hover', 'pressed'], + state: ['pressed', 'hover'], directives: { - shadow: ['--defaultButtonHoverGlow', '--pressedButtonBevel'] + shadow: ['--buttonPressedBevel', '--buttonDefaultHoverGlow'] } }, { state: ['toggled'], directives: { background: '--inheritedBackground,-14.2', - shadow: ['--defaultButtonShadow', '--pressedButtonBevel'] + shadow: ['--buttonDefaultShadow', '--buttonPressedBevel'] } }, { state: ['toggled', 'hover'], directives: { background: '--inheritedBackground,-14.2', - shadow: ['--defaultButtonHoverGlow', '--pressedButtonBevel'] + shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel'] + } + }, + { + state: ['toggled', 'disabled'], + directives: { + background: '$blend(--inheritedBackground 0.25 --parent)', + shadow: ['--buttonPressedBevel'] } }, { state: ['disabled'], directives: { - background: '$blend(--inheritedBackground, 0.25, --parent)', - shadow: ['--defaultButtonBevel'] + background: '$blend(--inheritedBackground 0.25 --parent)', + shadow: ['--buttonDefaultBevel'] } }, { diff --git a/src/components/button_unstyled.style.js b/src/components/button_unstyled.style.js index 65b5c57b..a4f0f6f9 100644 --- a/src/components/button_unstyled.style.js +++ b/src/components/button_unstyled.style.js @@ -1,6 +1,7 @@ export default { name: 'ButtonUnstyled', selector: '.button-unstyled', + notEditable: true, states: { toggled: '.toggled', disabled: ':disabled', diff --git a/src/components/color_input/color_input.scss b/src/components/color_input/color_input.scss index 19c88a69..3128381f 100644 --- a/src/components/color_input/color_input.scss +++ b/src/components/color_input/color_input.scss @@ -5,6 +5,10 @@ flex: 1 1 auto; } + .opt { + margin-right: 0.5em; + } + &-field.input { display: inline-flex; flex: 0 0 0; diff --git a/src/components/color_input/color_input.vue b/src/components/color_input/color_input.vue index b6e84629..418fb18c 100644 --- a/src/components/color_input/color_input.vue +++ b/src/components/color_input/color_input.vue @@ -11,11 +11,11 @@ {{ label }}
-
- - -
-
+ + + + + + + +
+
+ {{ $t('settings.style.themes3.editor.test_string') }} +
+
+
+ {{ $t('settings.style.themes3.editor.invalid') }} +
+
+
+
+ + {{ $t('settings.style.shadows.light_grid') }} + +
+ + +
+ +
- - - - {{ $t('settings.style.shadows.light_grid') }} - -
+ - diff --git a/src/components/contrast_ratio/contrast_ratio.vue b/src/components/contrast_ratio/contrast_ratio.vue index bbd6fd4a..2d3c5c83 100644 --- a/src/components/contrast_ratio/contrast_ratio.vue +++ b/src/components/contrast_ratio/contrast_ratio.vue @@ -3,39 +3,44 @@ v-if="contrast" class="contrast-ratio" > - + {{ contrast.text }} + + - + - + - + - - + - + - + - + - + + + diff --git a/src/components/rich_content/rich_content.style.js b/src/components/rich_content/rich_content.style.js index c8314000..d9cd17ae 100644 --- a/src/components/rich_content/rich_content.style.js +++ b/src/components/rich_content/rich_content.style.js @@ -1,6 +1,7 @@ export default { name: 'RichContent', selector: '.RichContent', + notEditable: true, validInnerComponents: [ 'Text', 'FunText', diff --git a/src/components/root.style.js b/src/components/root.style.js index 4bd735aa..25b2b665 100644 --- a/src/components/root.style.js +++ b/src/components/root.style.js @@ -1,6 +1,7 @@ export default { name: 'Root', selector: ':root', + notEditable: true, validInnerComponents: [ 'Underlay', 'Modals', @@ -42,7 +43,7 @@ export default { // Selection colors '--selectionBackground': 'color | --accent', - '--selectionText': 'color | $textColor(--accent, --text, no-preserve)' + '--selectionText': 'color | $textColor(--accent --text no-preserve)' } } ] diff --git a/src/components/roundness_input/roundness_input.vue b/src/components/roundness_input/roundness_input.vue new file mode 100644 index 00000000..022a184e --- /dev/null +++ b/src/components/roundness_input/roundness_input.vue @@ -0,0 +1,51 @@ + + + diff --git a/src/components/scrollbar.style.js b/src/components/scrollbar.style.js index 94e6135d..1168f67d 100644 --- a/src/components/scrollbar.style.js +++ b/src/components/scrollbar.style.js @@ -1,6 +1,7 @@ export default { name: 'Scrollbar', - selector: '::-webkit-scrollbar', + selector: ['::-webkit-scrollbar-button', '::-webkit-scrollbar-thumb', '::-webkit-resizer'], + notEditable: true, // for now defaultRules: [ { directives: { diff --git a/src/components/scrollbar_element.style.js b/src/components/scrollbar_element.style.js index da942ab2..caa239aa 100644 --- a/src/components/scrollbar_element.style.js +++ b/src/components/scrollbar_element.style.js @@ -31,6 +31,7 @@ const hoverGlow = { export default { name: 'ScrollbarElement', selector: '::-webkit-scrollbar-button', + notEditable: true, // for now states: { pressed: ':active', hover: ':hover:not(:disabled)', @@ -82,7 +83,7 @@ export default { { state: ['disabled'], directives: { - background: '$blend(--inheritedBackground, 0.25, --parent)', + background: '$blend(--inheritedBackground 0.25 --parent)', shadow: [...buttonInsetFakeBorders] } }, diff --git a/src/components/select/select.vue b/src/components/select/select.vue index 0fb6fcc0..2214959f 100644 --- a/src/components/select/select.vue +++ b/src/components/select/select.vue @@ -49,6 +49,7 @@ label.Select { option { background-color: transparent; + &:checked, &.-active { color: var(--selectionText); background-color: var(--selectionBackground); diff --git a/src/components/select/select_motion.vue b/src/components/select/select_motion.vue new file mode 100644 index 00000000..45e278fc --- /dev/null +++ b/src/components/select/select_motion.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/src/components/settings_modal/helpers/setting.js b/src/components/settings_modal/helpers/setting.js index 3b3e6268..2dc9653e 100644 --- a/src/components/settings_modal/helpers/setting.js +++ b/src/components/settings_modal/helpers/setting.js @@ -10,9 +10,13 @@ export default { ProfileSettingIndicator }, props: { + modelValue: { + type: String, + default: null + }, path: { type: [String, Array], - required: true + required: false }, disabled: { type: Boolean, @@ -68,7 +72,7 @@ export default { } }, created () { - if (this.realDraftMode && this.realSource !== 'admin') { + if (this.realDraftMode && (this.realSource !== 'admin' || this.path == null)) { this.draft = this.state } }, @@ -76,14 +80,14 @@ export default { draft: { // TODO allow passing shared draft object? get () { - if (this.realSource === 'admin') { + if (this.realSource === 'admin' || this.path == null) { return get(this.$store.state.adminSettings.draft, this.canonPath) } else { return this.localDraft } }, set (value) { - if (this.realSource === 'admin') { + if (this.realSource === 'admin' || this.path == null) { this.$store.commit('updateAdminDraft', { path: this.canonPath, value }) } else { this.localDraft = value @@ -91,6 +95,9 @@ export default { } }, state () { + if (this.path == null) { + return this.modelValue + } const value = get(this.configSource, this.canonPath) if (value === undefined) { return this.defaultState @@ -145,6 +152,9 @@ export default { return this.backendDescription?.suggestions }, shouldBeDisabled () { + if (this.path == null) { + return this.disabled + } const parentValue = this.parentPath !== undefined ? get(this.configSource, this.parentPath) : null return this.disabled || (parentValue !== null ? (this.parentInvert ? parentValue : !parentValue) : false) }, @@ -159,6 +169,9 @@ export default { } }, configSink () { + if (this.path == null) { + return (k, v) => this.$emit('update:modelValue', v) + } switch (this.realSource) { case 'profile': return (k, v) => this.$store.dispatch('setProfileOption', { name: k, value: v }) @@ -184,6 +197,7 @@ export default { return this.realSource === 'profile' }, isChanged () { + if (this.path == null) return false switch (this.realSource) { case 'profile': case 'admin': @@ -193,9 +207,11 @@ export default { } }, canonPath () { + if (this.path == null) return null return Array.isArray(this.path) ? this.path : this.path.split('.') }, isDirty () { + if (this.path == null) return false if (this.realSource === 'admin' && this.canonPath.length > 3) { return false // should not show draft buttons for "grouped" values } else { diff --git a/src/components/settings_modal/helpers/string_setting.vue b/src/components/settings_modal/helpers/string_setting.vue index 7b30d1b9..d0c70b15 100644 --- a/src/components/settings_modal/helpers/string_setting.vue +++ b/src/components/settings_modal/helpers/string_setting.vue @@ -5,6 +5,7 @@ > + {{ ' ' }} {{ ' ' }} - - - {{ getUnitString(option) }} - - + + {{ ' ' }} .UnitSetting { + .no-break { + display: inline-block; + } + .number-input { max-width: 6.5em; text-align: right; diff --git a/src/components/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss index d01553db..bd0ed452 100644 --- a/src/components/settings_modal/settings_modal.scss +++ b/src/components/settings_modal/settings_modal.scss @@ -10,6 +10,10 @@ list-style-type: none; padding-left: 2em; + .btn:not(.dropdown-button) { + padding: 0 2em; + } + li { margin-bottom: 0.5em; } @@ -54,10 +58,6 @@ .btn { min-height: 2em; } - - .btn:not(.dropdown-button) { - padding: 0 2em; - } } } @@ -76,6 +76,23 @@ } } + &.-mobile { + .setting-list, + .option-list { + padding-left: 0.25em; + + > li { + margin: 1em 0; + line-height: 1.5em; + vertical-align: center; + } + + &.two-column { + column-count: 1; + } + } + } + &.peek { .settings-modal-panel { /* Explanation: diff --git a/src/components/settings_modal/settings_modal_admin_content.scss b/src/components/settings_modal/settings_modal_admin_content.scss index a5314fe1..c1a25cf1 100644 --- a/src/components/settings_modal/settings_modal_admin_content.scss +++ b/src/components/settings_modal/settings_modal_admin_content.scss @@ -17,10 +17,13 @@ } .select-multiple { + margin-top: 0.5em; display: flex; + flex-direction: column; .option-list { margin: 0; + margin-top: 0.5em; padding-left: 0.5em; } } diff --git a/src/components/settings_modal/settings_modal_user_content.js b/src/components/settings_modal/settings_modal_user_content.js index ebd5329f..2e1d9954 100644 --- a/src/components/settings_modal/settings_modal_user_content.js +++ b/src/components/settings_modal/settings_modal_user_content.js @@ -10,6 +10,7 @@ import GeneralTab from './tabs/general_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 StyleTab from './tabs/style_tab/style_tab.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { @@ -17,6 +18,7 @@ import { faUser, faFilter, faPaintBrush, + faPalette, faBell, faDownload, faEyeSlash, @@ -29,6 +31,7 @@ library.add( faUser, faFilter, faPaintBrush, + faPalette, faBell, faDownload, faEyeSlash, @@ -48,6 +51,7 @@ const SettingsModalContent = { ProfileTab, GeneralTab, AppearanceTab, + StyleTab, VersionTab, ThemeTab }, @@ -60,6 +64,12 @@ const SettingsModalContent = { }, bodyLock () { return this.$store.state.interface.settingsModalState === 'visible' + }, + expertLevel () { + return this.$store.state.config.expertLevel + }, + isMobileLayout () { + return this.$store.state.interface.layoutType === 'mobile' } }, methods: { diff --git a/src/components/settings_modal/settings_modal_user_content.scss b/src/components/settings_modal/settings_modal_user_content.scss index a5314fe1..25e9bda3 100644 --- a/src/components/settings_modal/settings_modal_user_content.scss +++ b/src/components/settings_modal/settings_modal_user_content.scss @@ -1,6 +1,21 @@ .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; + } + .setting-item { border-bottom: 2px solid var(--border); margin: 1em 1em 1.4em; @@ -8,7 +23,6 @@ > div, > label { - display: block; margin-bottom: 0.5em; &:last-child { @@ -17,10 +31,13 @@ } .select-multiple { + margin-top: 1em; display: flex; + flex-direction: column; .option-list { margin: 0; + margin-top: 0.5em; padding-left: 0.5em; } } diff --git a/src/components/settings_modal/settings_modal_user_content.vue b/src/components/settings_modal/settings_modal_user_content.vue index 1441d892..4d8981b2 100644 --- a/src/components/settings_modal/settings_modal_user_content.vue +++ b/src/components/settings_modal/settings_modal_user_content.vue @@ -21,7 +21,16 @@
+ +
+
diff --git a/src/components/settings_modal/tabs/appearance_tab.js b/src/components/settings_modal/tabs/appearance_tab.js index b5fd6c4c..7ea4a09a 100644 --- a/src/components/settings_modal/tabs/appearance_tab.js +++ b/src/components/settings_modal/tabs/appearance_tab.js @@ -3,20 +3,20 @@ import ChoiceSetting from '../helpers/choice_setting.vue' import IntegerSetting from '../helpers/integer_setting.vue' import FloatSetting from '../helpers/float_setting.vue' import UnitSetting, { defaultHorizontalUnits } from '../helpers/unit_setting.vue' +import PaletteEditor from 'src/components/palette_editor/palette_editor.vue' import FontControl from 'src/components/font_control/font_control.vue' import { normalizeThemeData } from 'src/modules/interface' -import { - getThemes -} from 'src/services/style_setter/style_setter.js' +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, getScopedVersion } from 'src/services/theme_data/css_utils.js' +import { deserialize } from 'src/services/theme_data/iss_deserializer.js' import SharedComputedObject from '../helpers/shared_computed_object.js' import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue' @@ -27,6 +27,10 @@ import { import Preview from './theme_tab/theme_preview.vue' +// helper for debugging +// eslint-disable-next-line no-unused-vars +const toValue = (x) => JSON.parse(JSON.stringify(x === undefined ? 'null' : x)) + library.add( faGlobe ) @@ -34,7 +38,28 @@ library.add( const AppearanceTab = { data () { return { - availableStyles: [], + availableThemesV3: [], + availableThemesV2: [], + bundledPalettes: [], + compilationCache: {}, + fileImporter: newImporter({ + accept: '.json, .piss', + 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, @@ -61,33 +86,69 @@ const AppearanceTab = { UnitSetting, ProfileSettingIndicator, FontControl, - Preview + Preview, + PaletteEditor }, mounted () { - getThemes() - .then((promises) => { - return Promise.all( - Object.entries(promises) - .map(([k, v]) => v.then(res => [k, res])) - ) + this.$store.dispatch('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 = this.$store.dispatch(`fetch${capitalizedResource}sIndex`) + } + + return promise.then(index => { + return Object + .entries(index) + .map(([k, func]) => [k, func()]) }) - .then(themes => themes.reduce((acc, [k, v]) => { - if (v) { - return [ - ...acc, - { - name: v.name || v[0], - key: k, - data: v - } - ] + } + + 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 => { + this.availableThemesV2.push({ key, data, name: data.name, version: 'v2' }) + })) + }) + + this.userPalette = this.$store.state.interface.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 { - return acc + palette = { key, ...v } } - }, [])) - .then((themesComplete) => { - this.availableStyles = themesComplete - }) + if (!palette.key.startsWith('style.')) { + this.bundledPalettes.push(palette) + } + })) + }) if (window.IntersectionObserver) { this.intersectionObserver = new IntersectionObserver((entries, observer) => { @@ -111,7 +172,65 @@ const AppearanceTab = { }) }) }, + watch: { + paletteDataUsed () { + this.userPalette = this.paletteDataUsed || {} + } + }, computed: { + paletteDataUsed () { + return this.$store.state.interface.paletteDataUsed + }, + availableStyles () { + return [ + ...this.availableThemesV3, + ...this.availableThemesV2 + ] + }, + availablePalettes () { + return [ + ...this.bundledPalettes, + ...this.stylePalettes + ] + }, + stylePalettes () { + const ruleset = this.$store.state.interface.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(([k, v]) => v)) + }) + return result + }, noIntersectionObserver () { return !window.IntersectionObserver }, @@ -144,15 +263,22 @@ const AppearanceTab = { this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val }) } }, + customThemeVersion () { + const { themeVersion } = this.$store.state.interface + return themeVersion + }, isCustomThemeUsed () { - const { theme } = this.mergedConfig - return theme === 'custom' || theme === null + const { customTheme, customThemeSource } = this.mergedConfig + return customTheme != null || customThemeSource != null + }, + isCustomStyleUsed (name) { + const { styleCustomData } = this.mergedConfig + return styleCustomData != null }, ...SharedComputedObject() }, methods: { updateFont (key, value) { - console.log(key, value) this.$store.dispatch('setOption', { name: 'theme3hacks', value: { @@ -164,25 +290,120 @@ const AppearanceTab = { } }) }, + importFile () { + this.fileImporter.importData() + }, + importParser (file, filename) { + if (filename.endsWith('.json')) { + return JSON.parse(file) + } else if (filename.endsWith('.piss')) { + return deserialize(file) + } + }, + onImport (parsed, filename) { + if (filename.endsWith('.json')) { + this.$store.dispatch('setThemeCustom', parsed.source || parsed.theme) + } else if (filename.endsWith('.piss')) { + this.$store.dispatch('setStyleCustom', parsed) + } + }, + onImportFailure (result) { + console.error('Failure importing theme:', result) + this.$store.dispatch('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('.piss')) { + 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) { - const { theme } = this.mergedConfig - return key === theme + 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) + }, + setStyle (name) { + this.$store.dispatch('setStyle', name) }, setTheme (name) { - this.$store.dispatch('setTheme', { themeName: name, saveData: true, recompile: true }) + this.$store.dispatch('setTheme', name) }, - previewTheme (key, input) { - const style = normalizeThemeData(input) - const x = 2 - if (x === 1) return - const theme2 = convertTheme2To3(style) - const theme3 = init({ - inputRuleset: theme2, - ultimateBackgroundColor: '#000000', - liteMode: true, - debug: true, - onlyNormalState: true - }) + setPalette (name, data) { + this.$store.dispatch('setPalette', name) + this.userPalette = data + }, + setPaletteCustom (data) { + this.$store.dispatch('setPaletteCustom', data) + this.userPalette = data + }, + resetTheming (name) { + this.$store.dispatch('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, v]) => k && k !== 'name') + .map(([k, v]) => ['--' + k, 'color | ' + v]) + ) + } + } else { + paletteRule = null + } + + theme3 = init({ + inputRuleset: [...input, paletteRule].filter(x => x), + ultimateBackgroundColor: '#000000', + liteMode: true, + debug: true, + onlyNormalState: true + }) + } + } else { + theme3 = init({ + inputRuleset: [], + ultimateBackgroundColor: '#000000', + liteMode: true, + debug: true, + onlyNormalState: true + }) + } + + if (!this.compilationCache[key]) { + this.compilationCache[key] = theme3 + } return getScopedVersion( getCssRules(theme3.eager), diff --git a/src/components/settings_modal/tabs/appearance_tab.scss b/src/components/settings_modal/tabs/appearance_tab.scss new file mode 100644 index 00000000..596c674f --- /dev/null +++ b/src/components/settings_modal/tabs/appearance_tab.scss @@ -0,0 +1,120 @@ +.appearance-tab { + .palette, + .theme-notice { + padding: 0.5em; + margin: 1em; + } + + .setting-item { + padding-bottom: 0; + + &.heading { + display: grid; + align-items: baseline; + grid-template-columns: 1fr auto auto auto; + grid-gap: 0.5em; + + h2 { + flex: 1 0 auto; + } + } + } + + .palettes { + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: 0.5em; + + h4, + .unsupported-theme-v2, + .userPalette { + grid-column: 1 / span 2; + } + } + + .palette-entry { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.5em; + + .palette-label 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 & { + .palette-entry { + flex-wrap: wrap; + justify-content: center; + } + + .palette-label { + line-height: 1.5em; + margin-top: 0.5em; + width: 100%; + } + + .palette-preview { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + grid-template-rows: 1em 1em; + margin-bottom: 0.5em; + } + } + + .theme-list { + list-style: none; + display: flex; + flex-wrap: wrap; + margin: -0.5em 0; + height: 25em; + overflow-x: hidden; + overflow-y: 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; + 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 de6eb8e7..d8d3ff15 100644 --- a/src/components/settings_modal/tabs/appearance_tab.vue +++ b/src/components/settings_modal/tabs/appearance_tab.vue @@ -1,44 +1,161 @@