aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.js7
-rw-r--r--src/App.scss240
-rw-r--r--src/App.vue9
-rw-r--r--src/_variables.scss11
-rw-r--r--src/boot/after_store.js29
-rw-r--r--src/components/attachment/attachment.js5
-rw-r--r--src/components/attachment/attachment.vue9
-rw-r--r--src/components/chat_panel/chat_panel.vue4
-rw-r--r--src/components/color_input/color_input.vue53
-rw-r--r--src/components/contrast_ratio/contrast_ratio.vue69
-rw-r--r--src/components/delete_button/delete_button.vue2
-rw-r--r--src/components/export_import/export_import.vue87
-rw-r--r--src/components/font_control/font_control.js58
-rw-r--r--src/components/font_control/font_control.vue54
-rw-r--r--src/components/instance_specific_panel/instance_specific_panel.js3
-rw-r--r--src/components/instance_specific_panel/instance_specific_panel.vue2
-rw-r--r--src/components/interface_language_switcher/interface_language_switcher.vue3
-rw-r--r--src/components/media_upload/media_upload.js9
-rw-r--r--src/components/notification/notification.js6
-rw-r--r--src/components/notification/notification.vue10
-rw-r--r--src/components/notifications/notifications.js1
-rw-r--r--src/components/notifications/notifications.scss58
-rw-r--r--src/components/notifications/notifications.vue5
-rw-r--r--src/components/opacity_input/opacity_input.vue38
-rw-r--r--src/components/post_status_form/post_status_form.js7
-rw-r--r--src/components/post_status_form/post_status_form.vue16
-rw-r--r--src/components/range_input/range_input.vue48
-rw-r--r--src/components/registration/registration.js84
-rw-r--r--src/components/registration/registration.vue146
-rw-r--r--src/components/settings/settings.js13
-rw-r--r--src/components/settings/settings.vue44
-rw-r--r--src/components/shadow_control/shadow_control.js87
-rw-r--r--src/components/shadow_control/shadow_control.vue243
-rw-r--r--src/components/status/status.js9
-rw-r--r--src/components/status/status.vue40
-rw-r--r--src/components/style_switcher/preview.vue78
-rw-r--r--src/components/style_switcher/style_switcher.js672
-rw-r--r--src/components/style_switcher/style_switcher.scss335
-rw-r--r--src/components/style_switcher/style_switcher.vue538
-rw-r--r--src/components/tab_switcher/tab_switcher.jsx13
-rw-r--r--src/components/tab_switcher/tab_switcher.scss36
-rw-r--r--src/components/timeline/timeline.js5
-rw-r--r--src/components/timeline/timeline.vue16
-rw-r--r--src/components/user_card/user_card.js3
-rw-r--r--src/components/user_card/user_card.vue4
-rw-r--r--src/components/user_card_content/user_card_content.js32
-rw-r--r--src/components/user_card_content/user_card_content.vue170
-rw-r--r--src/components/user_finder/user_finder.vue4
-rw-r--r--src/components/user_panel/user_panel.js1
-rw-r--r--src/components/user_panel/user_panel.vue2
-rw-r--r--src/components/user_profile/user_profile.vue18
-rw-r--r--src/components/user_settings/user_settings.js121
-rw-r--r--src/components/user_settings/user_settings.vue52
-rw-r--r--src/i18n/en.json149
-rw-r--r--src/i18n/ru.json128
-rw-r--r--src/lib/persisted_state.js1
-rw-r--r--src/main.js36
-rw-r--r--src/modules/config.js8
-rw-r--r--src/modules/errors.js12
-rw-r--r--src/modules/instance.js6
-rw-r--r--src/modules/interface.js17
-rw-r--r--src/modules/users.js87
-rw-r--r--src/services/color_convert/color_convert.js110
-rw-r--r--src/services/file_size_format/file_size_format.js17
-rw-r--r--src/services/push/push.js69
-rw-r--r--src/services/style_setter/style_setter.js495
-rw-r--r--src/services/user_highlighter/user_highlighter.js2
-rw-r--r--src/sw.js38
68 files changed, 3885 insertions, 899 deletions
diff --git a/src/App.js b/src/App.js
index c5a15cf6..4f3fd798 100644
--- a/src/App.js
+++ b/src/App.js
@@ -61,7 +61,12 @@ export default {
})
},
logo () { return this.$store.state.instance.logo },
- style () { return { 'background-image': `url(${this.background})` } },
+ style () {
+ return {
+ '--body-background-image': `url(${this.background})`,
+ 'background-image': `url(${this.background})`
+ }
+ },
sitename () { return this.$store.state.instance.name },
chat () { return this.$store.state.chat.channel.state === 'joined' },
suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled },
diff --git a/src/App.scss b/src/App.scss
index 6f0ee003..7f33cd51 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -34,10 +34,11 @@ h4 {
body {
font-family: sans-serif;
+ font-family: var(--interfaceFont, sans-serif);
font-size: 14px;
margin: 0;
- color: $fallback--fg;
- color: var(--fg, $fallback--fg);
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
max-width: 100vw;
overflow-x: hidden;
}
@@ -50,19 +51,24 @@ a {
button {
user-select: none;
- color: $fallback--fg;
- color: var(--fg, $fallback--fg);
- background-color: $fallback--btn;
- background-color: var(--btn, $fallback--btn);
+ color: $fallback--text;
+ color: var(--btnText, $fallback--text);
+ background-color: $fallback--fg;
+ background-color: var(--btn, $fallback--fg);
border: none;
border-radius: $fallback--btnRadius;
border-radius: var(--btnRadius, $fallback--btnRadius);
cursor: pointer;
- border-top: 1px solid rgba(255, 255, 255, 0.2);
- border-bottom: 1px solid rgba(0, 0, 0, 0.2);
- box-shadow: 0px 0px 2px black;
+ box-shadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
+ box-shadow: var(--buttonShadow);
font-size: 14px;
font-family: sans-serif;
+ font-family: var(--interfaceFont, sans-serif);
+
+ i[class*=icon-] {
+ color: $fallback--text;
+ color: var(--btnText, $fallback--text);
+ }
&::-moz-focus-inner {
border: none;
@@ -70,11 +76,12 @@ button {
&:hover {
box-shadow: 0px 0px 4px rgba(255, 255, 255, 0.3);
+ box-shadow: var(--buttonHoverShadow);
}
&:active {
- border-bottom: 1px solid rgba(255, 255, 255, 0.2);
- border-top: 1px solid rgba(0, 0, 0, 0.2);
+ box-shadow: 0px 0px 4px 0px rgba(255, 255, 255, 0.3), 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset;
+ box-shadow: var(--buttonPressedShadow);
}
&:disabled {
@@ -99,32 +106,37 @@ input, textarea, .select {
border: none;
border-radius: $fallback--inputRadius;
border-radius: var(--inputRadius, $fallback--inputRadius);
- border-bottom: 1px solid rgba(255, 255, 255, 0.2);
- border-top: 1px solid rgba(0, 0, 0, 0.2);
- box-shadow: 0px 0px 2px black inset;
- background-color: $fallback--input;
- background-color: var(--input, $fallback--input);
- color: $fallback--lightFg;
- color: var(--lightFg, $fallback--lightFg);
+ box-shadow: 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px 0px 2px 0px rgba(0, 0, 0, 1) inset;
+ box-shadow: var(--inputShadow);
+ background-color: $fallback--fg;
+ background-color: var(--input, $fallback--fg);
+ color: $fallback--lightText;
+ color: var(--inputText, $fallback--lightText);
font-family: sans-serif;
+ font-family: var(--inputFont, sans-serif);
font-size: 14px;
- padding: 8px 7px;
+ padding: 8px .5em;
box-sizing: border-box;
display: inline-block;
position: relative;
- height: 29px;
+ height: 28px;
line-height: 16px;
hyphens: none;
+ &:disabled, &[disabled=disabled] {
+ cursor: not-allowed;
+ opacity: 0.5;
+ }
+
.icon-down-open {
position: absolute;
top: 0;
bottom: 0;
right: 5px;
height: 100%;
- color: $fallback--fg;
- color: var(--fg, $fallback--fg);
- line-height: 29px;
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ line-height: 28px;
z-index: 0;
pointer-events: none;
}
@@ -135,22 +147,33 @@ input, textarea, .select {
appearance: none;
background: transparent;
border: none;
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
margin: 0;
- color: $fallback--fg;
- color: var(--fg, $fallback--fg);
- padding: 4px 2em 3px 3px;
+ padding: 0 2em 0 .2em;
+ font-family: sans-serif;
+ font-family: var(--inputFont, sans-serif);
+ font-size: 14px;
width: 100%;
z-index: 1;
- height: 29px;
+ height: 28px;
line-height: 16px;
}
+ &[type=range] {
+ background: none;
+ border: none;
+ margin: 0;
+ box-shadow: none;
+ flex: 1;
+ }
+
&[type=radio],
&[type=checkbox] {
display: none;
&:checked + label::before {
- color: $fallback--fg;
- color: var(--fg, $fallback--fg);
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
}
&:disabled,
{
@@ -166,14 +189,13 @@ input, textarea, .select {
transition: color 200ms;
width: 1.1em;
height: 1.1em;
- border-radius: $fallback--checkBoxRadius;
- border-radius: var(--checkBoxRadius, $fallback--checkBoxRadius);
- border-bottom: 1px solid rgba(255, 255, 255, 0.2);
- border-top: 1px solid rgba(0, 0, 0, 0.2);
+ border-radius: $fallback--checkboxRadius;
+ border-radius: var(--checkboxRadius, $fallback--checkboxRadius);
box-shadow: 0px 0px 2px black inset;
+ box-shadow: var(--inputShadow);
margin-right: .5em;
- background-color: $fallback--input;
- background-color: var(--input, $fallback--input);
+ background-color: $fallback--fg;
+ background-color: var(--input, $fallback--fg);
vertical-align: top;
text-align: center;
line-height: 1.1em;
@@ -187,8 +209,8 @@ input, textarea, .select {
}
option {
- color: $fallback--fg;
- color: var(--fg, $fallback--fg);
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
}
@@ -206,24 +228,23 @@ i[class*=icon-] {
padding: 0 10px 0 10px;
}
-.gaps {
- margin: -1em 0 0 -1em;
-}
-
.item {
flex: 1;
line-height: 50px;
height: 50px;
overflow: hidden;
+ display: flex;
+ flex-wrap: wrap;
.nav-icon {
font-size: 1.1em;
margin-left: 0.4em;
}
-}
-.gaps > .item {
- padding: 1em 0 0 1em;
+ &.right {
+ justify-content: flex-end;
+ padding-right: 20px;
+ }
}
.auto-size {
@@ -257,7 +278,7 @@ nav {
mask-position: center;
mask-size: contain;
background-color: $fallback--fg;
- background-color: var(--fg, $fallback--fg);
+ background-color: var(--topBarText, $fallback--fg);
position: absolute;
top: 0;
bottom: 0;
@@ -274,17 +295,15 @@ nav {
}
.inner-nav {
- padding-left: 20px;
- padding-right: 20px;
display: flex;
align-items: center;
flex-basis: 970px;
margin: auto;
height: 50px;
- a i {
+ a, a i {
color: $fallback--link;
- color: var(--link, $fallback--link);
+ color: var(--topBarLink, $fallback--link);
}
}
}
@@ -307,15 +326,33 @@ main-router {
.panel {
display: flex;
+ position: relative;
+
flex-direction: column;
margin: 0.5em;
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
- border-radius: $fallback--panelRadius;
- border-radius: var(--panelRadius, $fallback--panelRadius);
- box-shadow: 1px 1px 4px rgba(0,0,0,.6);
+ &::after, & {
+ border-radius: $fallback--panelRadius;
+ border-radius: var(--panelRadius, $fallback--panelRadius);
+ }
+
+ &::after {
+ content: '';
+ position: absolute;
+
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+
+ pointer-events: none;
+
+ box-shadow: 1px 1px 4px rgba(0,0,0,.6);
+ box-shadow: var(--panelShadow);
+ }
}
.panel-body:empty::before {
@@ -333,15 +370,23 @@ main-router {
padding: .6em .6em;
text-align: left;
line-height: 28px;
- background-color: $fallback--btn;
- background-color: var(--btn, $fallback--btn);
+ color: var(--panelText);
+ background-color: $fallback--fg;
+ background-color: var(--panel, $fallback--fg);
align-items: baseline;
+ box-shadow: var(--panelHeaderShadow);
.title {
flex: 1 0 auto;
font-size: 1.3em;
}
+ .faint {
+ background-color: transparent;
+ color: $fallback--faint;
+ color: var(--panelFaint, $fallback--faint);
+ }
+
.alert {
white-space: nowrap;
text-overflow: ellipsis;
@@ -362,6 +407,11 @@ main-router {
min-width: 1px;
align-self: stretch;
}
+
+ a {
+ color: $fallback--link;
+ color: var(--panelLink, $fallback--link)
+ }
}
.panel-heading.stub {
@@ -372,6 +422,11 @@ main-router {
.panel-footer {
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
+
+ a {
+ color: $fallback--link;
+ color: var(--panelLink, $fallback--link)
+ }
}
.panel-body > p {
@@ -390,11 +445,30 @@ main-router {
nav {
z-index: 1000;
- background-color: $fallback--btn;
- background-color: var(--btn, $fallback--btn);
+ color: var(--topBarText);
+ background-color: $fallback--fg;
+ background-color: var(--topBar, $fallback--fg);
color: $fallback--faint;
color: var(--faint, $fallback--faint);
box-shadow: 0px 0px 4px rgba(0,0,0,.6);
+ box-shadow: var(--topBarShadow);
+
+ .back-button {
+ display: block;
+ max-width: 99px;
+ transition-property: opacity, max-width;
+ transition-duration: 300ms;
+ transition-timing-function: ease-out;
+
+ i {
+ margin: 0 1em;
+ }
+
+ &.hidden {
+ opacity: 0;
+ max-width: 20px;
+ }
+ }
}
.fade-enter-active, .fade-leave-active {
@@ -429,6 +503,7 @@ nav {
display: none;
width: 100%;
height: 46px;
+
button {
display: block;
flex: 1;
@@ -442,6 +517,16 @@ nav {
body {
overflow-y: scroll;
}
+
+ nav {
+ .back-button {
+ display: none;
+ }
+ .site-name {
+ padding-left: 20px;
+ }
+ }
+
.sidebar-bounds {
overflow: hidden;
max-height: 100vh;
@@ -468,20 +553,46 @@ nav {
flex-grow: 0;
}
}
+.badge {
+ display: inline-block;
+ border-radius: 99px;
+ min-width: 22px;
+ max-width: 22px;
+ min-height: 22px;
+ max-height: 22px;
+ font-size: 15px;
+ line-height: 22px;
+ text-align: center;
+ vertical-align: middle;
+ white-space: nowrap;
+ padding: 0;
+
+ &.badge-notification {
+ background-color: $fallback--cRed;
+ background-color: var(--badgeNotification, $fallback--cRed);
+ color: white;
+ color: var(--badgeNotificationText, white);
+ }
+}
.alert {
margin: 0.35em;
padding: 0.25em;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
- color: $fallback--faint;
- color: var(--faint, $fallback--faint);
min-height: 28px;
line-height: 28px;
&.error {
- background-color: $fallback--cAlertRed;
- background-color: var(--cAlertRed, $fallback--cAlertRed);
+ background-color: $fallback--alertError;
+ background-color: var(--alertError, $fallback--alertError);
+ color: $fallback--text;
+ color: var(--alertErrorText, $fallback--text);
+
+ .panel-heading & {
+ color: $fallback--text;
+ color: var(--alertErrorPanelText, $fallback--text);
+ }
}
}
@@ -513,19 +624,14 @@ nav {
}
}
-.item.right {
- text-align: right;
- padding-right: 20px;
-}
-
.visibility-tray {
font-size: 1.2em;
padding: 3px;
cursor: pointer;
.selected {
- color: $fallback--lightFg;
- color: var(--lightFg, $fallback--lightFg);
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
}
.text-format {
diff --git a/src/App.vue b/src/App.vue
index 8ba4eb1f..a3a7ecf6 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -7,7 +7,10 @@
</div>
<div class='inner-nav'>
<div class='item'>
- <router-link :to="{ name: 'root'}">{{sitename}}</router-link>
+ <router-link class="back-button" @click.native="activatePanel('timeline')" :to="{ name: 'root' }" active-class="hidden">
+ <i class="icon-left-open" :title="$t('nav.back')"></i>
+ </router-link>
+ <router-link class="site-name" :to="{ name: 'root' }" active-class="home">{{sitename}}</router-link>
</div>
<div class='item right'>
<user-finder class="nav-icon" @toggled="onFinderToggled"></user-finder>
@@ -25,12 +28,12 @@
<div class="sidebar-bounds">
<div class="sidebar-scroller">
<div class="sidebar">
- <user-panel></user-panel>
+ <user-panel :activatePanel="activatePanel"></user-panel>
<nav-panel :activatePanel="activatePanel"></nav-panel>
<instance-specific-panel v-if="showInstanceSpecificPanel"></instance-specific-panel>
<features-panel v-if="!currentUser"></features-panel>
<who-to-follow-panel v-if="currentUser && suggestionsEnabled"></who-to-follow-panel>
- <notifications v-if="currentUser"></notifications>
+ <notifications :activatePanel="activatePanel" v-if="currentUser"></notifications>
</div>
</div>
</div>
diff --git a/src/_variables.scss b/src/_variables.scss
index b5222a6a..150e4fb5 100644
--- a/src/_variables.scss
+++ b/src/_variables.scss
@@ -3,24 +3,23 @@ $main-background: white;
$darkened-background: whitesmoke;
$fallback--bg: #121a24;
-$fallback--btn: #182230;
-$fallback--input: #182230;
+$fallback--fg: #182230;
$fallback--faint: rgba(185, 185, 186, .5);
-$fallback--fg: #b9b9ba;
+$fallback--text: #b9b9ba;
$fallback--link: #d8a070;
$fallback--icon: #666;
$fallback--lightBg: rgb(21, 30, 42);
-$fallback--lightFg: #b9b9ba;
+$fallback--lightText: #b9b9ba;
$fallback--border: #222;
$fallback--cRed: #ff0000;
$fallback--cBlue: #0095ff;
$fallback--cGreen: #0fa00f;
$fallback--cOrange: orange;
-$fallback--cAlertRed: rgba(211,16,20,.5);
+$fallback--alertError: rgba(211,16,20,.5);
$fallback--panelRadius: 10px;
-$fallback--checkBoxRadius: 2px;
+$fallback--checkboxRadius: 2px;
$fallback--btnRadius: 4px;
$fallback--inputRadius: 4px;
$fallback--tooltipRadius: 5px;
diff --git a/src/boot/after_store.js b/src/boot/after_store.js
index ea5d4ecd..24a8d3ac 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -17,17 +17,29 @@ import FollowRequests from '../components/follow_requests/follow_requests.vue'
import OAuthCallback from '../components/oauth_callback/oauth_callback.vue'
import UserSearch from '../components/user_search/user_search.vue'
-const afterStoreSetup = ({store, i18n}) => {
+const afterStoreSetup = ({ store, i18n }) => {
window.fetch('/api/statusnet/config.json')
.then((res) => res.json())
.then((data) => {
- const {name, closed: registrationClosed, textlimit, server} = data.site
+ const { name, closed: registrationClosed, textlimit, uploadlimit, server, vapidPublicKey } = data.site
store.dispatch('setInstanceOption', { name: 'name', value: name })
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: (registrationClosed === '0') })
store.dispatch('setInstanceOption', { name: 'textlimit', value: parseInt(textlimit) })
+ store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadlimit.uploadlimit) })
+ store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadlimit.avatarlimit) })
+ store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadlimit.backgroundlimit) })
+ store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadlimit.bannerlimit) })
store.dispatch('setInstanceOption', { name: 'server', value: server })
+ if (data.nsfwCensorImage) {
+ store.dispatch('setInstanceOption', { name: 'nsfwCensorImage', value: data.nsfwCensorImage })
+ }
+
+ if (vapidPublicKey) {
+ store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
+ }
+
var apiConfig = data.site.pleromafe
window.fetch('/static/config.json')
@@ -38,8 +50,17 @@ const afterStoreSetup = ({store, i18n}) => {
return {}
})
.then((staticConfig) => {
+ const overrides = window.___pleromafe_dev_overrides || {}
+ const env = window.___pleromafe_mode.NODE_ENV
+
// This takes static config and overrides properties that are present in apiConfig
- var config = Object.assign({}, staticConfig, apiConfig)
+ let config = {}
+ if (overrides.staticConfigPreference && env === 'development') {
+ console.warn('OVERRIDING API CONFIG WITH STATIC CONFIG')
+ config = Object.assign({}, apiConfig, staticConfig)
+ } else {
+ config = Object.assign({}, staticConfig, apiConfig)
+ }
var theme = (config.theme)
var background = (config.background)
@@ -58,6 +79,7 @@ const afterStoreSetup = ({store, i18n}) => {
var loginMethod = (config.loginMethod)
var scopeCopy = (config.scopeCopy)
var subjectLineBehavior = (config.subjectLineBehavior)
+ var alwaysShowSubjectInput = (config.alwaysShowSubjectInput)
store.dispatch('setInstanceOption', { name: 'theme', value: theme })
store.dispatch('setInstanceOption', { name: 'background', value: background })
@@ -75,6 +97,7 @@ const afterStoreSetup = ({store, i18n}) => {
store.dispatch('setInstanceOption', { name: 'loginMethod', value: loginMethod })
store.dispatch('setInstanceOption', { name: 'scopeCopy', value: scopeCopy })
store.dispatch('setInstanceOption', { name: 'subjectLineBehavior', value: subjectLineBehavior })
+ store.dispatch('setInstanceOption', { name: 'alwaysShowSubjectInput', value: alwaysShowSubjectInput })
if (chatDisabled) {
store.dispatch('disableChat')
}
diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js
index 41730720..97c4f283 100644
--- a/src/components/attachment/attachment.js
+++ b/src/components/attachment/attachment.js
@@ -11,8 +11,9 @@ const Attachment = {
],
data () {
return {
- nsfwImage,
+ nsfwImage: this.$store.state.config.nsfwCensorImage || nsfwImage,
hideNsfwLocal: this.$store.state.config.hideNsfw,
+ preloadImage: this.$store.state.config.preloadImage,
loopVideo: this.$store.state.config.loopVideo,
showHidden: false,
loading: false,
@@ -46,7 +47,7 @@ const Attachment = {
}
},
toggleHidden () {
- if (this.img) {
+ if (this.img && !this.preloadImage) {
if (this.img.onload) {
this.img.onload()
} else {
diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue
index 8795b131..5eaa0d1d 100644
--- a/src/components/attachment/attachment.vue
+++ b/src/components/attachment/attachment.vue
@@ -9,12 +9,11 @@
<div class="hider" v-if="nsfw && hideNsfwLocal && !hidden">
<a href="#" @click.prevent="toggleHidden()">Hide</a>
</div>
-
- <a v-if="type === 'image' && !hidden" class="image-attachment" :href="attachment.url" target="_blank" :title="attachment.description">
+ <a v-if="type === 'image' && (!hidden || preloadImage)" class="image-attachment" :class="{'hidden': hidden && preloadImage}" :href="attachment.url" target="_blank" :title="attachment.description">
<StillImage :class="{'small': isSmall}" referrerpolicy="no-referrer" :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url"/>
</a>
- <video :class="{'small': isSmall}" v-if="type === 'video' && !hidden" @loadeddata="onVideoDataLoad" :src="attachment.url" controls :loop="loopVideo"></video>
+ <video :class="{'small': isSmall}" v-if="type === 'video' && !hidden" @loadeddata="onVideoDataLoad" :src="attachment.url" controls :loop="loopVideo" playsinline></video>
<audio v-if="type === 'audio'" :src="attachment.url" controls></audio>
@@ -161,6 +160,10 @@
display: flex;
flex: 1;
+ &.hidden {
+ display: none;
+ }
+
.still-image {
width: 100%;
height: 100%;
diff --git a/src/components/chat_panel/chat_panel.vue b/src/components/chat_panel/chat_panel.vue
index 30070d3e..f174319a 100644
--- a/src/components/chat_panel/chat_panel.vue
+++ b/src/components/chat_panel/chat_panel.vue
@@ -55,8 +55,8 @@
.chat-heading {
cursor: pointer;
.icon-comment-empty {
- color: $fallback--fg;
- color: var(--fg, $fallback--fg);
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
}
}
diff --git a/src/components/color_input/color_input.vue b/src/components/color_input/color_input.vue
new file mode 100644
index 00000000..34eec248
--- /dev/null
+++ b/src/components/color_input/color_input.vue
@@ -0,0 +1,53 @@
+<template>
+<div class="color-control style-control" :class="{ disabled: !present || disabled }">
+ <label :for="name" class="label">
+ {{label}}
+ </label>
+ <input
+ v-if="typeof fallback !== 'undefined'"
+ class="opt exlcude-disabled"
+ :id="name + '-o'"
+ type="checkbox"
+ :checked="present"
+ @input="$emit('input', typeof value === 'undefined' ? fallback : undefined)">
+ <label v-if="typeof fallback !== 'undefined'" class="opt-l" :for="name + '-o'"></label>
+ <input
+ :id="name"
+ class="color-input"
+ type="color"
+ :value="value || fallback"
+ :disabled="!present || disabled"
+ @input="$emit('input', $event.target.value)"
+ >
+ <input
+ :id="name + '-t'"
+ class="text-input"
+ type="text"
+ :value="value || fallback"
+ :disabled="!present || disabled"
+ @input="$emit('input', $event.target.value)"
+ >
+</div>
+</template>
+
+<script>
+export default {
+ props: [
+ 'name', 'label', 'value', 'fallback', 'disabled'
+ ],
+ computed: {
+ present () {
+ return typeof this.value !== 'undefined'
+ }
+ }
+}
+</script>
+
+<style lang="scss">
+.color-control {
+ input.text-input {
+ max-width: 7em;
+ flex: 1;
+ }
+}
+</style>
diff --git a/src/components/contrast_ratio/contrast_ratio.vue b/src/components/contrast_ratio/contrast_ratio.vue
new file mode 100644
index 00000000..bd971d00
--- /dev/null
+++ b/src/components/contrast_ratio/contrast_ratio.vue
@@ -0,0 +1,69 @@
+<template>
+<span v-if="contrast" class="contrast-ratio">
+ <span :title="hint" class="rating">
+ <span v-if="contrast.aaa">
+ <i class="icon-thumbs-up-alt"/>
+ </span>
+ <span v-if="!contrast.aaa && contrast.aa">
+ <i class="icon-adjust"/>
+ </span>
+ <span v-if="!contrast.aaa && !contrast.aa">
+ <i class="icon-attention"/>
+ </span>
+ </span>
+ <span class="rating" v-if="contrast && large" :title="hint_18pt">
+ <span v-if="contrast.laaa">
+ <i class="icon-thumbs-up-alt"/>
+ </span>
+ <span v-if="!contrast.laaa && contrast.laa">
+ <i class="icon-adjust"/>
+ </span>
+ <span v-if="!contrast.laaa && !contrast.laa">
+ <i class="icon-attention"/>
+ </span>
+ </span>
+</span>
+</template>
+
+<script>
+export default {
+ props: [
+ 'large', 'contrast'
+ ],
+ computed: {
+ hint () {
+ const levelVal = this.contrast.aaa ? 'aaa' : (this.contrast.aa ? 'aa' : 'bad')
+ const level = this.$t(`settings.style.common.contrast.level.${levelVal}`)
+ const context = this.$t('settings.style.common.contrast.context.text')
+ const ratio = this.contrast.text
+ return this.$t('settings.style.common.contrast.hint', { level, context, ratio })
+ },
+ hint_18pt () {
+ const levelVal = this.contrast.laaa ? 'aaa' : (this.contrast.laa ? 'aa' : 'bad')
+ const level = this.$t(`settings.style.common.contrast.level.${levelVal}`)
+ const context = this.$t('settings.style.common.contrast.context.18pt')
+ const ratio = this.contrast.text
+ return this.$t('settings.style.common.contrast.hint', { level, context, ratio })
+ }
+ }
+}
+</script>
+
+<style lang="scss">
+.contrast-ratio {
+ display: flex;
+ justify-content: flex-end;
+
+ margin-top: -4px;
+ margin-bottom: 5px;
+
+ .label {
+ margin-right: 1em;
+ }
+
+ .rating {
+ display: inline-block;
+ text-align: center;
+ }
+}
+</style>
diff --git a/src/components/delete_button/delete_button.vue b/src/components/delete_button/delete_button.vue
index d13547e2..b458b0dc 100644
--- a/src/components/delete_button/delete_button.vue
+++ b/src/components/delete_button/delete_button.vue
@@ -14,8 +14,8 @@
.icon-cancel,.delete-status {
cursor: pointer;
&:hover {
- color: var(--cRed, $fallback--cRed);
color: $fallback--cRed;
+ color: var(--cRed, $fallback--cRed);
}
}
</style>
diff --git a/src/components/export_import/export_import.vue b/src/components/export_import/export_import.vue
new file mode 100644
index 00000000..451a2668
--- /dev/null
+++ b/src/components/export_import/export_import.vue
@@ -0,0 +1,87 @@
+<template>
+<div class="import-export-container">
+ <slot name="before"/>
+ <button class="btn" @click="exportData">{{ exportLabel }}</button>
+ <button class="btn" @click="importData">{{ importLabel }}</button>
+ <slot name="afterButtons"/>
+ <p v-if="importFailed" class="alert error">{{ importFailedText }}</p>
+ <slot name="afterError"/>
+</div>
+</template>
+
+<script>
+export default {
+ props: [
+ 'exportObject',
+ 'importLabel',
+ 'exportLabel',
+ 'importFailedText',
+ 'validator',
+ 'onImport',
+ 'onImportFailure'
+ ],
+ data () {
+ return {
+ importFailed: false
+ }
+ },
+ methods: {
+ exportData () {
+ const stringified = JSON.stringify(this.exportObject) // Pretty-print and indent with 2 spaces
+
+ // Create an invisible link with a data url and simulate a click
+ const e = document.createElement('a')
+ e.setAttribute('download', 'pleroma_theme.json')
+ e.setAttribute('href', 'data:application/json;base64,' + window.btoa(stringified))
+ e.style.display = 'none'
+
+ document.body.appendChild(e)
+ e.click()
+ document.body.removeChild(e)
+ },
+ importData () {
+ this.importFailed = false
+ const filePicker = document.createElement('input')
+ filePicker.setAttribute('type', 'file')
+ filePicker.setAttribute('accept', '.json')
+
+ filePicker.addEventListener('change', event => {
+ if (event.target.files[0]) {
+ // eslint-disable-next-line no-undef
+ const reader = new FileReader()
+ reader.onload = ({target}) => {
+ try {
+ const parsed = JSON.parse(target.result)
+ const valid = this.validator(parsed)
+ if (valid) {
+ this.onImport(parsed)
+ } else {
+ this.importFailed = true
+ // this.onImportFailure(valid)
+ }
+ } catch (e) {
+ // This will happen both if there is a JSON syntax error or the theme is missing components
+ this.importFailed = true
+ // this.onImportFailure(e)
+ }
+ }
+ reader.readAsText(event.target.files[0])
+ }
+ })
+
+ document.body.appendChild(filePicker)
+ filePicker.click()
+ document.body.removeChild(filePicker)
+ }
+ }
+}
+</script>
+
+<style lang="scss">
+.import-export-container {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ justify-content: center;
+}
+</style>
diff --git a/src/components/font_control/font_control.js b/src/components/font_control/font_control.js
new file mode 100644
index 00000000..8e2b0e45
--- /dev/null
+++ b/src/components/font_control/font_control.js
@@ -0,0 +1,58 @@
+import { set } from 'vue'
+
+export default {
+ props: [
+ 'name', 'label', 'value', 'fallback', 'options', 'no-inherit'
+ ],
+ data () {
+ return {
+ lValue: this.value,
+ availableOptions: [
+ this.noInherit ? '' : 'inherit',
+ 'custom',
+ ...(this.options || []),
+ 'serif',
+ 'monospace',
+ 'sans-serif'
+ ].filter(_ => _)
+ }
+ },
+ beforeUpdate () {
+ this.lValue = this.value
+ },
+ computed: {
+ present () {
+ return typeof this.lValue !== 'undefined'
+ },
+ dValue () {
+ return this.lValue || this.fallback || {}
+ },
+ family: {
+ get () {
+ return this.dValue.family
+ },
+ set (v) {
+ set(this.lValue, 'family', v)
+ this.$emit('input', this.lValue)
+ }
+ },
+ isCustom () {
+ return this.preset === 'custom'
+ },
+ preset: {
+ get () {
+ if (this.family === 'serif' ||
+ this.family === 'sans-serif' ||
+ this.family === 'monospace' ||
+ this.family === 'inherit') {
+ return this.family
+ } else {
+ return 'custom'
+ }
+ },
+ set (v) {
+ this.family = v === 'custom' ? '' : v
+ }
+ }
+ }
+}
diff --git a/src/components/font_control/font_control.vue b/src/components/font_control/font_control.vue
new file mode 100644
index 00000000..ed36b280
--- /dev/null
+++ b/src/components/font_control/font_control.vue
@@ -0,0 +1,54 @@
+<template>
+<div class="font-control style-control" :class="{ custom: isCustom }">
+ <label :for="preset === 'custom' ? name : name + '-font-switcher'" class="label">
+ {{label}}
+ </label>
+ <input
+ v-if="typeof fallback !== 'undefined'"
+ class="opt exlcude-disabled"
+ type="checkbox"
+ :id="name + '-o'"
+ :checked="present"
+ @input="$emit('input', typeof value === 'undefined' ? fallback : undefined)">
+ <label v-if="typeof fallback !== 'undefined'" class="opt-l" :for="name + '-o'"></label>
+ <label :for="name + '-font-switcher'" class="select" :disabled="!present">
+ <select
+ :disabled="!present"
+ v-model="preset"
+ class="font-switcher"
+ :id="name + '-font-switcher'">
+ <option v-for="option in availableOptions" :value="option">
+ {{ option === 'custom' ? $t('settings.style.fonts.custom') : option }}
+ </option>
+ </select>
+ <i class="icon-down-open"/>
+ </label>
+ <input
+ v-if="isCustom"
+ class="custom-font"
+ type="text"
+ :id="name"
+ v-model="family">
+</div>
+</template>
+
+<script src="./font_control.js" ></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+.font-control {
+ input.custom-font {
+ min-width: 10em;
+ }
+ &.custom {
+ .select {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+ .custom-font {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ }
+ }
+}
+</style>
diff --git a/src/components/instance_specific_panel/instance_specific_panel.js b/src/components/instance_specific_panel/instance_specific_panel.js
index 09e3d055..9bb5e945 100644
--- a/src/components/instance_specific_panel/instance_specific_panel.js
+++ b/src/components/instance_specific_panel/instance_specific_panel.js
@@ -2,6 +2,9 @@ const InstanceSpecificPanel = {
computed: {
instanceSpecificPanelContent () {
return this.$store.state.instance.instanceSpecificPanelContent
+ },
+ show () {
+ return !this.$store.state.config.hideISP
}
}
}
diff --git a/src/components/instance_specific_panel/instance_specific_panel.vue b/src/components/instance_specific_panel/instance_specific_panel.vue
index ca8e00c0..a7b74667 100644
--- a/src/components/instance_specific_panel/instance_specific_panel.vue
+++ b/src/components/instance_specific_panel/instance_specific_panel.vue
@@ -1,5 +1,5 @@
<template>
- <div class="instance-specific-panel">
+ <div v-if="show" class="instance-specific-panel">
<div class="panel panel-default">
<div class="panel-body">
<div v-html="instanceSpecificPanelContent">
diff --git a/src/components/interface_language_switcher/interface_language_switcher.vue b/src/components/interface_language_switcher/interface_language_switcher.vue
index 4b541888..3f58af2c 100644
--- a/src/components/interface_language_switcher/interface_language_switcher.vue
+++ b/src/components/interface_language_switcher/interface_language_switcher.vue
@@ -1,5 +1,8 @@
<template>
<div>
+ <label for="interface-language-switcher">
+ {{ $t('settings.interfaceLanguage') }}
+ </label>
<label for="interface-language-switcher" class='select'>
<select id="interface-language-switcher" v-model="language">
<option v-for="(langCode, i) in languageCodes" :value="langCode">
diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js
index 66337c3f..42d900d3 100644
--- a/src/components/media_upload/media_upload.js
+++ b/src/components/media_upload/media_upload.js
@@ -1,5 +1,6 @@
/* eslint-env browser */
import statusPosterService from '../../services/status_poster/status_poster.service.js'
+import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
const mediaUpload = {
mounted () {
@@ -21,6 +22,12 @@ const mediaUpload = {
uploadFile (file) {
const self = this
const store = this.$store
+ if (file.size > store.state.instance.uploadlimit) {
+ const filesize = fileSizeFormatService.fileSizeFormat(file.size)
+ const allowedsize = fileSizeFormatService.fileSizeFormat(store.state.instance.uploadlimit)
+ self.$emit('upload-failed', 'file_too_big', {filesize: filesize.num, filesizeunit: filesize.unit, allowedsize: allowedsize.num, allowedsizeunit: allowedsize.unit})
+ return
+ }
const formData = new FormData()
formData.append('media', file)
@@ -32,7 +39,7 @@ const mediaUpload = {
self.$emit('uploaded', fileData)
self.uploading = false
}, (error) => { // eslint-disable-line handle-callback-err
- self.$emit('upload-failed')
+ self.$emit('upload-failed', 'default')
self.uploading = false
})
},
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index c786f2cc..345fe3ee 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -6,11 +6,13 @@ import { highlightClass, highlightStyle } from '../../services/user_highlighter/
const Notification = {
data () {
return {
- userExpanded: false
+ userExpanded: false,
+ betterShadow: this.$store.state.interface.browserSupport.cssFilter
}
},
props: [
- 'notification'
+ 'notification',
+ 'activatePanel'
],
components: {
Status, StillImage, UserCardContent
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index 13a5c0aa..e84ce0b6 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -1,8 +1,8 @@
<template>
- <status v-if="notification.type === 'mention'" :compact="true" :statusoid="notification.status"></status>
+ <status :activatePanel="activatePanel" v-if="notification.type === 'mention'" :compact="true" :statusoid="notification.status"></status>
<div class="non-mention" :class="[userClass, { highlighted: userStyle }]" :style="[ userStyle ]"v-else>
<a class='avatar-container' :href="notification.action.user.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded">
- <StillImage class='avatar-compact' :src="notification.action.user.profile_image_url_original"/>
+ <StillImage class='avatar-compact' :class="{'better-shadow': betterShadow}" :src="notification.action.user.profile_image_url_original"/>
</a>
<div class='notification-right'>
<div class="usercard notification-usercard" v-if="userExpanded">
@@ -25,13 +25,13 @@
<small>{{$t('notifications.followed_you')}}</small>
</span>
</div>
- <small class="timeago"><router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small>
+ <small class="timeago"><router-link @click.native="activatePanel('timeline')" v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small>
</span>
<div class="follow-text" v-if="notification.type === 'follow'">
- <router-link :to="{ name: 'user-profile', params: { id: notification.action.user.id } }">@{{notification.action.user.screen_name}}</router-link>
+ <router-link @click.native="activatePanel('timeline')" :to="{ name: 'user-profile', params: { id: notification.action.user.id } }">@{{notification.action.user.screen_name}}</router-link>
</div>
<template v-else>
- <status v-if="notification.status" class="faint" :compact="true" :statusoid="notification.status" :noHeading="true"></status>
+ <status :activatePanel="activatePanel" v-if="notification.status" class="faint" :compact="true" :statusoid="notification.status" :noHeading="true"></status>
<div class="broken-favorite" v-else>
{{$t('notifications.broken_favorite')}}
</div>
diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js
index 945ffd1f..4b7a591d 100644
--- a/src/components/notifications/notifications.js
+++ b/src/components/notifications/notifications.js
@@ -4,6 +4,7 @@ import notificationsFetcher from '../../services/notifications_fetcher/notificat
import { sortBy, filter } from 'lodash'
const Notifications = {
+ props: [ 'activatePanel' ],
created () {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss
index a137ccd5..a6468e01 100644
--- a/src/components/notifications/notifications.scss
+++ b/src/components/notifications/notifications.scss
@@ -4,31 +4,28 @@
// a bit of a hack to allow scrolling below notifications
padding-bottom: 15em;
- .unseen-count {
- display: inline-block;
- background-color: $fallback--cRed;
- background-color: var(--cRed, $fallback--cRed);
- text-shadow: 0px 0px 3px rgba(0, 0, 0, 0.5);
- border-radius: 99px;
- min-width: 22px;
- max-width: 22px;
- min-height: 22px;
- max-height: 22px;
- color: white;
- font-size: 15px;
- line-height: 22px;
- text-align: center;
- vertical-align: middle
- }
-
.loadmore-error {
- color: $fallback--fg;
- color: var(--fg, $fallback--fg);
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
}
- .unseen {
- box-shadow: inset 4px 0 0 var(--cRed, $fallback--cRed);
- padding-left: 0;
+ .notification {
+ position: relative;
+
+ .notification-overlay {
+ position: absolute;
+ top: 0;
+ right: 0;
+ left: 0;
+ bottom: 0;
+ pointer-events: none;
+ }
+
+ &.unseen {
+ .notification-overlay {
+ background-image: linear-gradient(135deg, var(--badgeNotification, $fallback--cRed) 4px, transparent 10px)
+ }
+ }
}
}
@@ -42,21 +39,27 @@
.broken-favorite {
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
- color: $fallback--faint;
- color: var(--faint, $fallback--faint);
- background-color: $fallback--cAlertRed;
- background-color: var(--cAlertRed, $fallback--cAlertRed);
+ color: $fallback--text;
+ color: var(--alertErrorText, $fallback--text);
+ background-color: $fallback--alertError;
+ background-color: var(--alertError, $fallback--alertError);
padding: 2px .5em
}
.avatar-compact {
width: 32px;
height: 32px;
+ box-shadow: var(--avatarStatusShadow);
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
overflow: hidden;
line-height: 0;
+ &.better-shadow {
+ box-shadow: var(--avatarStatusShadowInset);
+ filter: var(--avatarStatusShadowFilter)
+ }
+
&.animated::before {
display: none;
}
@@ -90,6 +93,9 @@
padding: 0.25em 0;
color: $fallback--faint;
color: var(--faint, $fallback--faint);
+ a {
+ color: var(--faintLink);
+ }
}
padding: 0;
.media-body {
diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue
index 7a4322f9..bef48567 100644
--- a/src/components/notifications/notifications.vue
+++ b/src/components/notifications/notifications.vue
@@ -4,7 +4,7 @@
<div class="panel-heading">
<div class="title">
{{$t('notifications.notifications')}}
- <span class="unseen-count" v-if="unseenCount">{{unseenCount}}</span>
+ <span class="badge badge-notification unseen-count" v-if="unseenCount">{{unseenCount}}</span>
</div>
<div @click.prevent class="loadmore-error alert error" v-if="error">
{{$t('timeline.error_fetching')}}
@@ -13,7 +13,8 @@
</div>
<div class="panel-body">
<div v-for="notification in visibleNotifications" :key="notification.action.id" class="notification" :class='{"unseen": !notification.seen}'>
- <notification :notification="notification"></notification>
+ <div class="notification-overlay"></div>
+ <notification :activatePanel="activatePanel" :notification="notification"></notification>
</div>
</div>
<div class="panel-footer">
diff --git a/src/components/opacity_input/opacity_input.vue b/src/components/opacity_input/opacity_input.vue
new file mode 100644
index 00000000..3926915b
--- /dev/null
+++ b/src/components/opacity_input/opacity_input.vue
@@ -0,0 +1,38 @@
+<template>
+<div class="opacity-control style-control" :class="{ disabled: !present || disabled }">
+ <label :for="name" class="label">
+ {{$t('settings.style.common.opacity')}}
+ </label>
+ <input
+ v-if="typeof fallback !== 'undefined'"
+ class="opt exclude-disabled"
+ :id="name + '-o'"
+ type="checkbox"
+ :checked="present"
+ @input="$emit('input', !present ? fallback : undefined)">
+ <label v-if="typeof fallback !== 'undefined'" class="opt-l" :for="name + '-o'"></label>
+ <input
+ :id="name"
+ class="input-number"
+ type="number"
+ :value="value || fallback"
+ :disabled="!present || disabled"
+ @input="$emit('input', $event.target.value)"
+ max="1"
+ min="0"
+ step=".05">
+</div>
+</template>
+
+<script>
+export default {
+ props: [
+ 'name', 'value', 'fallback', 'disabled'
+ ],
+ computed: {
+ present () {
+ return typeof this.value !== 'undefined'
+ }
+ }
+}
+</script>
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 789243cf..0ce2aff0 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -46,7 +46,7 @@ const PostStatusForm = {
statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser)
}
- const scope = (this.copyMessageScope && this.$store.state.config.copyScope || this.copyMessageScope === 'direct')
+ const scope = (this.copyMessageScope && this.$store.state.config.scopeCopy || this.copyMessageScope === 'direct')
? this.copyMessageScope
: this.$store.state.users.currentUser.default_scope
@@ -262,6 +262,11 @@ const PostStatusForm = {
let index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1)
},
+ uploadFailed (errString, templateArgs) {
+ templateArgs = templateArgs || {}
+ this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)
+ this.enableSubmit()
+ },
disableSubmit () {
this.submitDisabled = true
},
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index e4c46b9a..4776c819 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -64,7 +64,7 @@
</div>
</div>
<div class='form-bottom'>
- <media-upload @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="enableSubmit" :drop-files="dropFiles"></media-upload>
+ <media-upload @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload>
<p v-if="isOverLengthLimit" class="error">{{ charactersLeft }}</p>
<p class="faint" v-else-if="hasStatusLengthLimit">{{ charactersLeft }}</p>
@@ -153,8 +153,8 @@
padding-bottom: 0;
margin-left: $fallback--attachmentRadius;
margin-left: var(--attachmentRadius, $fallback--attachmentRadius);
- background-color: $fallback--btn;
- background-color: var(--btn, $fallback--btn);
+ background-color: $fallback--fg;
+ background-color: var(--btn, $fallback--fg);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
@@ -258,11 +258,13 @@
position: absolute;
z-index: 1;
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
+ // this doesn't match original but i don't care, making it uniform.
+ box-shadow: var(--popupShadow);
min-width: 75%;
background: $fallback--bg;
background: var(--bg, $fallback--bg);
- color: $fallback--lightFg;
- color: var(--lightFg, $fallback--lightFg);
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
}
.autocomplete {
@@ -291,8 +293,8 @@
}
&.highlighted {
- background-color: $fallback--btn;
- background-color: var(--btn, $fallback--btn);
+ background-color: $fallback--fg;
+ background-color: var(--lightBg, $fallback--fg);
}
}
}
diff --git a/src/components/range_input/range_input.vue b/src/components/range_input/range_input.vue
new file mode 100644
index 00000000..3e50664b
--- /dev/null
+++ b/src/components/range_input/range_input.vue
@@ -0,0 +1,48 @@
+<template>
+<div class="range-control style-control" :class="{ disabled: !present || disabled }">
+ <label :for="name" class="label">
+ {{label}}
+ </label>
+ <input
+ v-if="typeof fallback !== 'undefined'"
+ class="opt exclude-disabled"
+ :id="name + '-o'"
+ type="checkbox"
+ :checked="present"
+ @input="$emit('input', !present ? fallback : undefined)">
+ <label v-if="typeof fallback !== 'undefined'" class="opt-l" :for="name + '-o'"></label>
+ <input
+ :id="name"
+ class="input-number"
+ type="range"
+ :value="value || fallback"
+ :disabled="!present || disabled"
+ @input="$emit('input', $event.target.value)"
+ :max="max || hardMax || 100"
+ :min="min || hardMin || 0"
+ :step="step || 1">
+ <input
+ :id="name"
+ class="input-number"
+ type="number"
+ :value="value || fallback"
+ :disabled="!present || disabled"
+ @input="$emit('input', $event.target.value)"
+ :max="hardMax"
+ :min="hardMin"
+ :step="step || 1">
+</div>
+</template>
+
+<script>
+export default {
+ props: [
+ 'name', 'value', 'fallback', 'disabled', 'label', 'max', 'min', 'step', 'hardMin', 'hardMax'
+ ],
+ computed: {
+ present () {
+ return typeof this.value !== 'undefined'
+ }
+ }
+}
+</script>
diff --git a/src/components/registration/registration.js b/src/components/registration/registration.js
index f7f8a720..e5ead8bc 100644
--- a/src/components/registration/registration.js
+++ b/src/components/registration/registration.js
@@ -1,57 +1,61 @@
-import oauthApi from '../../services/new_api/oauth.js'
+import { validationMixin } from 'vuelidate'
+import { required, sameAs } from 'vuelidate/lib/validators'
+import { mapActions, mapState } from 'vuex'
const registration = {
+ mixins: [validationMixin],
data: () => ({
- user: {},
- error: false,
- registering: false
+ user: {
+ email: '',
+ fullname: '',
+ username: '',
+ password: '',
+ confirm: ''
+ }
}),
+ validations: {
+ user: {
+ email: { required },
+ username: { required },
+ fullname: { required },
+ password: { required },
+ confirm: {
+ required,
+ sameAsPassword: sameAs('password')
+ }
+ }
+ },
created () {
- if ((!this.$store.state.instance.registrationOpen && !this.token) || !!this.$store.state.users.currentUser) {
+ if ((!this.registrationOpen && !this.token) || this.signedIn) {
this.$router.push('/main/all')
}
- // Seems like this doesn't work at first page open for some reason
- if (this.$store.state.instance.registrationOpen && this.token) {
- this.$router.push('/registration')
- }
},
computed: {
- termsofservice () { return this.$store.state.instance.tos },
- token () { return this.$route.params.token }
+ token () { return this.$route.params.token },
+ ...mapState({
+ registrationOpen: (state) => state.instance.registrationOpen,
+ signedIn: (state) => !!state.users.currentUser,
+ isPending: (state) => state.users.signUpPending,
+ serverValidationErrors: (state) => state.users.signUpErrors,
+ termsOfService: (state) => state.instance.tos
+ })
},
methods: {
- submit () {
- this.registering = true
+ ...mapActions(['signUp']),
+ async submit () {
this.user.nickname = this.user.username
this.user.token = this.token
- this.$store.state.api.backendInteractor.register(this.user).then(
- (response) => {
- if (response.ok) {
- const data = {
- oauth: this.$store.state.oauth,
- instance: this.$store.state.instance.server
- }
- oauthApi.getOrCreateApp(data).then((app) => {
- oauthApi.getTokenWithCredentials(
- {
- app,
- instance: data.instance,
- username: this.user.username,
- password: this.user.password})
- .then((result) => {
- this.$store.commit('setToken', result.access_token)
- this.$store.dispatch('loginUser', result.access_token)
- this.$router.push('/main/friends')
- })
- })
- } else {
- this.registering = false
- response.json().then((data) => {
- this.error = data.error
- })
- }
+
+ this.$v.$touch()
+
+ if (!this.$v.$invalid) {
+ try {
+ await this.signUp(this.user)
+ this.$router.push('/main/friends')
+ } catch (error) {
+ console.warn('Registration failed: ' + error)
}
- )
+ }
}
}
}
diff --git a/src/components/registration/registration.vue b/src/components/registration/registration.vue
index 087cab6b..8cb1392b 100644
--- a/src/components/registration/registration.vue
+++ b/src/components/registration/registration.vue
@@ -7,50 +7,90 @@
<form v-on:submit.prevent='submit(user)' class='registration-form'>
<div class='container'>
<div class='text-fields'>
- <div class='form-group'>
- <label for='username'>{{$t('login.username')}}</label>
- <input :disabled="registering" v-model='user.username' class='form-control' id='username' placeholder='e.g. lain'>
+ <div class='form-group' :class="{ 'form-group--error': $v.user.username.$error }">
+ <label class='form--label' for='sign-up-username'>{{$t('login.username')}}</label>
+ <input :disabled="isPending" v-model.trim='$v.user.username.$model' class='form-control' id='sign-up-username' placeholder='e.g. lain'>
</div>
- <div class='form-group'>
- <label for='fullname'>{{$t('registration.fullname')}}</label>
- <input :disabled="registering" v-model='user.fullname' class='form-control' id='fullname' placeholder='e.g. Lain Iwakura'>
+ <div class="form-error" v-if="$v.user.username.$dirty">
+ <ul>
+ <li v-if="!$v.user.username.required">
+ <span>{{$t('registration.validations.username_required')}}</span>
+ </li>
+ </ul>
</div>
- <div class='form-group'>
- <label for='email'>{{$t('registration.email')}}</label>
- <input :disabled="registering" v-model='user.email' class='form-control' id='email' type="email">
+
+ <div class='form-group' :class="{ 'form-group--error': $v.user.fullname.$error }">
+ <label class='form--label' for='sign-up-fullname'>{{$t('registration.fullname')}}</label>
+ <input :disabled="isPending" v-model.trim='$v.user.fullname.$model' class='form-control' id='sign-up-fullname' placeholder='e.g. Lain Iwakura'>
</div>
- <div class='form-group'>
- <label for='bio'>{{$t('registration.bio')}}</label>
- <input :disabled="registering" v-model='user.bio' class='form-control' id='bio'>
+ <div class="form-error" v-if="$v.user.fullname.$dirty">
+ <ul>
+ <li v-if="!$v.user.fullname.required">
+ <span>{{$t('registration.validations.fullname_required')}}</span>
+ </li>
+ </ul>
</div>
- <div class='form-group'>
- <label for='password'>{{$t('login.password')}}</label>
- <input :disabled="registering" v-model='user.password' class='form-control' id='password' type='password'>
+
+ <div class='form-group' :class="{ 'form-group--error': $v.user.email.$error }">
+ <label class='form--label' for='email'>{{$t('registration.email')}}</label>
+ <input :disabled="isPending" v-model='$v.user.email.$model' class='form-control' id='email' type="email">
</div>
- <div class='form-group'>
- <label for='password_confirmation'>{{$t('registration.password_confirm')}}</label>
- <input :disabled="registering" v-model='user.confirm' class='form-control' id='password_confirmation' type='password'>
+ <div class="form-error" v-if="$v.user.email.$dirty">
+ <ul>
+ <li v-if="!$v.user.email.required">
+ <span>{{$t('registration.validations.email_required')}}</span>
+ </li>
+ </ul>
</div>
- <!--
+
<div class='form-group'>
- <label for='captcha'>Captcha</label>
- <img src='/qvittersimplesecurity/captcha.jpg' alt='captcha' class='captcha'>
- <input :disabled="registering" v-model='user.captcha' placeholder='Enter captcha' type='test' class='form-control' id='captcha'>
+ <label class='form--label' for='bio'>{{$t('registration.bio')}}</label>
+ <input :disabled="isPending" v-model='user.bio' class='form-control' id='bio'>
+ </div>
+
+ <div class='form-group' :class="{ 'form-group--error': $v.user.password.$error }">
+ <label class='form--label' for='sign-up-password'>{{$t('login.password')}}</label>
+ <input :disabled="isPending" v-model='user.password' class='form-control' id='sign-up-password' type='password'>
+ </div>
+ <div class="form-error" v-if="$v.user.password.$dirty">
+ <ul>
+ <li v-if="!$v.user.password.required">
+ <span>{{$t('registration.validations.password_required')}}</span>
+ </li>
+ </ul>
+ </div>
+
+ <div class='form-group' :class="{ 'form-group--error': $v.user.confirm.$error }">
+ <label class='form--label' for='sign-up-password-confirmation'>{{$t('registration.password_confirm')}}</label>
+ <input :disabled="isPending" v-model='user.confirm' class='form-control' id='sign-up-password-confirmation' type='password'>
+ </div>
+ <div class="form-error" v-if="$v.user.confirm.$dirty">
+ <ul>
+ <li v-if="!$v.user.confirm.required">
+ <span>{{$t('registration.validations.password_confirmation_required')}}</span>
+ </li>
+ <li v-if="!$v.user.confirm.sameAsPassword">
+ <span>{{$t('registration.validations.password_confirmation_match')}}</span>
+ </li>
+ </ul>
</div>
- -->
+
<div class='form-group' v-if='token' >
<label for='token'>{{$t('registration.token')}}</label>
<input disabled='true' v-model='token' class='form-control' id='token' type='text'>
</div>
<div class='form-group'>
- <button :disabled="registering" type='submit' class='btn btn-default'>{{$t('general.submit')}}</button>
+ <button :disabled="isPending" type='submit' class='btn btn-default'>{{$t('general.submit')}}</button>
</div>
</div>
- <div class='terms-of-service' v-html="termsofservice">
+
+ <div class='terms-of-service' v-html="termsOfService">
</div>
</div>
- <div v-if="error" class='form-group'>
- <div class='alert error'>{{error}}</div>
+ <div v-if="serverValidationErrors.length" class='form-group'>
+ <div class='alert error'>
+ <span v-for="error in serverValidationErrors">{{error}}</span>
+ </div>
</div>
</form>
</div>
@@ -60,6 +100,7 @@
<script src="./registration.js"></script>
<style lang="scss">
@import '../../_variables.scss';
+$validations-cRed: #f04124;
.registration-form {
display: flex;
@@ -89,6 +130,55 @@
flex-direction: column;
padding: 0.3em 0.0em 0.3em;
line-height:24px;
+ margin-bottom: 1em;
+ }
+
+ @keyframes shakeError {
+ 0% {
+ transform: translateX(0); }
+ 15% {
+ transform: translateX(0.375rem); }
+ 30% {
+ transform: translateX(-0.375rem); }
+ 45% {
+ transform: translateX(0.375rem); }
+ 60% {
+ transform: translateX(-0.375rem); }
+ 75% {
+ transform: translateX(0.375rem); }
+ 90% {
+ transform: translateX(-0.375rem); }
+ 100% {
+ transform: translateX(0); } }
+
+ .form-group--error {
+ animation-name: shakeError;
+ animation-duration: .6s;
+ animation-timing-function: ease-in-out;
+ }
+
+ .form-group--error .form--label {
+ color: $validations-cRed;
+ color: var(--cRed, $validations-cRed);
+ }
+
+ .form-error {
+ margin-top: -0.7em;
+ text-align: left;
+
+ span {
+ font-size: 12px;
+ }
+ }
+
+ .form-error ul {
+ list-style: none;
+ padding: 0 0 0 5px;
+ margin-top: 0;
+
+ li::before {
+ content: "• ";
+ }
}
form textarea {
@@ -102,8 +192,6 @@
}
.btn {
- //align-self: flex-start;
- //width: 10em;
margin-top: 0.6em;
height: 28px;
}
diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js
index 91a2014a..681ccda8 100644
--- a/src/components/settings/settings.js
+++ b/src/components/settings/settings.js
@@ -13,6 +13,8 @@ const settings = {
hideAttachmentsLocal: user.hideAttachments,
hideAttachmentsInConvLocal: user.hideAttachmentsInConv,
hideNsfwLocal: user.hideNsfw,
+ hideISPLocal: user.hideISP,
+ preloadImage: user.preloadImage,
hidePostStatsLocal: typeof user.hidePostStats === 'undefined'
? instance.hidePostStats
: user.hidePostStats,
@@ -45,6 +47,7 @@ const settings = {
scopeCopyLocal: user.scopeCopy,
scopeCopyDefault: this.$t('settings.values.' + instance.scopeCopy),
stopGifs: user.stopGifs,
+ webPushNotificationsLocal: user.webPushNotifications,
loopSilentAvailable:
// Firefox
Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') ||
@@ -83,6 +86,12 @@ const settings = {
hideNsfwLocal (value) {
this.$store.dispatch('setOption', { name: 'hideNsfw', value })
},
+ preloadImage (value) {
+ this.$store.dispatch('setOption', { name: 'preloadImage', value })
+ },
+ hideISPLocal (value) {
+ this.$store.dispatch('setOption', { name: 'hideISP', value })
+ },
'notificationVisibilityLocal.likes' (value) {
this.$store.dispatch('setOption', { name: 'notificationVisibility', value: this.$store.state.config.notificationVisibility })
},
@@ -134,6 +143,10 @@ const settings = {
},
stopGifs (value) {
this.$store.dispatch('setOption', { name: 'stopGifs', value })
+ },
+ webPushNotificationsLocal (value) {
+ this.$store.dispatch('setOption', { name: 'webPushNotifications', value })
+ if (value) this.$store.dispatch('registerPushNotifications')
}
}
}
diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue
index de506e4d..3f920de5 100644
--- a/src/components/settings/settings.vue
+++ b/src/components/settings/settings.vue
@@ -14,15 +14,24 @@
<div @click.prevent class="alert transparent" v-if="!currentSaveStateNotice.error">
{{ $t('settings.saving_ok') }}
</div>
- </template>
+ </template>
</transition>
</div>
<div class="panel-body">
+<keep-alive>
<tab-switcher>
<div :label="$t('settings.general')" >
<div class="setting-item">
- <h2>{{ $t('settings.interfaceLanguage') }}</h2>
- <interface-language-switcher />
+ <h2>{{ $t('settings.interface') }}</h2>
+ <ul class="setting-list">
+ <li>
+ <interface-language-switcher />
+ </li>
+ <li>
+ <input type="checkbox" id="hideISP" v-model="hideISPLocal">
+ <label for="hideISP">{{$t('settings.hide_isp')}}</label>
+ </li>
+ </ul>
</div>
<div class="setting-item">
<h2>{{$t('nav.timeline')}}</h2>
@@ -109,6 +118,12 @@
<input type="checkbox" id="hideNsfw" v-model="hideNsfwLocal">
<label for="hideNsfw">{{$t('settings.nsfw_clickthrough')}}</label>
</li>
+ <ul class="setting-list suboptions" >
+ <li>
+ <input :disabled="!hideAttachmentsInConvLocal" type="checkbox" id="preloadImage" v-model="preloadImage">
+ <label for="preloadImage">{{$t('settings.preload_images')}}</label>
+ </li>
+ </ul>
<li>
<input type="checkbox" id="stopGifs" v-model="stopGifs">
<label for="stopGifs">{{$t('settings.stop_gifs')}}</label>
@@ -128,6 +143,18 @@
</li>
</ul>
</div>
+
+ <div class="setting-item">
+ <h2>{{$t('settings.notifications')}}</h2>
+ <ul class="setting-list">
+ <li>
+ <input type="checkbox" id="webPushNotifications" v-model="webPushNotificationsLocal">
+ <label for="webPushNotifications">
+ {{$t('settings.enable_web_push_notifications')}}
+ </label>
+ </li>
+ </ul>
+ </div>
</div>
<div :label="$t('settings.theme')" >
@@ -199,6 +226,7 @@
</div>
</tab-switcher>
+</keep-alive>
</div>
</div>
</template>
@@ -210,7 +238,7 @@
@import '../../_variables.scss';
.setting-item {
- border-bottom: 2px solid var(--btn, $fallback--btn);
+ border-bottom: 2px solid var(--fg, $fallback--fg);
margin: 1em 1em 1.4em;
padding-bottom: 1.4em;
@@ -259,12 +287,8 @@
.btn {
min-height: 28px;
- }
-
- .submit {
- margin-top: 1em;
- min-height: 30px;
- width: 10em;
+ min-width: 10em;
+ padding: 0 2em;
}
}
.select-multiple {
diff --git a/src/components/shadow_control/shadow_control.js b/src/components/shadow_control/shadow_control.js
new file mode 100644
index 00000000..44e4a22f
--- /dev/null
+++ b/src/components/shadow_control/shadow_control.js
@@ -0,0 +1,87 @@
+import ColorInput from '../color_input/color_input.vue'
+import OpacityInput from '../opacity_input/opacity_input.vue'
+import { getCssShadow } from '../../services/style_setter/style_setter.js'
+import { hex2rgb } from '../../services/color_convert/color_convert.js'
+
+export default {
+ // 'Value' and 'Fallback' can be undefined, but if they are
+ // initially vue won't detect it when they become something else
+ // therefore i'm using "ready" which should be passed as true when
+ // data becomes available
+ props: [
+ 'value', 'fallback', 'ready'
+ ],
+ data () {
+ return {
+ selectedId: 0,
+ // TODO there are some bugs regarding display of array (it's not getting updated when deleting for some reason)
+ cValue: this.value || this.fallback || []
+ }
+ },
+ components: {
+ ColorInput,
+ OpacityInput
+ },
+ methods: {
+ add () {
+ this.cValue.push(Object.assign({}, this.selected))
+ this.selectedId = this.cValue.length - 1
+ },
+ del () {
+ this.cValue.splice(this.selectedId, 1)
+ this.selectedId = this.cValue.length === 0 ? undefined : this.selectedId - 1
+ },
+ moveUp () {
+ const movable = this.cValue.splice(this.selectedId, 1)[0]
+ this.cValue.splice(this.selectedId - 1, 0, movable)
+ this.selectedId -= 1
+ },
+ moveDn () {
+ const movable = this.cValue.splice(this.selectedId, 1)[0]
+ this.cValue.splice(this.selectedId + 1, 0, movable)
+ this.selectedId += 1
+ }
+ },
+ beforeUpdate () {
+ this.cValue = this.value || this.fallback
+ },
+ computed: {
+ selected () {
+ if (this.ready && this.cValue.length > 0) {
+ return this.cValue[this.selectedId]
+ } else {
+ return {
+ x: 0,
+ y: 0,
+ blur: 0,
+ spread: 0,
+ inset: false,
+ color: '#000000',
+ alpha: 1
+ }
+ }
+ },
+ moveUpValid () {
+ return this.ready && this.selectedId > 0
+ },
+ moveDnValid () {
+ return this.ready && this.selectedId < this.cValue.length - 1
+ },
+ present () {
+ return this.ready &&
+ typeof this.cValue[this.selectedId] !== 'undefined' &&
+ !this.usingFallback
+ },
+ usingFallback () {
+ return typeof this.value === 'undefined'
+ },
+ rgb () {
+ return hex2rgb(this.selected.color)
+ },
+ style () {
+ return this.ready ? {
+ boxShadow: getCssShadow(this.cValue)
+ } : {}
+ }
+ }
+}
diff --git a/src/components/shadow_control/shadow_control.vue b/src/components/shadow_control/shadow_control.vue
new file mode 100644
index 00000000..744925d4
--- /dev/null
+++ b/src/components/shadow_control/shadow_control.vue
@@ -0,0 +1,243 @@
+<template>
+<div class="shadow-control" :class="{ disabled: !present }">
+ <div class="shadow-preview-container">
+ <div :disabled="!present" class="y-shift-control">
+ <input
+ v-model="selected.y"
+ :disabled="!present"
+ class="input-number"
+ type="number">
+ <div class="wrap">
+ <input
+ v-model="selected.y"
+ :disabled="!present"
+ class="input-range"
+ type="range"
+ max="20"
+ min="-20">
+ </div>
+ </div>
+ <div class="preview-window">
+ <div class="preview-block" :style="style"></div>
+ </div>
+ <div :disabled="!present" class="x-shift-control">
+ <input
+ v-model="selected.x"
+ :disabled="!present"
+ class="input-number"
+ type="number">
+ <div class="wrap">
+ <input
+ v-model="selected.x"
+ :disabled="!present"
+ class="input-range"
+ type="range"
+ max="20"
+ min="-20">
+ </div>
+ </div>
+ </div>
+
+ <div class="shadow-tweak">
+ <div :disabled="usingFallback" class="id-control style-control">
+ <label for="shadow-switcher" class="select" :disabled="!ready || usingFallback">
+ <select
+ v-model="selectedId" class="shadow-switcher"
+ :disabled="!ready || usingFallback"
+ id="shadow-switcher">
+ <option v-for="(shadow, index) in cValue" :value="index">
+ {{$t('settings.style.shadows.shadow_id', { value: index })}}
+ </option>
+ </select>
+ <i class="icon-down-open"/>
+ </label>
+ <button class="btn btn-default" :disabled="!ready || !present" @click="del">
+ <i class="icon-cancel"/>
+ </button>
+ <button class="btn btn-default" :disabled="!moveUpValid" @click="moveUp">
+ <i class="icon-up-open"/>
+ </button>
+ <button class="btn btn-default" :disabled="!moveDnValid" @click="moveDn">
+ <i class="icon-down-open"/>
+ </button>
+ <button class="btn btn-default" :disabled="usingFallback" @click="add">
+ <i class="icon-plus"/>
+ </button>
+ </div>
+ <div :disabled="!present" class="inset-control style-control">
+ <label for="inset" class="label">
+ {{$t('settings.style.shadows.inset')}}
+ </label>
+ <input
+ v-model="selected.inset"
+ :disabled="!present"
+ name="inset"
+ id="inset"
+ class="input-inset"
+ type="checkbox">
+ <label class="checkbox-label" for="inset"></label>
+ </div>
+ <div :disabled="!present" class="blur-control style-control">
+ <label for="spread" class="label">
+ {{$t('settings.style.shadows.blur')}}
+ </label>
+ <input
+ v-model="selected.blur"
+ :disabled="!present"
+ name="blur"
+ id="blur"
+ class="input-range"
+ type="range"
+ max="20"
+ min="0">
+ <input
+ v-model="selected.blur"
+ :disabled="!present"
+ class="input-number"
+ type="number"
+ min="0">
+ </div>
+ <div :disabled="!present" class="spread-control style-control">
+ <label for="spread" class="label">
+ {{$t('settings.style.shadows.spread')}}
+ </label>
+ <input
+ v-model="selected.spread"
+ :disabled="!present"
+ name="spread"
+ id="spread"
+ class="input-range"
+ type="range"
+ max="20"
+ min="-20">
+ <input
+ v-model="selected.spread"
+ :disabled="!present"
+ class="input-number"
+ type="number">
+ </div>
+ <ColorInput
+ v-model="selected.color"
+ :disabled="!present"
+ :label="$t('settings.style.common.color')"
+ name="shadow"/>
+ <OpacityInput
+ v-model="selected.alpha"
+ :disabled="!present"/>
+ <p>
+ {{$t('settings.style.shadows.hint')}}
+ </p>
+ </div>
+</div>
+</template>
+
+<script src="./shadow_control.js" ></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+.shadow-control {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ margin-bottom: 1em;
+
+ .shadow-preview-container,
+ .shadow-tweak {
+ margin: 5px 6px 0 0;
+ }
+ .shadow-preview-container {
+ flex: 0;
+ display: flex;
+ flex-wrap: wrap;
+
+ $side: 15em;
+
+ input[type=number] {
+ width: 5em;
+ min-width: 2em;
+ }
+ .x-shift-control,
+ .y-shift-control {
+ display: flex;
+ flex: 0;
+
+ &[disabled=disabled] *{
+ opacity: .5
+ }
+
+ }
+
+ .x-shift-control {
+ align-items: flex-start;
+ }
+
+ .x-shift-control .wrap,
+ input[type=range] {
+ margin: 0;
+ width: $side;
+ height: 2em;
+ }
+ .y-shift-control {
+ flex-direction: column;
+ align-items: flex-end;
+ .wrap {
+ width: 2em;
+ height: $side;
+ }
+ input[type=range] {
+ transform-origin: 1em 1em;
+ transform: rotate(90deg);
+ }
+ }
+ .preview-window {
+ flex: 1;
+ background-color: #999999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-image:
+ linear-gradient(45deg, #666666 25%, transparent 25%),
+ linear-gradient(-45deg, #666666 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, #666666 75%),
+ linear-gradient(-45deg, transparent 75%, #666666 75%);
+ background-size: 20px 20px;
+ background-position:0 0, 0 10px, 10px -10px, -10px 0;
+
+ border-radius: $fallback--inputRadius;
+ border-radius: var(--inputRadius, $fallback--inputRadius);
+
+ .preview-block {
+ width: 33%;
+ height: 33%;
+ background-color: $fallback--bg;
+ background-color: var(--bg, $fallback--bg);
+ border-radius: $fallback--panelRadius;
+ border-radius: var(--panelRadius, $fallback--panelRadius);
+ }
+ }
+ }
+
+ .shadow-tweak {
+ flex: 1;
+ min-width: 280px;
+
+ .id-control {
+ align-items: stretch;
+ .select, .btn {
+ min-width: 1px;
+ margin-right: 5px;
+ }
+ .btn {
+ padding: 0 .4em;
+ margin: 0 .1em;
+ }
+ .select {
+ flex: 1;
+ select {
+ align-self: initial;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 10716583..9a63d047 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -20,7 +20,8 @@ const Status = {
'replies',
'noReplyLinks',
'noHeading',
- 'inlineExpanded'
+ 'inlineExpanded',
+ 'activatePanel'
],
data () {
return {
@@ -33,7 +34,8 @@ const Status = {
showingTall: false,
expandingSubject: typeof this.$store.state.config.collapseMessageWithSubject === 'undefined'
? !this.$store.state.instance.collapseMessageWithSubject
- : !this.$store.state.config.collapseMessageWithSubject
+ : !this.$store.state.config.collapseMessageWithSubject,
+ betterShadow: this.$store.state.interface.browserSupport.cssFilter
}
},
computed: {
@@ -53,6 +55,9 @@ const Status = {
const user = this.retweet ? (this.statusoid.retweeted_status.user) : this.statusoid.user
return highlightClass(user)
},
+ deleted () {
+ return this.statusoid.deleted
+ },
repeaterStyle () {
const user = this.statusoid.user
const highlight = this.$store.state.config.highlight
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 9f65f281..067980ac 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -1,15 +1,15 @@
<template>
- <div class="status-el" v-if="!hideReply" :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]">
+ <div class="status-el" v-if="!hideReply && !deleted" :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]">
<template v-if="muted && !noReplyLinks">
<div class="media status container muted">
- <small><router-link :to="{ name: 'user-profile', params: { id: status.user.id } }">{{status.user.screen_name}}</router-link></small>
+ <small><router-link @click.native="activatePanel('timeline')" :to="{ name: 'user-profile', params: { id: status.user.id } }">{{status.user.screen_name}}</router-link></small>
<small class="muteWords">{{muteWordHits.join(', ')}}</small>
<a href="#" class="unmute" @click.prevent="toggleMute"><i class="icon-eye-off"></i></a>
</div>
</template>
<template v-else>
<div v-if="retweet && !noHeading" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info">
- <StillImage v-if="retweet" class='avatar' :src="statusoid.user.profile_image_url_original"/>
+ <StillImage v-if="retweet" class='avatar' :class='{ "better-shadow": betterShadow }' :src="statusoid.user.profile_image_url_original"/>
<div class="media-body faint">
<a v-if="retweeterHtml" :href="statusoid.user.statusnet_profile_url" class="user-name" :title="'@'+statusoid.user.screen_name" v-html="retweeterHtml"></a>
<a v-else :href="statusoid.user.statusnet_profile_url" class="user-name" :title="'@'+statusoid.user.screen_name">{{retweeter}}</a>
@@ -21,7 +21,7 @@
<div :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet }]" :style="[ userStyle ]" class="media status">
<div v-if="!noHeading" class="media-left">
<a :href="status.user.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded">
- <StillImage class='avatar' :class="{'avatar-compact': compact}" :src="status.user.profile_image_url_original"/>
+ <StillImage class='avatar' :class="{'avatar-compact': compact, 'better-shadow': betterShadow}" :src="status.user.profile_image_url_original"/>
</a>
</div>
<div class="status-body">
@@ -34,10 +34,10 @@
<h4 class="user-name" v-if="status.user.name_html" v-html="status.user.name_html"></h4>
<h4 class="user-name" v-else>{{status.user.name}}</h4>
<span class="links">
- <router-link :to="{ name: 'user-profile', params: { id: status.user.id } }">{{status.user.screen_name}}</router-link>
+ <router-link @click.native="activatePanel('timeline')" :to="{ name: 'user-profile', params: { id: status.user.id } }">{{status.user.screen_name}}</router-link>
<span v-if="status.in_reply_to_screen_name" class="faint reply-info">
<i class="icon-right-open"></i>
- <router-link :to="{ name: 'user-profile', params: { id: status.in_reply_to_user_id } }">
+ <router-link @click.native="activatePanel('timeline')" :to="{ name: 'user-profile', params: { id: status.in_reply_to_user_id } }">
{{status.in_reply_to_screen_name}}
</router-link>
</span>
@@ -54,7 +54,7 @@
</h4>
</div>
<div class="media-heading-right">
- <router-link class="timeago" :to="{ name: 'conversation', params: { id: status.id } }">
+ <router-link class="timeago" @click.native="activatePanel('timeline')" :to="{ name: 'conversation', params: { id: status.id } }">
<timeago :since="status.created_at" :auto-update="60"></timeago>
</router-link>
<div class="visibility-icon" v-if="status.visibility">
@@ -73,7 +73,7 @@
</div>
<div v-if="showPreview" class="status-preview-container">
- <status class="status-preview" v-if="preview" :noReplyLinks="true" :statusoid="preview" :compact=true></status>
+ <status :activatePanel="activatePanel" class="status-preview" v-if="preview" :noReplyLinks="true" :statusoid="preview" :compact=true></status>
<div class="status-preview status-preview-loading" v-else>
<i class="icon-spin4 animate-spin"></i>
</div>
@@ -146,6 +146,7 @@
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5);
+ box-shadow: var(--popupShadow);
margin-top: 0.25em;
margin-left: 0.5em;
z-index: 50;
@@ -284,8 +285,8 @@
margin-left: 0.2em;
}
a:hover i {
- color: $fallback--fg;
- color: var(--fg, $fallback--fg);
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
}
}
@@ -323,6 +324,8 @@
.status-content {
margin-right: 0.5em;
+ font-family: var(--postFont, sans-serif);
+
img, video {
max-width: 100%;
max-height: 400px;
@@ -339,6 +342,10 @@
overflow: auto;
}
+ code, samp, kbd, var, pre {
+ font-family: var(--postCodeFont, monospace);
+ }
+
p {
margin: 0;
margin-top: 0.2em;
@@ -457,18 +464,30 @@
.status .avatar-compact {
width: 32px;
height: 32px;
+ box-shadow: var(--avatarStatusShadow);
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+
+ &.better-shadow {
+ box-shadow: var(--avatarStatusShadowInset);
+ filter: var(--avatarStatusShadowFilter)
+ }
}
.avatar {
width: 48px;
height: 48px;
+ box-shadow: var(--avatarStatusShadow);
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
overflow: hidden;
position: relative;
+ &.better-shadow {
+ box-shadow: var(--avatarStatusShadowInset);
+ filter: var(--avatarStatusShadowFilter)
+ }
+
img {
width: 100%;
height: 100%;
@@ -532,6 +551,7 @@ a.unmute {
.status-el:last-child {
border-bottom-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
+ border-bottom: none;
}
}
diff --git a/src/components/style_switcher/preview.vue b/src/components/style_switcher/preview.vue
new file mode 100644
index 00000000..09a136e9
--- /dev/null
+++ b/src/components/style_switcher/preview.vue
@@ -0,0 +1,78 @@
+<template>
+<div class="panel dummy">
+ <div class="panel-heading">
+ <div class="title">
+ {{$t('settings.style.preview.header')}}
+ <span class="badge badge-notification">
+ 99
+ </span>
+ </div>
+ <span class="faint">
+ {{$t('settings.style.preview.header_faint')}}
+ </span>
+ <span class="alert error">
+ {{$t('settings.style.preview.error')}}
+ </span>
+ <button class="btn">
+ {{$t('settings.style.preview.button')}}
+ </button>
+ </div>
+ <div class="panel-body theme-preview-content">
+ <div class="post">
+ <div class="avatar">
+ ( ͡° ͜ʖ ͡°)
+ </div>
+ <div class="content">
+ <h4>
+ {{$t('settings.style.preview.content')}}
+ </h4>
+
+ <i18n path="settings.style.preview.text">
+ <code style="font-family: var(--postCodeFont)">
+ {{$t('settings.style.preview.mono')}}
+ </code>
+ <a style="color: var(--link)">
+ {{$t('settings.style.preview.link')}}
+ </a>
+ </i18n>
+
+ <div class="icons">
+ <i style="color: var(--cBlue)" class="icon-reply"/>
+ <i style="color: var(--cGreen)" class="icon-retweet"/>
+ <i style="color: var(--cOrange)" class="icon-star"/>
+ <i style="color: var(--cRed)" class="icon-cancel"/>
+ </div>
+ </div>
+ </div>
+
+ <div class="after-post">
+ <div class="avatar-alt">
+ :^)
+ </div>
+ <div class="content">
+ <i18n path="settings.style.preview.fine_print" tag="span" class="faint">
+ <a style="color: var(--faintLink)">
+ {{$t('settings.style.preview.faint_link')}}
+ </a>
+ </i18n>
+ </div>
+ </div>
+ <div class="separator"></div>
+
+ <span class="alert error">
+ {{$t('settings.style.preview.error')}}
+ </span>
+ <input :value="$t('settings.style.preview.input')" type="text">
+
+ <div class="actions">
+ <span class="checkbox">
+ <input checked="very yes" type="checkbox" id="preview_checkbox">
+ <label for="preview_checkbox">{{$t('settings.style.preview.checkbox')}}</label>
+ </span>
+ <button class="btn">
+ {{$t('settings.style.preview.button')}}
+ </button>
+ </div>
+ </div>
+</div>
+</template>
diff --git a/src/components/style_switcher/style_switcher.js b/src/components/style_switcher/style_switcher.js
index 95c15b49..6a4e1cba 100644
--- a/src/components/style_switcher/style_switcher.js
+++ b/src/components/style_switcher/style_switcher.js
@@ -1,21 +1,101 @@
-import { rgbstr2hex } from '../../services/color_convert/color_convert.js'
+import { rgb2hex, hex2rgb, getContrastRatio, alphaBlend } from '../../services/color_convert/color_convert.js'
+import { set, delete as del } from 'vue'
+import { generateColors, generateShadows, generateRadii, generateFonts, composePreset, getThemes } from '../../services/style_setter/style_setter.js'
+import ColorInput from '../color_input/color_input.vue'
+import RangeInput from '../range_input/range_input.vue'
+import OpacityInput from '../opacity_input/opacity_input.vue'
+import ShadowControl from '../shadow_control/shadow_control.vue'
+import FontControl from '../font_control/font_control.vue'
+import ContrastRatio from '../contrast_ratio/contrast_ratio.vue'
+import TabSwitcher from '../tab_switcher/tab_switcher.jsx'
+import Preview from './preview.vue'
+import ExportImport from '../export_import/export_import.vue'
+
+// List of color values used in v1
+const v1OnlyNames = [
+ 'bg',
+ 'fg',
+ 'text',
+ 'link',
+ 'cRed',
+ 'cGreen',
+ 'cBlue',
+ 'cOrange'
+].map(_ => _ + 'ColorLocal')
export default {
data () {
return {
availableStyles: [],
selected: this.$store.state.config.theme,
- invalidThemeImported: false,
- bgColorLocal: '',
- btnColorLocal: '',
+
+ previewShadows: {},
+ previewColors: {},
+ previewRadii: {},
+ previewFonts: {},
+
+ shadowsInvalid: true,
+ colorsInvalid: true,
+ radiiInvalid: true,
+
+ keepColor: false,
+ keepShadows: false,
+ keepOpacity: false,
+ keepRoundness: false,
+ keepFonts: false,
+
textColorLocal: '',
linkColorLocal: '',
- redColorLocal: '',
- blueColorLocal: '',
- greenColorLocal: '',
- orangeColorLocal: '',
+
+ bgColorLocal: '',
+ bgOpacityLocal: undefined,
+
+ fgColorLocal: '',
+ fgTextColorLocal: undefined,
+ fgLinkColorLocal: undefined,
+
+ btnColorLocal: undefined,
+ btnTextColorLocal: undefined,
+ btnOpacityLocal: undefined,
+
+ inputColorLocal: undefined,
+ inputTextColorLocal: undefined,
+ inputOpacityLocal: undefined,
+
+ panelColorLocal: undefined,
+ panelTextColorLocal: undefined,
+ panelLinkColorLocal: undefined,
+ panelFaintColorLocal: undefined,
+ panelOpacityLocal: undefined,
+
+ topBarColorLocal: undefined,
+ topBarTextColorLocal: undefined,
+ topBarLinkColorLocal: undefined,
+
+ alertErrorColorLocal: undefined,
+
+ badgeOpacityLocal: undefined,
+ badgeNotificationColorLocal: undefined,
+
+ borderColorLocal: undefined,
+ borderOpacityLocal: undefined,
+
+ faintColorLocal: undefined,
+ faintOpacityLocal: undefined,
+ faintLinkColorLocal: undefined,
+
+ cRedColorLocal: '',
+ cBlueColorLocal: '',
+ cGreenColorLocal: '',
+ cOrangeColorLocal: '',
+
+ shadowSelected: undefined,
+ shadowsLocal: {},
+ fontsLocal: {},
+
btnRadiusLocal: '',
inputRadiusLocal: '',
+ checkboxRadiusLocal: '',
panelRadiusLocal: '',
avatarRadiusLocal: '',
avatarAltRadiusLocal: '',
@@ -26,144 +106,470 @@ export default {
created () {
const self = this
- window.fetch('/static/styles.json')
- .then((data) => data.json())
- .then((themes) => {
- self.availableStyles = themes
- })
+ getThemes().then((themesComplete) => {
+ self.availableStyles = themesComplete
+ })
},
mounted () {
- this.normalizeLocalState(this.$store.state.config.colors, this.$store.state.config.radii)
+ this.normalizeLocalState(this.$store.state.config.customTheme)
+ if (typeof this.shadowSelected === 'undefined') {
+ this.shadowSelected = this.shadowsAvailable[0]
+ }
},
- methods: {
- exportCurrentTheme () {
- const stringified = JSON.stringify({
+ computed: {
+ selectedVersion () {
+ return Array.isArray(this.selected) ? 1 : 2
+ },
+ currentColors () {
+ return {
+ bg: this.bgColorLocal,
+ text: this.textColorLocal,
+ link: this.linkColorLocal,
+
+ fg: this.fgColorLocal,
+ fgText: this.fgTextColorLocal,
+ fgLink: this.fgLinkColorLocal,
+
+ panel: this.panelColorLocal,
+ panelText: this.panelTextColorLocal,
+ panelLink: this.panelLinkColorLocal,
+ panelFaint: this.panelFaintColorLocal,
+
+ input: this.inputColorLocal,
+ inputText: this.inputTextColorLocal,
+
+ topBar: this.topBarColorLocal,
+ topBarText: this.topBarTextColorLocal,
+ topBarLink: this.topBarLinkColorLocal,
+
+ btn: this.btnColorLocal,
+ btnText: this.btnTextColorLocal,
+
+ alertError: this.alertErrorColorLocal,
+ badgeNotification: this.badgeNotificationColorLocal,
+
+ faint: this.faintColorLocal,
+ faintLink: this.faintLinkColorLocal,
+ border: this.borderColorLocal,
+
+ cRed: this.cRedColorLocal,
+ cBlue: this.cBlueColorLocal,
+ cGreen: this.cGreenColorLocal,
+ cOrange: this.cOrangeColorLocal
+ }
+ },
+ currentOpacity () {
+ return {
+ bg: this.bgOpacityLocal,
+ btn: this.btnOpacityLocal,
+ input: this.inputOpacityLocal,
+ panel: this.panelOpacityLocal,
+ topBar: this.topBarOpacityLocal,
+ border: this.borderOpacityLocal,
+ faint: this.faintOpacityLocal
+ }
+ },
+ currentRadii () {
+ return {
+ btn: this.btnRadiusLocal,
+ input: this.inputRadiusLocal,
+ checkbox: this.checkboxRadiusLocal,
+ panel: this.panelRadiusLocal,
+ avatar: this.avatarRadiusLocal,
+ avatarAlt: this.avatarAltRadiusLocal,
+ tooltip: this.tooltipRadiusLocal,
+ attachment: this.attachmentRadiusLocal
+ }
+ },
+ preview () {
+ return composePreset(this.previewColors, this.previewRadii, this.previewShadows, this.previewFonts)
+ },
+ previewTheme () {
+ if (!this.preview.theme.colors) return { colors: {}, opacity: {}, radii: {}, shadows: {}, fonts: {} }
+ return this.preview.theme
+ },
+ // This needs optimization maybe
+ previewContrast () {
+ if (!this.previewTheme.colors.bg) return {}
+ const colors = this.previewTheme.colors
+ const opacity = this.previewTheme.opacity
+ if (!colors.bg) return {}
+ const hints = (ratio) => ({
+ text: ratio.toPrecision(3) + ':1',
+ // AA level, AAA level
+ aa: ratio >= 4.5,
+ aaa: ratio >= 7,
+ // same but for 18pt+ texts
+ laa: ratio >= 3,
+ laaa: ratio >= 4.5
+ })
+
+ // fgsfds :DDDD
+ const fgs = {
+ text: hex2rgb(colors.text),
+ panelText: hex2rgb(colors.panelText),
+ panelLink: hex2rgb(colors.panelLink),
+ btnText: hex2rgb(colors.btnText),
+ topBarText: hex2rgb(colors.topBarText),
+ inputText: hex2rgb(colors.inputText),
+
+ link: hex2rgb(colors.link),
+ topBarLink: hex2rgb(colors.topBarLink),
+
+ red: hex2rgb(colors.cRed),
+ green: hex2rgb(colors.cGreen),
+ blue: hex2rgb(colors.cBlue),
+ orange: hex2rgb(colors.cOrange)
+ }
+
+ const bgs = {
+ bg: hex2rgb(colors.bg),
+ btn: hex2rgb(colors.btn),
+ panel: hex2rgb(colors.panel),
+ topBar: hex2rgb(colors.topBar),
+ input: hex2rgb(colors.input),
+ alertError: hex2rgb(colors.alertError),
+ badgeNotification: hex2rgb(colors.badgeNotification)
+ }
+
+ /* This is a bit confusing because "bottom layer" used is text color
+ * This is done to get worst case scenario when background below transparent
+ * layer matches text color, making it harder to read the lower alpha is.
+ */
+ const ratios = {
+ bgText: getContrastRatio(alphaBlend(bgs.bg, opacity.bg, fgs.text), fgs.text),
+ bgLink: getContrastRatio(alphaBlend(bgs.bg, opacity.bg, fgs.link), fgs.link),
+ bgRed: getContrastRatio(alphaBlend(bgs.bg, opacity.bg, fgs.red), fgs.red),
+ bgGreen: getContrastRatio(alphaBlend(bgs.bg, opacity.bg, fgs.green), fgs.green),
+ bgBlue: getContrastRatio(alphaBlend(bgs.bg, opacity.bg, fgs.blue), fgs.blue),
+ bgOrange: getContrastRatio(alphaBlend(bgs.bg, opacity.bg, fgs.orange), fgs.orange),
+
+ tintText: getContrastRatio(alphaBlend(bgs.bg, 0.5, fgs.panelText), fgs.text),
+
+ panelText: getContrastRatio(alphaBlend(bgs.panel, opacity.panel, fgs.panelText), fgs.panelText),
+ panelLink: getContrastRatio(alphaBlend(bgs.panel, opacity.panel, fgs.panelLink), fgs.panelLink),
+
+ btnText: getContrastRatio(alphaBlend(bgs.btn, opacity.btn, fgs.btnText), fgs.btnText),
+
+ inputText: getContrastRatio(alphaBlend(bgs.input, opacity.input, fgs.inputText), fgs.inputText),
+
+ topBarText: getContrastRatio(alphaBlend(bgs.topBar, opacity.topBar, fgs.topBarText), fgs.topBarText),
+ topBarLink: getContrastRatio(alphaBlend(bgs.topBar, opacity.topBar, fgs.topBarLink), fgs.topBarLink)
+ }
+
+ return Object.entries(ratios).reduce((acc, [k, v]) => { acc[k] = hints(v); return acc }, {})
+ },
+ previewRules () {
+ if (!this.preview.rules) return ''
+ return [
+ ...Object.values(this.preview.rules),
+ 'color: var(--text)',
+ 'font-family: var(--interfaceFont, sans-serif)'
+ ].join(';')
+ },
+ shadowsAvailable () {
+ return Object.keys(this.previewTheme.shadows).sort()
+ },
+ currentShadowOverriden: {
+ get () {
+ return !!this.currentShadow
+ },
+ set (val) {
+ if (val) {
+ set(this.shadowsLocal, this.shadowSelected, this.currentShadowFallback.map(_ => Object.assign({}, _)))
+ } else {
+ del(this.shadowsLocal, this.shadowSelected)
+ }
+ }
+ },
+ currentShadowFallback () {
+ return this.previewTheme.shadows[this.shadowSelected]
+ },
+ currentShadow: {
+ get () {
+ return this.shadowsLocal[this.shadowSelected]
+ },
+ set (v) {
+ set(this.shadowsLocal, this.shadowSelected, v)
+ }
+ },
+ themeValid () {
+ return !this.shadowsInvalid && !this.colorsInvalid && !this.radiiInvalid
+ },
+ exportedTheme () {
+ const saveEverything = (
+ !this.keepFonts &&
+ !this.keepShadows &&
+ !this.keepOpacity &&
+ !this.keepRoundness &&
+ !this.keepColor
+ )
+
+ const theme = {}
+
+ if (this.keepFonts || saveEverything) {
+ theme.fonts = this.fontsLocal
+ }
+ if (this.keepShadows || saveEverything) {
+ theme.shadows = this.shadowsLocal
+ }
+ if (this.keepOpacity || saveEverything) {
+ theme.opacity = this.currentOpacity
+ }
+ if (this.keepColor || saveEverything) {
+ theme.colors = this.currentColors
+ }
+ if (this.keepRoundness || saveEverything) {
+ theme.radii = this.currentRadii
+ }
+
+ return {
// To separate from other random JSON files and possible future theme formats
- _pleroma_theme_version: 1,
- colors: this.$store.state.config.colors,
- radii: this.$store.state.config.radii
- }, null, 2) // Pretty-print and indent with 2 spaces
-
- // Create an invisible link with a data url and simulate a click
- const e = document.createElement('a')
- e.setAttribute('download', 'pleroma_theme.json')
- e.setAttribute('href', 'data:application/json;base64,' + window.btoa(stringified))
- e.style.display = 'none'
-
- document.body.appendChild(e)
- e.click()
- document.body.removeChild(e)
- },
-
- importTheme () {
- this.invalidThemeImported = false
- const filePicker = document.createElement('input')
- filePicker.setAttribute('type', 'file')
- filePicker.setAttribute('accept', '.json')
-
- filePicker.addEventListener('change', event => {
- if (event.target.files[0]) {
- // eslint-disable-next-line no-undef
- const reader = new FileReader()
- reader.onload = ({target}) => {
- try {
- const parsed = JSON.parse(target.result)
- if (parsed._pleroma_theme_version === 1) {
- this.normalizeLocalState(parsed.colors, parsed.radii)
- } else {
- // A theme from the future, spooky
- this.invalidThemeImported = true
- }
- } catch (e) {
- // This will happen both if there is a JSON syntax error or the theme is missing components
- this.invalidThemeImported = true
- }
- }
- reader.readAsText(event.target.files[0])
+ _pleroma_theme_version: 2, theme
+ }
+ }
+ },
+ components: {
+ ColorInput,
+ OpacityInput,
+ RangeInput,
+ ContrastRatio,
+ ShadowControl,
+ FontControl,
+ TabSwitcher,
+ Preview,
+ ExportImport
+ },
+ methods: {
+ setCustomTheme () {
+ this.$store.dispatch('setOption', {
+ name: 'customTheme',
+ value: {
+ shadows: this.shadowsLocal,
+ fonts: this.fontsLocal,
+ opacity: this.currentOpacity,
+ colors: this.currentColors,
+ radii: this.currentRadii
}
})
+ },
+ onImport (parsed) {
+ if (parsed._pleroma_theme_version === 1) {
+ this.normalizeLocalState(parsed, 1)
+ } else if (parsed._pleroma_theme_version === 2) {
+ this.normalizeLocalState(parsed.theme, 2)
+ }
+ },
+ importValidator (parsed) {
+ const version = parsed._pleroma_theme_version
+ return version >= 1 || version <= 2
+ },
+ clearAll () {
+ const state = this.$store.state.config.customTheme
+ const version = state.colors ? 2 : 'l1'
+ this.normalizeLocalState(this.$store.state.config.customTheme, version)
+ },
- document.body.appendChild(filePicker)
- filePicker.click()
- document.body.removeChild(filePicker)
+ // Clears all the extra stuff when loading V1 theme
+ clearV1 () {
+ Object.keys(this.$data)
+ .filter(_ => _.endsWith('ColorLocal') || _.endsWith('OpacityLocal'))
+ .filter(_ => !v1OnlyNames.includes(_))
+ .forEach(key => {
+ set(this.$data, key, undefined)
+ })
},
- setCustomTheme () {
- if (!this.bgColorLocal && !this.btnColorLocal && !this.linkColorLocal) {
- // reset to picked themes
- }
-
- const rgb = (hex) => {
- const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
- return result ? {
- r: parseInt(result[1], 16),
- g: parseInt(result[2], 16),
- b: parseInt(result[3], 16)
- } : null
- }
- const bgRgb = rgb(this.bgColorLocal)
- const btnRgb = rgb(this.btnColorLocal)
- const textRgb = rgb(this.textColorLocal)
- const linkRgb = rgb(this.linkColorLocal)
-
- const redRgb = rgb(this.redColorLocal)
- const blueRgb = rgb(this.blueColorLocal)
- const greenRgb = rgb(this.greenColorLocal)
- const orangeRgb = rgb(this.orangeColorLocal)
-
- if (bgRgb && btnRgb && linkRgb) {
- this.$store.dispatch('setOption', {
- name: 'customTheme',
- value: {
- fg: btnRgb,
- bg: bgRgb,
- text: textRgb,
- link: linkRgb,
- cRed: redRgb,
- cBlue: blueRgb,
- cGreen: greenRgb,
- cOrange: orangeRgb,
- btnRadius: this.btnRadiusLocal,
- inputRadius: this.inputRadiusLocal,
- panelRadius: this.panelRadiusLocal,
- avatarRadius: this.avatarRadiusLocal,
- avatarAltRadius: this.avatarAltRadiusLocal,
- tooltipRadius: this.tooltipRadiusLocal,
- attachmentRadius: this.attachmentRadiusLocal
- }})
- }
- },
-
- normalizeLocalState (colors, radii) {
- this.bgColorLocal = rgbstr2hex(colors.bg)
- this.btnColorLocal = rgbstr2hex(colors.btn)
- this.textColorLocal = rgbstr2hex(colors.fg)
- this.linkColorLocal = rgbstr2hex(colors.link)
-
- this.redColorLocal = rgbstr2hex(colors.cRed)
- this.blueColorLocal = rgbstr2hex(colors.cBlue)
- this.greenColorLocal = rgbstr2hex(colors.cGreen)
- this.orangeColorLocal = rgbstr2hex(colors.cOrange)
-
- this.btnRadiusLocal = radii.btnRadius || 4
- this.inputRadiusLocal = radii.inputRadius || 4
- this.panelRadiusLocal = radii.panelRadius || 10
- this.avatarRadiusLocal = radii.avatarRadius || 5
- this.avatarAltRadiusLocal = radii.avatarAltRadius || 50
- this.tooltipRadiusLocal = radii.tooltipRadius || 2
- this.attachmentRadiusLocal = radii.attachmentRadius || 5
+ clearRoundness () {
+ Object.keys(this.$data)
+ .filter(_ => _.endsWith('RadiusLocal'))
+ .forEach(key => {
+ set(this.$data, key, undefined)
+ })
+ },
+
+ clearOpacity () {
+ Object.keys(this.$data)
+ .filter(_ => _.endsWith('OpacityLocal'))
+ .forEach(key => {
+ set(this.$data, key, undefined)
+ })
+ },
+
+ clearShadows () {
+ this.shadowsLocal = {}
+ },
+
+ clearFonts () {
+ this.fontsLocal = {}
+ },
+
+ /**
+ * This applies stored theme data onto form. Supports three versions of data:
+ * v2 (version = 2) - newer version of themes.
+ * v1 (version = 1) - older version of themes (import from file)
+ * v1l (version = l1) - older version of theme (load from local storage)
+ * v1 and v1l differ because of way themes were stored/exported.
+ * @param {Object} input - input data
+ * @param {Number} version - version of data. 0 means try to guess based on data. "l1" means v1, locastorage type
+ */
+ normalizeLocalState (input, version = 0) {
+ const colors = input.colors || input
+ const radii = input.radii || input
+ const opacity = input.opacity
+ const shadows = input.shadows || {}
+ const fonts = input.fonts || {}
+
+ if (version === 0) {
+ if (input.version) version = input.version
+ // Old v1 naming: fg is text, btn is foreground
+ if (typeof colors.text === 'undefined' && typeof colors.fg !== 'undefined') {
+ version = 1
+ }
+ // New v2 naming: text is text, fg is foreground
+ if (typeof colors.text !== 'undefined' && typeof colors.fg !== 'undefined') {
+ version = 2
+ }
+ }
+
+ // Stuff that differs between V1 and V2
+ if (version === 1) {
+ this.fgColorLocal = rgb2hex(colors.btn)
+ this.textColorLocal = rgb2hex(colors.fg)
+ }
+
+ if (!this.keepColor) {
+ this.clearV1()
+ const keys = new Set(version !== 1 ? Object.keys(colors) : [])
+ if (version === 1 || version === 'l1') {
+ keys
+ .add('bg')
+ .add('link')
+ .add('cRed')
+ .add('cBlue')
+ .add('cGreen')
+ .add('cOrange')
+ }
+
+ keys.forEach(key => {
+ this[key + 'ColorLocal'] = rgb2hex(colors[key])
+ })
+ }
+
+ if (!this.keepRoundness) {
+ this.clearRoundness()
+ Object.entries(radii).forEach(([k, v]) => {
+ // 'Radius' is kept mostly for v1->v2 localstorage transition
+ const key = k.endsWith('Radius') ? k.split('Radius')[0] : k
+ this[key + 'RadiusLocal'] = v
+ })
+ }
+
+ if (!this.keepShadows) {
+ this.clearShadows()
+ this.shadowsLocal = shadows
+ this.shadowSelected = this.shadowsAvailable[0]
+ }
+
+ if (!this.keepFonts) {
+ this.clearFonts()
+ this.fontsLocal = fonts
+ }
+
+ if (opacity && !this.keepOpacity) {
+ this.clearOpacity()
+ Object.entries(opacity).forEach(([k, v]) => {
+ if (typeof v === 'undefined' || v === null || Number.isNaN(v)) return
+ this[k + 'OpacityLocal'] = v
+ })
+ }
}
},
watch: {
+ currentRadii () {
+ try {
+ this.previewRadii = generateRadii({ radii: this.currentRadii })
+ this.radiiInvalid = false
+ } catch (e) {
+ this.radiiInvalid = true
+ console.warn(e)
+ }
+ },
+ shadowsLocal: {
+ handler () {
+ try {
+ this.previewShadows = generateShadows({ shadows: this.shadowsLocal })
+ this.shadowsInvalid = false
+ } catch (e) {
+ this.shadowsInvalid = true
+ console.warn(e)
+ }
+ },
+ deep: true
+ },
+ fontsLocal: {
+ handler () {
+ try {
+ this.previewFonts = generateFonts({ fonts: this.fontsLocal })
+ this.fontsInvalid = false
+ } catch (e) {
+ this.fontsInvalid = true
+ console.warn(e)
+ }
+ },
+ deep: true
+ },
+ currentColors () {
+ try {
+ this.previewColors = generateColors({
+ opacity: this.currentOpacity,
+ colors: this.currentColors
+ })
+ this.colorsInvalid = false
+ } catch (e) {
+ this.colorsInvalid = true
+ console.warn(e)
+ }
+ },
+ currentOpacity () {
+ try {
+ this.previewColors = generateColors({
+ opacity: this.currentOpacity,
+ colors: this.currentColors
+ })
+ } catch (e) {
+ console.warn(e)
+ }
+ },
selected () {
- this.bgColorLocal = this.selected[1]
- this.btnColorLocal = this.selected[2]
- this.textColorLocal = this.selected[3]
- this.linkColorLocal = this.selected[4]
- this.redColorLocal = this.selected[5]
- this.greenColorLocal = this.selected[6]
- this.blueColorLocal = this.selected[7]
- this.orangeColorLocal = this.selected[8]
+ if (this.selectedVersion === 1) {
+ if (!this.keepRoundness) {
+ this.clearRoundness()
+ }
+
+ if (!this.keepShadows) {
+ this.clearShadows()
+ }
+
+ if (!this.keepOpacity) {
+ this.clearOpacity()
+ }
+
+ if (!this.keepColor) {
+ this.clearV1()
+
+ this.bgColorLocal = this.selected[1]
+ this.fgColorLocal = this.selected[2]
+ this.textColorLocal = this.selected[3]
+ this.linkColorLocal = this.selected[4]
+ this.cRedColorLocal = this.selected[5]
+ this.cGreenColorLocal = this.selected[6]
+ this.cBlueColorLocal = this.selected[7]
+ this.cOrangeColorLocal = this.selected[8]
+ }
+ } else if (this.selectedVersion >= 2) {
+ this.normalizeLocalState(this.selected.theme, 2)
+ }
}
}
}
diff --git a/src/components/style_switcher/style_switcher.scss b/src/components/style_switcher/style_switcher.scss
new file mode 100644
index 00000000..135c113a
--- /dev/null
+++ b/src/components/style_switcher/style_switcher.scss
@@ -0,0 +1,335 @@
+@import '../../_variables.scss';
+.style-switcher {
+ .preset-switcher {
+ margin-right: 1em;
+ }
+
+ .style-control {
+ display: flex;
+ align-items: baseline;
+ margin-bottom: 5px;
+
+ .label {
+ flex: 1;
+ }
+
+ &.disabled {
+ input, select {
+ &:not(.exclude-disabled) {
+ opacity: .5
+ }
+ }
+ }
+
+ input, select {
+ min-width: 3em;
+ margin: 0;
+ flex: 0;
+
+ &[type=color] {
+ padding: 1px;
+ cursor: pointer;
+ height: 29px;
+ min-width: 2em;
+ border: none;
+ align-self: stretch;
+ }
+
+ &[type=number] {
+ min-width: 5em;
+ }
+
+ &[type=range] {
+ flex: 1;
+ min-width: 3em;
+ }
+
+ &[type=checkbox] + label {
+ margin: 6px 0;
+ }
+
+ &:not([type=number]):not([type=text]) {
+ align-self: flex-start;
+ }
+ }
+ }
+
+ .tab-switcher {
+ margin: 0 -1em;
+ }
+
+ .reset-container {
+ flex-wrap: wrap;
+ }
+
+ .fonts-container,
+ .reset-container,
+ .apply-container,
+ .radius-container,
+ .color-container,
+ {
+ display: flex;
+ }
+
+ .fonts-container,
+ .radius-container {
+ flex-direction: column;
+ }
+
+ .color-container{
+ > h4 {
+ width: 99%;
+ }
+ flex-wrap: wrap;
+ justify-content: space-between;
+ }
+
+ .fonts-container,
+ .color-container,
+ .shadow-container,
+ .radius-container,
+ .presets-container {
+ margin: 1em 1em 0;
+ }
+
+ .tab-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ width: 100%;
+ min-height: 30px;
+
+ .btn {
+ min-width: 1px;
+ flex: 0 auto;
+ padding: 0 1em;
+ }
+
+ p {
+ flex: 1;
+ margin: 0;
+ margin-right: .5em;
+ }
+
+ margin-bottom: 1em;
+ }
+
+ .shadow-selector {
+ .override {
+ flex: 1;
+ margin-left: .5em;
+ }
+ .select-container {
+ margin-top: -4px;
+ margin-bottom: -3px;
+ }
+ }
+
+ .save-load,
+ .save-load-options {
+ display: flex;
+ justify-content: center;
+ align-items: baseline;
+ flex-wrap: wrap;
+
+ .presets,
+ .import-export {
+ margin-bottom: .5em;
+ }
+
+ .import-export {
+ display: flex;
+ }
+
+ .override {
+ margin-left: .5em;
+ }
+ }
+
+ .save-load-options {
+ flex-wrap: wrap;
+ margin-top: .5em;
+ justify-content: center;
+ .keep-option {
+ margin: 0 .5em .5em;
+ min-width: 25%;
+ }
+ }
+
+ .preview-container {
+ border-top: 1px dashed;
+ border-bottom: 1px dashed;
+ border-color: $fallback--border;
+ border-color: var(--border, $fallback--border);
+ margin: 1em -1em 0;
+ padding: 1em;
+ background: var(--body-background-image);
+ background-size: cover;
+ background-position: 50% 50%;
+
+ .dummy {
+ .post {
+ font-family: var(--postFont);
+ display: flex;
+
+ .content {
+ flex: 1;
+
+ h4 {
+ margin-bottom: .25em;
+ }
+
+ .icons {
+ margin-top: .5em;
+ display: flex;
+
+ i {
+ margin-right: 1em;
+ }
+ }
+ }
+ }
+
+ .after-post {
+ margin-top: 1em;
+ display: flex;
+ align-items: center;
+ }
+
+ .avatar, .avatar-alt{
+ background: linear-gradient(135deg, #b8e1fc 0%,#a9d2f3 10%,#90bae4 25%,#90bcea 37%,#90bff0 50%,#6ba8e5 51%,#a2daf5 83%,#bdf3fd 100%);
+ color: black;
+ font-family: sans-serif;
+ text-align: center;
+ margin-right: 1em;
+ }
+
+ .avatar-alt {
+ flex: 0 auto;
+ margin-left: 28px;
+ font-size: 12px;
+ min-width: 20px;
+ min-height: 20px;
+ line-height: 20px;
+ border-radius: $fallback--avatarAltRadius;
+ border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+ }
+
+ .avatar {
+ flex: 0 auto;
+ width: 48px;
+ height: 48px;
+ font-size: 14px;
+ line-height: 48px;
+ }
+
+ .actions {
+ display: flex;
+ align-items: baseline;
+
+ .checkbox {
+ display: inline-flex;
+ align-items: baseline;
+ margin-right: 1em;
+ flex: 1;
+ }
+ }
+
+ .separator {
+ margin: 1em;
+ border-bottom: 1px solid;
+ border-color: $fallback--border;
+ border-color: var(--border, $fallback--border);
+ }
+
+ .panel-heading {
+ .badge, .alert, .btn, .faint {
+ margin-left: 1em;
+ white-space: nowrap;
+ }
+ .faint {
+ text-overflow: ellipsis;
+ min-width: 2em;
+ overflow-x: hidden;
+ }
+ .flex-spacer {
+ flex: 1;
+ }
+ }
+ .btn {
+ margin-left: 0;
+ padding: 0 1em;
+ min-width: 3em;
+ min-height: 30px;
+ }
+ }
+ }
+
+ .apply-container {
+ justify-content: center;
+ }
+
+ .radius-item,
+ .color-item {
+ min-width: 20em;
+ margin: 5px 6px 0 0;
+ display:flex;
+ flex-direction: column;
+ flex: 1 1 0;
+
+ &.wide {
+ min-width: 60%
+ }
+
+ &:not(.wide):nth-child(2n+1) {
+ margin-right: 7px;
+
+ }
+
+ .color, .opacity {
+ display:flex;
+ align-items: baseline;
+ }
+ }
+
+ .radius-item {
+ flex-basis: auto;
+ }
+
+ .theme-radius-rn,
+ .theme-color-cl {
+ border: 0;
+ box-shadow: none;
+ background: transparent;
+ color: var(--faint, $fallback--faint);
+ align-self: stretch;
+ }
+
+ .theme-color-cl,
+ .theme-radius-in,
+ .theme-color-in {
+ margin-left: 4px;
+ }
+
+ .theme-radius-in {
+ min-width: 1em;
+ }
+
+ .theme-radius-in {
+ max-width: 7em;
+ flex: 1;
+ }
+
+ .theme-radius-lb{
+ max-width: 50em;
+ }
+
+ .theme-preview-content {
+ padding: 20px;
+ }
+
+ .btn {
+ margin-left: .25em;
+ margin-right: .25em;
+ }
+}
diff --git a/src/components/style_switcher/style_switcher.vue b/src/components/style_switcher/style_switcher.vue
index 72a338bd..84963c81 100644
--- a/src/components/style_switcher/style_switcher.vue
+++ b/src/components/style_switcher/style_switcher.vue
@@ -1,300 +1,276 @@
<template>
-<div>
+<div class="style-switcher">
<div class="presets-container">
- <div>
- {{$t('settings.presets')}}
- <label for="style-switcher" class='select'>
- <select id="style-switcher" v-model="selected" class="style-switcher">
- <option v-for="style in availableStyles"
- :value="style"
- :style="{
- backgroundColor: style[1],
- color: style[3]
- }">
- {{style[0]}}
- </option>
- </select>
- <i class="icon-down-open"/>
- </label>
+ <div class="save-load">
+ <export-import
+ :exportObject='exportedTheme'
+ :exportLabel='$t("settings.export_theme")'
+ :importLabel='$t("settings.import_theme")'
+ :importFailedText='$t("settings.invalid_theme_imported")'
+ :onImport='onImport'
+ :validator='importValidator'>
+ <template slot="before">
+ <div class="presets">
+ {{$t('settings.presets')}}
+ <label for="preset-switcher" class='select'>
+ <select id="preset-switcher" v-model="selected" class="preset-switcher">
+ <option v-for="style in availableStyles"
+ :value="style"
+ :style="{
+ backgroundColor: style[1] || style.theme.colors.bg,
+ color: style[3] || style.theme.colors.text
+ }">
+ {{style[0] || style.name}}
+ </option>
+ </select>
+ <i class="icon-down-open"/>
+ </label>
+ </div>
+ </template>
+ </export-import>
</div>
- <div class="import-export">
- <button class="btn" @click="exportCurrentTheme">{{ $t('settings.export_theme') }}</button>
- <button class="btn" @click="importTheme">{{ $t('settings.import_theme') }}</button>
- <p v-if="invalidThemeImported" class="import-warning">{{ $t('settings.invalid_theme_imported') }}</p>
+ <div class="save-load-options">
+ <span class="keep-option">
+ <input
+ id="keep-color"
+ type="checkbox"
+ v-model="keepColor">
+ <label for="keep-color">{{$t('settings.style.switcher.keep_color')}}</label>
+ </span>
+ <span class="keep-option">
+ <input
+ id="keep-shadows"
+ type="checkbox"
+ v-model="keepShadows">
+ <label for="keep-shadows">{{$t('settings.style.switcher.keep_shadows')}}</label>
+ </span>
+ <span class="keep-option">
+ <input
+ id="keep-opacity"
+ type="checkbox"
+ v-model="keepOpacity">
+ <label for="keep-opacity">{{$t('settings.style.switcher.keep_opacity')}}</label>
+ </span>
+ <span class="keep-option">
+ <input
+ id="keep-roundness"
+ type="checkbox"
+ v-model="keepRoundness">
+ <label for="keep-roundness">{{$t('settings.style.switcher.keep_roundness')}}</label>
+ </span>
+ <span class="keep-option">
+ <input
+ id="keep-fonts"
+ type="checkbox"
+ v-model="keepFonts">
+ <label for="keep-fonts">{{$t('settings.style.switcher.keep_fonts')}}</label>
+ </span>
+ <p>{{$t('settings.style.switcher.save_load_hint')}}</p>
</div>
</div>
<div class="preview-container">
- <div :style="{
- '--btnRadius': btnRadiusLocal + 'px',
- '--inputRadius': inputRadiusLocal + 'px',
- '--panelRadius': panelRadiusLocal + 'px',
- '--avatarRadius': avatarRadiusLocal + 'px',
- '--avatarAltRadius': avatarAltRadiusLocal + 'px',
- '--tooltipRadius': tooltipRadiusLocal + 'px',
- '--attachmentRadius': attachmentRadiusLocal + 'px'
- }">
- <div class="panel dummy">
- <div class="panel-heading" :style="{ 'background-color': btnColorLocal, 'color': textColorLocal }">Preview</div>
- <div class="panel-body theme-preview-content" :style="{ 'background-color': bgColorLocal, 'color': textColorLocal }">
- <div class="avatar" :style="{
- 'border-radius': avatarRadiusLocal + 'px'
- }">
- ( ͡° ͜ʖ ͡°)
- </div>
- <h4>Content</h4>
- <br>
- A bunch of more content and
- <a :style="{ color: linkColorLocal }">a nice lil' link</a>
- <i :style="{ color: blueColorLocal }" class="icon-reply"/>
- <i :style="{ color: greenColorLocal }" class="icon-retweet"/>
- <i :style="{ color: redColorLocal }" class="icon-cancel"/>
- <i :style="{ color: orangeColorLocal }" class="icon-star"/>
- <br>
- <button class="btn" :style="{ 'background-color': btnColorLocal, 'color': textColorLocal }">Button</button>
+ <preview :style="previewRules"/>
+ </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>
+ <button class="btn" @click="clearOpacity">{{$t('settings.style.switcher.clear_opacity')}}</button>
+ <button class="btn" @click="clearV1">{{$t('settings.style.switcher.clear_all')}}</button>
+ </div>
+ <p>{{$t('settings.theme_help_v2_1')}}</p>
+ <h4>{{ $t('settings.style.common_colors.main') }}</h4>
+ <div class="color-item">
+ <ColorInput name="bgColor" v-model="bgColorLocal" :label="$t('settings.background')"/>
+ <OpacityInput name="bgOpacity" v-model="bgOpacityLocal" :fallback="previewTheme.opacity.bg || 1"/>
+ <ColorInput name="textColor" v-model="textColorLocal" :label="$t('settings.text')"/>
+ <ContrastRatio :contrast="previewContrast.bgText"/>
+ <ColorInput name="linkColor" v-model="linkColorLocal" :label="$t('settings.links')"/>
+ <ContrastRatio :contrast="previewContrast.bgLink"/>
</div>
+ <div class="color-item">
+ <ColorInput name="fgColor" v-model="fgColorLocal" :label="$t('settings.foreground')"/>
+ <ColorInput name="fgTextColor" v-model="fgTextColorLocal" :label="$t('settings.text')" :fallback="previewTheme.colors.fgText"/>
+ <ColorInput name="fgLinkColor" v-model="fgLinkColorLocal" :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 name="cRedColor" v-model="cRedColorLocal" :label="$t('settings.cRed')"/>
+ <ContrastRatio :contrast="previewContrast.bgRed"/>
+ <ColorInput name="cBlueColor" v-model="cBlueColorLocal" :label="$t('settings.cBlue')"/>
+ <ContrastRatio :contrast="previewContrast.bgBlue"/>
+ </div>
+ <div class="color-item">
+ <ColorInput name="cGreenColor" v-model="cGreenColorLocal" :label="$t('settings.cGreen')"/>
+ <ContrastRatio :contrast="previewContrast.bgGreen"/>
+ <ColorInput name="cOrangeColor" v-model="cOrangeColorLocal" :label="$t('settings.cOrange')"/>
+ <ContrastRatio :contrast="previewContrast.bgOrange"/>
+ </div>
+ <p>{{$t('settings.theme_help_v2_2')}}</p>
</div>
- </div>
- </div>
- <div class="color-container">
- <p>{{$t('settings.theme_help')}}</p>
- <div class="color-item">
- <label for="bgcolor" class="theme-color-lb">{{$t('settings.background')}}</label>
- <input id="bgcolor" class="theme-color-cl" type="color" v-model="bgColorLocal">
- <input id="bgcolor-t" class="theme-color-in" type="text" v-model="bgColorLocal">
- </div>
- <div class="color-item">
- <label for="fgcolor" class="theme-color-lb">{{$t('settings.foreground')}}</label>
- <input id="fgcolor" class="theme-color-cl" type="color" v-model="btnColorLocal">
- <input id="fgcolor-t" class="theme-color-in" type="text" v-model="btnColorLocal">
- </div>
- <div class="color-item">
- <label for="textcolor" class="theme-color-lb">{{$t('settings.text')}}</label>
- <input id="textcolor" class="theme-color-cl" type="color" v-model="textColorLocal">
- <input id="textcolor-t" class="theme-color-in" type="text" v-model="textColorLocal">
- </div>
- <div class="color-item">
- <label for="linkcolor" class="theme-color-lb">{{$t('settings.links')}}</label>
- <input id="linkcolor" class="theme-color-cl" type="color" v-model="linkColorLocal">
- <input id="linkcolor-t" class="theme-color-in" type="text" v-model="linkColorLocal">
- </div>
- <div class="color-item">
- <label for="redcolor" class="theme-color-lb">{{$t('settings.cRed')}}</label>
- <input id="redcolor" class="theme-color-cl" type="color" v-model="redColorLocal">
- <input id="redcolor-t" class="theme-color-in" type="text" v-model="redColorLocal">
- </div>
- <div class="color-item">
- <label for="bluecolor" class="theme-color-lb">{{$t('settings.cBlue')}}</label>
- <input id="bluecolor" class="theme-color-cl" type="color" v-model="blueColorLocal">
- <input id="bluecolor-t" class="theme-color-in" type="text" v-model="blueColorLocal">
- </div>
- <div class="color-item">
- <label for="greencolor" class="theme-color-lb">{{$t('settings.cGreen')}}</label>
- <input id="greencolor" class="theme-color-cl" type="color" v-model="greenColorLocal">
- <input id="greencolor-t" class="theme-color-in" type="green" v-model="greenColorLocal">
- </div>
- <div class="color-item">
- <label for="orangecolor" class="theme-color-lb">{{$t('settings.cOrange')}}</label>
- <input id="orangecolor" class="theme-color-cl" type="color" v-model="orangeColorLocal">
- <input id="orangecolor-t" class="theme-color-in" type="text" v-model="orangeColorLocal">
- </div>
- </div>
+ <div :label="$t('settings.style.advanced_colors._tab_label')" class="color-container">
+ <div class="tab-header">
+ <p>{{$t('settings.theme_help')}}</p>
+ <button class="btn" @click="clearOpacity">{{$t('settings.style.switcher.clear_opacity')}}</button>
+ <button class="btn" @click="clearV1">{{$t('settings.style.switcher.clear_all')}}</button>
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.alert') }}</h4>
+ <ColorInput name="alertError" v-model="alertErrorColorLocal" :label="$t('settings.style.advanced_colors.alert_error')" :fallback="previewTheme.colors.alertError"/>
+ <ContrastRatio :contrast="previewContrast.alertError"/>
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.badge') }}</h4>
+ <ColorInput name="badgeNotification" v-model="badgeNotificationColorLocal" :label="$t('settings.style.advanced_colors.badge_notification')" :fallback="previewTheme.colors.badgeNotification"/>
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.panel_header') }}</h4>
+ <ColorInput name="panelColor" v-model="panelColorLocal" :fallback="fgColorLocal" :label="$t('settings.background')"/>
+ <OpacityInput name="panelOpacity" v-model="panelOpacityLocal" :fallback="previewTheme.opacity.panel || 1"/>
+ <ColorInput name="panelTextColor" v-model="panelTextColorLocal" :fallback="previewTheme.colors.panelText" :label="$t('settings.text')"/>
+ <ContrastRatio :contrast="previewContrast.panelText" large="1"/>
+ <ColorInput name="panelLinkColor" v-model="panelLinkColorLocal" :fallback="previewTheme.colors.panelLink" :label="$t('settings.links')"/>
+ <ContrastRatio :contrast="previewContrast.panelLink" large="1"/>
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.top_bar') }}</h4>
+ <ColorInput name="topBarColor" v-model="topBarColorLocal" :fallback="fgColorLocal" :label="$t('settings.background')"/>
+ <ColorInput name="topBarTextColor" v-model="topBarTextColorLocal" :fallback="previewTheme.colors.topBarText" :label="$t('settings.text')"/>
+ <ContrastRatio :contrast="previewContrast.topBarText"/>
+ <ColorInput name="topBarLinkColor" v-model="topBarLinkColorLocal" :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 name="inputColor" v-model="inputColorLocal" :fallback="fgColorLocal" :label="$t('settings.background')"/>
+ <OpacityInput name="inputOpacity" v-model="inputOpacityLocal" :fallback="previewTheme.opacity.input || 1"/>
+ <ColorInput name="inputTextColor" v-model="inputTextColorLocal" :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 name="btnColor" v-model="btnColorLocal" :fallback="fgColorLocal" :label="$t('settings.background')"/>
+ <OpacityInput name="btnOpacity" v-model="btnOpacityLocal" :fallback="previewTheme.opacity.btn || 1"/>
+ <ColorInput name="btnTextColor" v-model="btnTextColorLocal" :fallback="previewTheme.colors.btnText" :label="$t('settings.text')"/>
+ <ContrastRatio :contrast="previewContrast.btnText"/>
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.borders') }}</h4>
+ <ColorInput name="borderColor" v-model="borderColorLocal" :fallback="previewTheme.colors.border" :label="$t('settings.style.common.color')"/>
+ <OpacityInput name="borderOpacity" v-model="borderOpacityLocal" :fallback="previewTheme.opacity.border || 1"/>
+ </div>
+ <div class="color-item">
+ <h4>{{ $t('settings.style.advanced_colors.faint_text') }}</h4>
+ <ColorInput name="faintColor" v-model="faintColorLocal" :fallback="previewTheme.colors.faint || 1" :label="$t('settings.text')"/>
+ <ColorInput name="faintLinkColor" v-model="faintLinkColorLocal" :fallback="previewTheme.colors.faintLink" :label="$t('settings.links')"/>
+ <ColorInput name="panelFaintColor" v-model="panelFaintColorLocal" :fallback="previewTheme.colors.panelFaint" :label="$t('settings.style.advanced_colors.panel_header')"/>
+ <OpacityInput name="faintOpacity" v-model="faintOpacityLocal" :fallback="previewTheme.opacity.faint || 0.5"/>
+ </div>
+ </div>
- <div class="radius-container">
- <p>{{$t('settings.radii_help')}}</p>
- <div class="radius-item">
- <label for="btnradius" class="theme-radius-lb">{{$t('settings.btnRadius')}}</label>
- <input id="btnradius" class="theme-radius-rn" type="range" v-model="btnRadiusLocal" max="16">
- <input id="btnradius-t" class="theme-radius-in" type="text" v-model="btnRadiusLocal">
- </div>
- <div class="radius-item">
- <label for="inputradius" class="theme-radius-lb">{{$t('settings.inputRadius')}}</label>
- <input id="inputradius" class="theme-radius-rn" type="range" v-model="inputRadiusLocal" max="16">
- <input id="inputradius-t" class="theme-radius-in" type="text" v-model="inputRadiusLocal">
- </div>
- <div class="radius-item">
- <label for="panelradius" class="theme-radius-lb">{{$t('settings.panelRadius')}}</label>
- <input id="panelradius" class="theme-radius-rn" type="range" v-model="panelRadiusLocal" max="50">
- <input id="panelradius-t" class="theme-radius-in" type="text" v-model="panelRadiusLocal">
- </div>
- <div class="radius-item">
- <label for="avatarradius" class="theme-radius-lb">{{$t('settings.avatarRadius')}}</label>
- <input id="avatarradius" class="theme-radius-rn" type="range" v-model="avatarRadiusLocal" max="28">
- <input id="avatarradius-t" class="theme-radius-in" type="green" v-model="avatarRadiusLocal">
- </div>
- <div class="radius-item">
- <label for="avataraltradius" class="theme-radius-lb">{{$t('settings.avatarAltRadius')}}</label>
- <input id="avataraltradius" class="theme-radius-rn" type="range" v-model="avatarAltRadiusLocal" max="28">
- <input id="avataraltradius-t" class="theme-radius-in" type="text" v-model="avatarAltRadiusLocal">
- </div>
- <div class="radius-item">
- <label for="attachmentradius" class="theme-radius-lb">{{$t('settings.attachmentRadius')}}</label>
- <input id="attachmentrradius" class="theme-radius-rn" type="range" v-model="attachmentRadiusLocal" max="50">
- <input id="attachmentradius-t" class="theme-radius-in" type="text" v-model="attachmentRadiusLocal">
- </div>
- <div class="radius-item">
- <label for="tooltipradius" class="theme-radius-lb">{{$t('settings.tooltipRadius')}}</label>
- <input id="tooltipradius" class="theme-radius-rn" type="range" v-model="tooltipRadiusLocal" max="20">
- <input id="tooltipradius-t" class="theme-radius-in" type="text" v-model="tooltipRadiusLocal">
- </div>
- </div>
+ <div :label="$t('settings.style.radii._tab_label')" class="radius-container">
+ <div class="tab-header">
+ <p>{{$t('settings.radii_help')}}</p>
+ <button class="btn" @click="clearRoundness">{{$t('settings.style.switcher.clear_all')}}</button>
+ </div>
+ <RangeInput name="btnRadius" :label="$t('settings.btnRadius')" v-model="btnRadiusLocal" :fallback="previewTheme.radii.btn" max="16" hardMin="0"/>
+ <RangeInput name="inputRadius" :label="$t('settings.inputRadius')" v-model="inputRadiusLocal" :fallback="previewTheme.radii.input" max="9" hardMin="0"/>
+ <RangeInput name="checkboxRadius" :label="$t('settings.checkboxRadius')" v-model="checkboxRadiusLocal" :fallback="previewTheme.radii.checkbox" max="16" hardMin="0"/>
+ <RangeInput name="panelRadius" :label="$t('settings.panelRadius')" v-model="panelRadiusLocal" :fallback="previewTheme.radii.panel" max="50" hardMin="0"/>
+ <RangeInput name="avatarRadius" :label="$t('settings.avatarRadius')" v-model="avatarRadiusLocal" :fallback="previewTheme.radii.avatar" max="28" hardMin="0"/>
+ <RangeInput name="avatarAltRadius" :label="$t('settings.avatarAltRadius')" v-model="avatarAltRadiusLocal" :fallback="previewTheme.radii.avatarAlt" max="28" hardMin="0"/>
+ <RangeInput name="attachmentRadius" :label="$t('settings.attachmentRadius')" v-model="attachmentRadiusLocal" :fallback="previewTheme.radii.attachment" max="50" hardMin="0"/>
+ <RangeInput name="tooltipRadius" :label="$t('settings.tooltipRadius')" v-model="tooltipRadiusLocal" :fallback="previewTheme.radii.tooltip" max="50" hardMin="0"/>
+ </div>
+
+ <div :label="$t('settings.style.shadows._tab_label')" class="shadow-container">
+ <div class="tab-header shadow-selector">
+ <div class="select-container">
+ {{$t('settings.style.shadows.component')}}
+ <label for="shadow-switcher" class="select">
+ <select id="shadow-switcher" v-model="shadowSelected" class="shadow-switcher">
+ <option v-for="shadow in shadowsAvailable"
+ :value="shadow">
+ {{$t('settings.style.shadows.components.' + shadow)}}
+ </option>
+ </select>
+ <i class="icon-down-open"/>
+ </label>
+ </div>
+ <div class="override">
+ <label for="override" class="label">
+ {{$t('settings.style.shadows.override')}}
+ </label>
+ <input
+ v-model="currentShadowOverriden"
+ name="override"
+ id="override"
+ class="input-override"
+ type="checkbox">
+ <label class="checkbox-label" for="override"></label>
+ </div>
+ <button class="btn" @click="clearShadows">{{$t('settings.style.switcher.clear_all')}}</button>
+ </div>
+ <shadow-control :ready="!!currentShadowFallback" :fallback="currentShadowFallback" v-model="currentShadow"/>
+ <div v-if="shadowSelected === 'avatar' || shadowSelected === 'avatarStatus'">
+ <i18n path="settings.style.shadows.filter_hint.always_drop_shadow" tag="p">
+ <code>filter: drop-shadow()</code>
+ </i18n>
+ <p>{{$t('settings.style.shadows.filter_hint.avatar_inset')}}</p>
+ <i18n path="settings.style.shadows.filter_hint.drop_shadow_syntax" tag="p">
+ <code>drop-shadow</code>
+ <code>spread-radius</code>
+ <code>inset</code>
+ </i18n>
+ <i18n path="settings.style.shadows.filter_hint.inset_classic" tag="p">
+ <code>box-shadow</code>
+ </i18n>
+ <p>{{$t('settings.style.shadows.filter_hint.spread_zero')}}</p>
+ </div>
+ </div>
+
+ <div :label="$t('settings.style.fonts._tab_label')" class="fonts-container">
+ <div class="tab-header">
+ <p>{{$t('settings.style.fonts.help')}}</p>
+ <button class="btn" @click="clearFonts">{{$t('settings.style.switcher.clear_all')}}</button>
+ </div>
+ <FontControl
+ name="ui"
+ v-model="fontsLocal.interface"
+ :label="$t('settings.style.fonts.components.interface')"
+ :fallback="previewTheme.fonts.interface"
+ no-inherit="1"/>
+ <FontControl
+ name="input"
+ v-model="fontsLocal.input"
+ :label="$t('settings.style.fonts.components.input')"
+ :fallback="previewTheme.fonts.input"/>
+ <FontControl
+ name="post"
+ v-model="fontsLocal.post"
+ :label="$t('settings.style.fonts.components.post')"
+ :fallback="previewTheme.fonts.post"/>
+ <FontControl
+ name="postCode"
+ v-model="fontsLocal.postCode"
+ :label="$t('settings.style.fonts.components.postCode')"
+ :fallback="previewTheme.fonts.postCode"/>
+ </div>
+ </tab-switcher>
+ </keep-alive>
<div class="apply-container">
- <button class="btn submit" @click="setCustomTheme">{{$t('general.apply')}}</button>
+ <button class="btn submit" :disabled="!themeValid" @click="setCustomTheme">{{$t('general.apply')}}</button>
+ <button class="btn" @click="clearAll">{{$t('settings.style.switcher.reset')}}</button>
</div>
</div>
</template>
<script src="./style_switcher.js"></script>
-<style lang="scss">
-@import '../../_variables.scss';
-.style-switcher {
- margin-right: 1em;
-}
-
-.import-warning {
- color: $fallback--cRed;
- color: var(--cRed, $fallback--cRed);
-}
-
-.apply-container,
-.radius-container,
-.color-container,
-.presets-container {
- display: flex;
-
- p {
- flex: 2 0 100%;
- margin-top: 2em;
- margin-bottom: .5em;
- }
-}
-
-.radius-container {
- flex-direction: column;
-}
-
-.color-container {
- flex-wrap: wrap;
- justify-content: space-between;
-}
-
-.presets-container {
- justify-content: center;
- .import-export {
- display: flex;
-
- .btn {
- margin-left: .5em;
- }
- }
-}
-
-.preview-container {
- border-top: 1px dashed;
- border-bottom: 1px dashed;
- border-color: $fallback--border;
- border-color: var(--border, $fallback--border);
- margin: 1em -1em 0;
- padding: 1em;
-
- .btn {
- margin-top: 1em;
- min-height: 30px;
- width: 10em;
- }
-}
-
-.apply-container {
- justify-content: center;
-}
-
-.radius-item,
-.color-item {
- min-width: 20em;
- display:flex;
- flex: 1 1 0;
- align-items: baseline;
- margin: 5px 6px 5px 0;
-
- label {
- color: var(--faint, $fallback--faint);
- }
-}
-
-.radius-item {
- flex-basis: auto;
-}
-
-.theme-radius-rn,
-.theme-color-cl {
- border: 0;
- box-shadow: none;
- background: transparent;
- color: var(--faint, $fallback--faint);
- align-self: stretch;
-}
-
-.theme-color-cl,
-.theme-radius-in,
-.theme-color-in {
- margin-left: 4px;
-}
-
-.theme-color-in {
- min-width: 4em;
-}
-
-.theme-radius-in {
- min-width: 1em;
-}
-
-.theme-radius-in,
-.theme-color-in {
- max-width: 7em;
- flex: 1;
-}
-
-.theme-radius-lb,
-.theme-color-lb {
- flex: 2;
- min-width: 7em;
-}
-
-.theme-radius-lb{
- max-width: 50em;
-}
-
-.theme-color-lb {
- max-width: 10em;
-}
-
-.theme-color-cl {
- padding: 1px;
- max-width: 8em;
- height: 100%;
- flex: 0;
- min-width: 2em;
- cursor: pointer;
- max-height: 29px;
-}
-
-.theme-preview-content {
- padding: 20px;
-}
-
-.dummy {
- .avatar {
- background: linear-gradient(135deg, #b8e1fc 0%,#a9d2f3 10%,#90bae4 25%,#90bcea 37%,#90bff0 50%,#6ba8e5 51%,#a2daf5 83%,#bdf3fd 100%);
- color: black;
- text-align: center;
- height: 48px;
- line-height: 48px;
- width: 48px;
- float: left;
- margin-right: 1em;
- }
-}
-</style>
+<style src="./style_switcher.scss" lang="scss"></style>
diff --git a/src/components/tab_switcher/tab_switcher.jsx b/src/components/tab_switcher/tab_switcher.jsx
index 3fff38f6..9e3dee04 100644
--- a/src/components/tab_switcher/tab_switcher.jsx
+++ b/src/components/tab_switcher/tab_switcher.jsx
@@ -25,11 +25,14 @@ export default Vue.component('tab-switcher', {
}
return (<button onClick={this.activateTab(index)} class={ classes.join(' ') }>{slot.data.attrs.label}</button>)
});
- const contents = (
- <div>
- {this.$slots.default.filter(slot => slot.data)[this.active]}
- </div>
- );
+ const contents = this.$slots.default.filter(_=>_.data).map(( slot, index ) => {
+ const active = index === this.active
+ return (
+ <div class={active ? 'active' : 'hidden'}>
+ {slot}
+ </div>
+ )
+ });
return (
<div class="tab-switcher">
<div class="tabs">
diff --git a/src/components/tab_switcher/tab_switcher.scss b/src/components/tab_switcher/tab_switcher.scss
index 374a19c5..fbd3321b 100644
--- a/src/components/tab_switcher/tab_switcher.scss
+++ b/src/components/tab_switcher/tab_switcher.scss
@@ -1,13 +1,21 @@
@import '../../_variables.scss';
.tab-switcher {
+ .contents {
+ .hidden {
+ display: none;
+ }
+ }
.tabs {
display: flex;
position: relative;
justify-content: center;
width: 100%;
- overflow: hidden;
+ overflow-y: hidden;
+ overflow-x: auto;
padding-top: 5px;
+ height: 32px;
+ box-sizing: border-box;
&::after, &::before {
display: block;
@@ -17,20 +25,34 @@
.tab, &::after, &::before {
border-bottom: 1px solid;
- border-bottom-color: $fallback--btn;
- border-bottom-color: var(--btn, $fallback--btn);
+ border-bottom-color: $fallback--border;
+ border-bottom-color: var(--border, $fallback--border);
}
.tab {
+ position: relative;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
- padding: .3em 1em;
+ padding: 5px 1em 99px;
+ white-space: nowrap;
&:not(.active) {
- border-bottom: 1px solid;
- border-bottom-color: $fallback--btn;
- border-bottom-color: var(--btn, $fallback--btn);
z-index: 4;
+
+ &:hover {
+ z-index: 6;
+ }
+
+ &::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 26px;
+ border-bottom: 1px solid;
+ border-bottom-color: $fallback--border;
+ border-bottom-color: var(--border, $fallback--border);
+ }
}
&.active {
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index a651f619..f28b85bd 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -2,6 +2,7 @@ import Status from '../status/status.vue'
import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js'
import StatusOrConversation from '../status_or_conversation/status_or_conversation.vue'
import UserCard from '../user_card/user_card.vue'
+import { throttle } from 'lodash'
const Timeline = {
props: [
@@ -88,7 +89,7 @@ const Timeline = {
this.paused = false
}
},
- fetchOlderStatuses () {
+ fetchOlderStatuses: throttle(function () {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
store.commit('setLoading', { timeline: this.timelineName, value: true })
@@ -101,7 +102,7 @@ const Timeline = {
userId: this.userId,
tag: this.tag
}).then(() => store.commit('setLoading', { timeline: this.timelineName, value: false }))
- },
+ }, 1000, this),
fetchFollowers () {
const id = this.userId
this.$store.state.api.backendInteractor.fetchFollowers({ id })
diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue
index 2dd4376a..bc7f74c2 100644
--- a/src/components/timeline/timeline.vue
+++ b/src/components/timeline/timeline.vue
@@ -10,7 +10,7 @@
<button @click.prevent="showNewStatuses" class="loadmore-button" v-if="timeline.newStatusCount > 0 && !timelineError">
{{$t('timeline.show_new')}}{{newStatusCountStr}}
</button>
- <div @click.prevent class="loadmore-text" v-if="!timeline.newStatusCount > 0 && !timelineError">
+ <div @click.prevent class="loadmore-text faint" v-if="!timeline.newStatusCount > 0 && !timelineError">
{{$t('timeline.up_to_date')}}
</div>
</div>
@@ -58,15 +58,7 @@
.timeline {
.loadmore-text {
- opacity: 0.8;
- background-color: transparent;
- color: $fallback--faint;
- color: var(--faint, $fallback--faint);
- }
-
- .loadmore-error {
- color: $fallback--fg;
- color: var(--fg, $fallback--fg);
+ opacity: 1;
}
}
@@ -79,7 +71,7 @@
border-color: var(--border, $fallback--border);
padding: 10px;
z-index: 1;
- background-color: $fallback--btn;
- background-color: var(--btn, $fallback--btn);
+ background-color: $fallback--fg;
+ background-color: var(--panel, $fallback--fg);
}
</style>
diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js
index a019627a..b8eb28e3 100644
--- a/src/components/user_card/user_card.js
+++ b/src/components/user_card/user_card.js
@@ -14,6 +14,9 @@ const UserCard = {
components: {
UserCardContent
},
+ computed: {
+ currentUser () { return this.$store.state.users.currentUser }
+ },
methods: {
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index 5a8e5531..eb0d7576 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -10,13 +10,13 @@
<div :title="user.name" v-if="user.name_html" class="user-name">
<span v-html="user.name_html"></span>
<span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you">
- {{ $t('user_card.follows_you') }}
+ {{ currentUser.id == user.id ? $t('user_card.its_you') : $t('user_card.follows_you') }}
</span>
</div>
<div :title="user.name" v-else class="user-name">
{{ user.name }}
<span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you">
- {{ $t('user_card.follows_you') }}
+ {{ currentUser.id == user.id ? $t('user_card.its_you') : $t('user_card.follows_you') }}
</span>
</div>
<router-link class='user-screen-name' :to="{ name: 'user-profile', params: { id: user.id } }">
diff --git a/src/components/user_card_content/user_card_content.js b/src/components/user_card_content/user_card_content.js
index b5dd9b91..be7a2349 100644
--- a/src/components/user_card_content/user_card_content.js
+++ b/src/components/user_card_content/user_card_content.js
@@ -2,24 +2,38 @@ import StillImage from '../still-image/still-image.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js'
export default {
- props: [ 'user', 'switcher', 'selected', 'hideBio' ],
+ props: [ 'user', 'switcher', 'selected', 'hideBio', 'activatePanel' ],
data () {
return {
hideUserStatsLocal: typeof this.$store.state.config.hideUserStats === 'undefined'
? this.$store.state.instance.hideUserStats
- : this.$store.state.config.hideUserStats
+ : this.$store.state.config.hideUserStats,
+ betterShadow: this.$store.state.interface.browserSupport.cssFilter
}
},
computed: {
headingStyle () {
- const color = this.$store.state.config.colors.bg
+ const color = this.$store.state.config.customTheme.colors
+ ? this.$store.state.config.customTheme.colors.bg // v2
+ : this.$store.state.config.colors.bg // v1
+
if (color) {
- const rgb = hex2rgb(color)
+ const rgb = (typeof color === 'string') ? hex2rgb(color) : color
const tintColor = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .5)`
+
+ const gradient = [
+ [tintColor, this.hideBio ? '60%' : ''],
+ this.hideBio ? [
+ color, '100%'
+ ] : [
+ tintColor, ''
+ ]
+ ].map(_ => _.join(' ')).join(', ')
+
return {
backgroundColor: `rgb(${Math.floor(rgb.r * 0.53)}, ${Math.floor(rgb.g * 0.56)}, ${Math.floor(rgb.b * 0.59)})`,
backgroundImage: [
- `linear-gradient(to bottom, ${tintColor}, ${tintColor})`,
+ `linear-gradient(to bottom, ${gradient})`,
`url(${this.user.cover_photo})`
].join(', ')
}
@@ -98,6 +112,14 @@ export default {
const store = this.$store
store.commit('setProfileView', { v })
}
+ },
+ linkClicked ({target}) {
+ if (target.tagName === 'SPAN') {
+ target = target.parentNode
+ }
+ if (target.tagName === 'A') {
+ window.open(target.href, '_blank')
+ }
}
}
}
diff --git a/src/components/user_card_content/user_card_content.vue b/src/components/user_card_content/user_card_content.vue
index 84669d7f..08b25595 100644
--- a/src/components/user_card_content/user_card_content.vue
+++ b/src/components/user_card_content/user_card_content.vue
@@ -2,20 +2,20 @@
<div id="heading" class="profile-panel-background" :style="headingStyle">
<div class="panel-heading text-center">
<div class='user-info'>
- <router-link to='/user-settings' style="float: right; margin-top:16px;" v-if="!isOtherUser">
- <i class="icon-cog usersettings"></i>
+ <router-link @click.native="activatePanel && activatePanel('timeline')" to='/user-settings' style="float: right; margin-top:16px;" v-if="!isOtherUser">
+ <i class="icon-cog usersettings" :title="$t('tool_tip.user_settings')"></i>
</router-link>
<a :href="user.statusnet_profile_url" target="_blank" class="floater" v-if="isOtherUser">
<i class="icon-link-ext usersettings"></i>
</a>
<div class='container'>
- <router-link :to="{ name: 'user-profile', params: { id: user.id } }">
- <StillImage class="avatar" :src="user.profile_image_url_original"/>
+ <router-link @click.native="activatePanel && activatePanel('timeline')" :to="{ name: 'user-profile', params: { id: user.id } }">
+ <StillImage class="avatar" :class='{ "better-shadow": betterShadow }' :src="user.profile_image_url_original"/>
</router-link>
<div class="name-and-screen-name">
<div :title="user.name" class='user-name' v-if="user.name_html" v-html="user.name_html"></div>
<div :title="user.name" class='user-name' v-else>{{user.name}}</div>
- <router-link class='user-screen-name':to="{ name: 'user-profile', params: { id: user.id } }">
+ <router-link @click.native="activatePanel && activatePanel('timeline')" class='user-screen-name':to="{ name: 'user-profile', params: { id: user.id } }">
<span>@{{user.screen_name}}</span><span v-if="user.locked"><i class="icon icon-lock"></i></span>
<span v-if="!hideUserStatsLocal" class="dailyAvg">{{dailyAvg}} {{ $t('user_card.per_day') }}</span>
</router-link>
@@ -41,74 +41,74 @@
</div>
</div>
<div v-if="isOtherUser" class="user-interactions">
- <div class="follow" v-if="loggedIn">
- <span v-if="user.following">
- <!--Following them!-->
- <button @click="unfollowUser" class="pressed">
- {{ $t('user_card.following') }}
- </button>
- </span>
- <span v-if="!user.following">
- <button @click="followUser">
- {{ $t('user_card.follow') }}
- </button>
- </span>
- </div>
- <div class='mute' v-if='isOtherUser'>
- <span v-if='user.muted'>
- <button @click="toggleMute" class="pressed">
- {{ $t('user_card.muted') }}
- </button>
- </span>
- <span v-if='!user.muted'>
- <button @click="toggleMute">
- {{ $t('user_card.mute') }}
- </button>
- </span>
- </div>
- <div class="remote-follow" v-if='!loggedIn && user.is_local'>
- <form method="POST" :action='subscribeUrl'>
- <input type="hidden" name="nickname" :value="user.screen_name">
- <input type="hidden" name="profile" value="">
- <button click="submit" class="remote-button">
- {{ $t('user_card.remote_follow') }}
- </button>
- </form>
- </div>
- <div class='block' v-if='isOtherUser && loggedIn'>
- <span v-if='user.statusnet_blocking'>
- <button @click="unblockUser" class="pressed">
- {{ $t('user_card.blocked') }}
- </button>
- </span>
- <span v-if='!user.statusnet_blocking'>
- <button @click="blockUser">
- {{ $t('user_card.block') }}
- </button>
- </span>
- </div>
+ <div class="follow" v-if="loggedIn">
+ <span v-if="user.following">
+ <!--Following them!-->
+ <button @click="unfollowUser" class="pressed">
+ {{ $t('user_card.following') }}
+ </button>
+ </span>
+ <span v-if="!user.following">
+ <button @click="followUser">
+ {{ $t('user_card.follow') }}
+ </button>
+ </span>
</div>
- </div>
- </div>
- <div class="panel-body profile-panel-body">
- <div v-if="!hideUserStatsLocal || switcher" class="user-counts" :class="{clickable: switcher}">
- <div class="user-count" v-on:click.prevent="setProfileView('statuses')" :class="{selected: selected === 'statuses'}">
- <h5>{{ $t('user_card.statuses') }}</h5>
- <span v-if="!hideUserStatsLocal">{{user.statuses_count}} <br></span>
+ <div class='mute' v-if='isOtherUser'>
+ <span v-if='user.muted'>
+ <button @click="toggleMute" class="pressed">
+ {{ $t('user_card.muted') }}
+ </button>
+ </span>
+ <span v-if='!user.muted'>
+ <button @click="toggleMute">
+ {{ $t('user_card.mute') }}
+ </button>
+ </span>
</div>
- <div class="user-count" v-on:click.prevent="setProfileView('friends')" :class="{selected: selected === 'friends'}">
- <h5>{{ $t('user_card.followees') }}</h5>
- <span v-if="!hideUserStatsLocal">{{user.friends_count}}</span>
+ <div class="remote-follow" v-if='!loggedIn && user.is_local'>
+ <form method="POST" :action='subscribeUrl'>
+ <input type="hidden" name="nickname" :value="user.screen_name">
+ <input type="hidden" name="profile" value="">
+ <button click="submit" class="remote-button">
+ {{ $t('user_card.remote_follow') }}
+ </button>
+ </form>
</div>
- <div class="user-count" v-on:click.prevent="setProfileView('followers')" :class="{selected: selected === 'followers'}">
- <h5>{{ $t('user_card.followers') }}</h5>
- <span v-if="!hideUserStatsLocal">{{user.followers_count}}</span>
+ <div class='block' v-if='isOtherUser && loggedIn'>
+ <span v-if='user.statusnet_blocking'>
+ <button @click="unblockUser" class="pressed">
+ {{ $t('user_card.blocked') }}
+ </button>
+ </span>
+ <span v-if='!user.statusnet_blocking'>
+ <button @click="blockUser">
+ {{ $t('user_card.block') }}
+ </button>
+ </span>
</div>
</div>
- <p v-if="!hideBio && user.description_html" class="profile-bio" v-html="user.description_html"></p>
- <p v-else-if="!hideBio" class="profile-bio">{{ user.description }}</p>
</div>
</div>
+ <div class="panel-body profile-panel-body" v-if="!hideBio">
+ <div v-if="!hideUserStatsLocal || switcher" class="user-counts" :class="{clickable: switcher}">
+ <div class="user-count" v-on:click.prevent="setProfileView('statuses')" :class="{selected: selected === 'statuses'}">
+ <h5>{{ $t('user_card.statuses') }}</h5>
+ <span v-if="!hideUserStatsLocal">{{user.statuses_count}} <br></span>
+ </div>
+ <div class="user-count" v-on:click.prevent="setProfileView('friends')" :class="{selected: selected === 'friends'}">
+ <h5>{{ $t('user_card.followees') }}</h5>
+ <span v-if="!hideUserStatsLocal">{{user.friends_count}}</span>
+ </div>
+ <div class="user-count" v-on:click.prevent="setProfileView('followers')" :class="{selected: selected === 'followers'}">
+ <h5>{{ $t('user_card.followers') }}</h5>
+ <span v-if="!hideUserStatsLocal">{{user.followers_count}}</span>
+ </div>
+ </div>
+ <p @click.prevent="linkClicked" v-if="!hideBio && user.description_html" class="profile-bio" v-html="user.description_html"></p>
+ <p v-else-if="!hideBio" class="profile-bio">{{ user.description }}</p>
+ </div>
+</div>
</template>
<script src="./user_card_content.js"></script>
@@ -120,10 +120,15 @@
background-size: cover;
border-radius: $fallback--panelRadius;
border-radius: var(--panelRadius, $fallback--panelRadius);
+ overflow: hidden;
+
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
.panel-heading {
padding: 0.6em 0em;
text-align: center;
+ box-shadow: none;
}
}
@@ -138,15 +143,14 @@
}
.user-info {
- color: $fallback--lightFg;
- color: var(--lightFg, $fallback--lightFg);
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
padding: 0 16px;
.container {
padding: 16px 10px 6px 10px;
display: flex;
max-height: 56px;
- overflow: hidden;
.avatar {
border-radius: $fallback--avatarRadius;
@@ -155,8 +159,14 @@
width: 56px;
height: 56px;
box-shadow: 0px 1px 8px rgba(0,0,0,0.75);
+ box-shadow: var(--avatarShadow);
object-fit: cover;
+ &.better-shadow {
+ box-shadow: var(--avatarShadowInset);
+ filter: var(--avatarShadowFilter)
+ }
+
&.animated::before {
display: none;
}
@@ -173,8 +183,8 @@
}
.usersettings {
- color: $fallback--lightFg;
- color: var(--lightFg, $fallback--lightFg);
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
opacity: .8;
}
@@ -185,6 +195,16 @@
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 0;
+ // This is so that text doesn't get overlapped by avatar's shadow if it has
+ // big one
+ z-index: 1;
+
+ img {
+ width: 26px;
+ height: 26px;
+ vertical-align: middle;
+ object-fit: contain
+ }
}
.user-name{
@@ -193,8 +213,8 @@
}
.user-screen-name {
- color: $fallback--lightFg;
- color: var(--lightFg, $fallback--lightFg);
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
display: inline-block;
font-weight: light;
font-size: 15px;
@@ -269,8 +289,8 @@
padding: .5em 1.5em 0em 1.5em;
text-align: center;
justify-content: space-between;
- color: $fallback--lightFg;
- color: var(--lightFg, $fallback--lightFg);
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
&.clickable {
.user-count {
diff --git a/src/components/user_finder/user_finder.vue b/src/components/user_finder/user_finder.vue
index e0a7962a..9efdfab7 100644
--- a/src/components/user_finder/user_finder.vue
+++ b/src/components/user_finder/user_finder.vue
@@ -1,5 +1,5 @@
<template>
- <span class="user-finder-container">
+ <div class="user-finder-container">
<i class="icon-spin4 user-finder-icon animate-spin-slow" v-if="loading" />
<a href="#" v-if="hidden" :title="$t('finder.find_user')"><i class="icon-user-plus user-finder-icon" @click.prevent.stop="toggleHidden" /></a>
<template v-else>
@@ -9,7 +9,7 @@
</button>
<i class="icon-cancel user-finder-icon" @click.prevent.stop="toggleHidden"/>
</template>
- </span>
+ </div>
</template>
<script src="./user_finder.js"></script>
diff --git a/src/components/user_panel/user_panel.js b/src/components/user_panel/user_panel.js
index 15804b88..eb7cb09c 100644
--- a/src/components/user_panel/user_panel.js
+++ b/src/components/user_panel/user_panel.js
@@ -3,6 +3,7 @@ import PostStatusForm from '../post_status_form/post_status_form.vue'
import UserCardContent from '../user_card_content/user_card_content.vue'
const UserPanel = {
+ props: [ 'activatePanel' ],
computed: {
user () { return this.$store.state.users.currentUser }
},
diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue
index 2d5cb500..83eb099f 100644
--- a/src/components/user_panel/user_panel.vue
+++ b/src/components/user_panel/user_panel.vue
@@ -1,7 +1,7 @@
<template>
<div class="user-panel">
<div v-if='user' class="panel panel-default" style="overflow: visible;">
- <user-card-content :user="user" :switcher="false" :hideBio="true"></user-card-content>
+ <user-card-content :activatePanel="activatePanel" :user="user" :switcher="false" :hideBio="true"></user-card-content>
<div class="panel-footer">
<post-status-form v-if='user'></post-status-form>
</div>
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index 91d4acd2..4d2853a6 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -3,6 +3,16 @@
<div v-if="user" class="user-profile panel panel-default">
<user-card-content :user="user" :switcher="true" :selected="timeline.viewing"></user-card-content>
</div>
+ <div v-else class="panel user-profile-placeholder">
+ <div class="panel-heading">
+ <div class="title">
+ {{ $t('settings.profile_tab') }}
+ </div>
+ </div>
+ <div class="panel-body">
+ <i class="icon-spin3 animate-spin"></i>
+ </div>
+ </div>
<Timeline :title="$t('user_profile.timeline_title')" :timeline="timeline" :timeline-name="'user'" :user-id="userId"/>
</div>
</template>
@@ -21,4 +31,12 @@
align-items: stretch;
}
}
+.user-profile-placeholder {
+ .panel-body {
+ display: flex;
+ justify-content: center;
+ align-items: middle;
+ padding: 7em;
+ }
+}
</style>
diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js
index 761e674a..ca7bf319 100644
--- a/src/components/user_settings/user_settings.js
+++ b/src/components/user_settings/user_settings.js
@@ -1,20 +1,30 @@
import TabSwitcher from '../tab_switcher/tab_switcher.jsx'
import StyleSwitcher from '../style_switcher/style_switcher.vue'
+import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
const UserSettings = {
data () {
return {
- newname: this.$store.state.users.currentUser.name,
- newbio: this.$store.state.users.currentUser.description,
- newlocked: this.$store.state.users.currentUser.locked,
- newnorichtext: this.$store.state.users.currentUser.no_rich_text,
- newdefaultScope: this.$store.state.users.currentUser.default_scope,
+ newName: this.$store.state.users.currentUser.name,
+ newBio: this.$store.state.users.currentUser.description,
+ newLocked: this.$store.state.users.currentUser.locked,
+ newNoRichText: this.$store.state.users.currentUser.no_rich_text,
+ newDefaultScope: this.$store.state.users.currentUser.default_scope,
+ newHideNetwork: this.$store.state.users.currentUser.hide_network,
followList: null,
followImportError: false,
followsImported: false,
enableFollowsExport: true,
- uploading: [ false, false, false, false ],
- previews: [ null, null, null ],
+ avatarUploading: false,
+ bannerUploading: false,
+ backgroundUploading: false,
+ followListUploading: false,
+ avatarPreview: null,
+ bannerPreview: null,
+ backgroundPreview: null,
+ avatarUploadError: null,
+ bannerUploadError: null,
+ backgroundUploadError: null,
deletingAccount: false,
deleteAccountConfirmPasswordInput: '',
deleteAccountError: false,
@@ -40,48 +50,67 @@ const UserSettings = {
},
vis () {
return {
- public: { selected: this.newdefaultScope === 'public' },
- unlisted: { selected: this.newdefaultScope === 'unlisted' },
- private: { selected: this.newdefaultScope === 'private' },
- direct: { selected: this.newdefaultScope === 'direct' }
+ public: { selected: this.newDefaultScope === 'public' },
+ unlisted: { selected: this.newDefaultScope === 'unlisted' },
+ private: { selected: this.newDefaultScope === 'private' },
+ direct: { selected: this.newDefaultScope === 'direct' }
}
}
},
methods: {
updateProfile () {
const name = this.newname
- const description = this.newbio
- const locked = this.newlocked
+ const description = this.newBio
+ const locked = this.newLocked
+ // Backend notation.
/* eslint-disable camelcase */
- const default_scope = this.newdefaultScope
- const no_rich_text = this.newnorichtext
- this.$store.state.api.backendInteractor.updateProfile({params: {name, description, locked, default_scope, no_rich_text}}).then((user) => {
- if (!user.error) {
- this.$store.commit('addNewUsers', [user])
- this.$store.commit('setCurrentUser', user)
- }
- })
+ const default_scope = this.newDefaultScope
+ const no_rich_text = this.newNoRichText
+ const hide_network = this.newHideNetwork
/* eslint-enable camelcase */
+ this.$store.state.api.backendInteractor
+ .updateProfile({
+ params: {
+ name,
+ description,
+ locked,
+ // Backend notation.
+ /* eslint-disable camelcase */
+ default_scope,
+ no_rich_text,
+ hide_network
+ /* eslint-enable camelcase */
+ }}).then((user) => {
+ if (!user.error) {
+ this.$store.commit('addNewUsers', [user])
+ this.$store.commit('setCurrentUser', user)
+ }
+ })
},
changeVis (visibility) {
- this.newdefaultScope = visibility
+ this.newDefaultScope = visibility
},
uploadFile (slot, e) {
const file = e.target.files[0]
if (!file) { return }
+ if (file.size > this.$store.state.instance[slot + 'limit']) {
+ const filesize = fileSizeFormatService.fileSizeFormat(file.size)
+ const allowedsize = fileSizeFormatService.fileSizeFormat(this.$store.state.instance[slot + 'limit'])
+ this[slot + 'UploadError'] = this.$t('upload.error.base') + ' ' + this.$t('upload.error.file_too_big', {filesize: filesize.num, filesizeunit: filesize.unit, allowedsize: allowedsize.num, allowedsizeunit: allowedsize.unit})
+ return
+ }
// eslint-disable-next-line no-undef
const reader = new FileReader()
reader.onload = ({target}) => {
const img = target.result
- this.previews[slot] = img
- this.$forceUpdate() // just changing the array with the index doesn't update the view
+ this[slot + 'Preview'] = img
}
reader.readAsDataURL(file)
},
submitAvatar () {
- if (!this.previews[0]) { return }
+ if (!this.avatarPreview) { return }
- let img = this.previews[0]
+ let img = this.avatarPreview
// eslint-disable-next-line no-undef
let imginfo = new Image()
let cropX, cropY, cropW, cropH
@@ -97,20 +126,25 @@ const UserSettings = {
cropX = Math.floor((imginfo.width - imginfo.height) / 2)
cropW = imginfo.height
}
- this.uploading[0] = true
+ this.avatarUploading = true
this.$store.state.api.backendInteractor.updateAvatar({params: {img, cropX, cropY, cropW, cropH}}).then((user) => {
if (!user.error) {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
- this.previews[0] = null
+ this.avatarPreview = null
+ } else {
+ this.avatarUploadError = this.$t('upload.error.base') + user.error
}
- this.uploading[0] = false
+ this.avatarUploading = false
})
},
+ clearUploadError (slot) {
+ this[slot + 'UploadError'] = null
+ },
submitBanner () {
- if (!this.previews[1]) { return }
+ if (!this.bannerPreview) { return }
- let banner = this.previews[1]
+ let banner = this.bannerPreview
// eslint-disable-next-line no-undef
let imginfo = new Image()
/* eslint-disable camelcase */
@@ -120,22 +154,24 @@ const UserSettings = {
height = imginfo.height
offset_top = 0
offset_left = 0
- this.uploading[1] = true
+ this.bannerUploading = true
this.$store.state.api.backendInteractor.updateBanner({params: {banner, offset_top, offset_left, width, height}}).then((data) => {
if (!data.error) {
let clone = JSON.parse(JSON.stringify(this.$store.state.users.currentUser))
clone.cover_photo = data.url
this.$store.commit('addNewUsers', [clone])
this.$store.commit('setCurrentUser', clone)
- this.previews[1] = null
+ this.bannerPreview = null
+ } else {
+ this.bannerUploadError = this.$t('upload.error.base') + data.error
}
- this.uploading[1] = false
+ this.bannerUploading = false
})
/* eslint-enable camelcase */
},
submitBg () {
- if (!this.previews[2]) { return }
- let img = this.previews[2]
+ if (!this.backgroundPreview) { return }
+ let img = this.backgroundPreview
// eslint-disable-next-line no-undef
let imginfo = new Image()
let cropX, cropY, cropW, cropH
@@ -144,20 +180,22 @@ const UserSettings = {
cropY = 0
cropW = imginfo.width
cropH = imginfo.width
- this.uploading[2] = true
+ this.backgroundUploading = true
this.$store.state.api.backendInteractor.updateBg({params: {img, cropX, cropY, cropW, cropH}}).then((data) => {
if (!data.error) {
let clone = JSON.parse(JSON.stringify(this.$store.state.users.currentUser))
clone.background_image = data.url
this.$store.commit('addNewUsers', [clone])
this.$store.commit('setCurrentUser', clone)
- this.previews[2] = null
+ this.backgroundPreview = null
+ } else {
+ this.backgroundUploadError = this.$t('upload.error.base') + data.error
}
- this.uploading[2] = false
+ this.backgroundUploading = false
})
},
importFollows () {
- this.uploading[3] = true
+ this.followListUploading = true
const followList = this.followList
this.$store.state.api.backendInteractor.followImport({params: followList})
.then((status) => {
@@ -166,7 +204,7 @@ const UserSettings = {
} else {
this.followImportError = true
}
- this.uploading[3] = false
+ this.followListUploading = false
})
},
/* This function takes an Array of Users
@@ -198,6 +236,7 @@ const UserSettings = {
.fetchFriends({id: this.$store.state.users.currentUser.id})
.then((friendList) => {
this.exportPeople(friendList, 'friends.csv')
+ setTimeout(() => { this.enableFollowsExport = true }, 2000)
})
},
followListChange () {
diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue
index 234a7d86..67b65b57 100644
--- a/src/components/user_settings/user_settings.vue
+++ b/src/components/user_settings/user_settings.vue
@@ -9,11 +9,11 @@
<div class="setting-item" >
<h2>{{$t('settings.name_bio')}}</h2>
<p>{{$t('settings.name')}}</p>
- <input class='name-changer' id='username' v-model="newname"></input>
+ <input class='name-changer' id='username' v-model="newName"></input>
<p>{{$t('settings.bio')}}</p>
- <textarea class="bio" v-model="newbio"></textarea>
+ <textarea class="bio" v-model="newBio"></textarea>
<p>
- <input type="checkbox" v-model="newlocked" id="account-locked">
+ <input type="checkbox" v-model="newLocked" id="account-locked">
<label for="account-locked">{{$t('settings.lock_account_description')}}</label>
</p>
<div v-if="scopeOptionsEnabled">
@@ -26,47 +26,63 @@
</div>
</div>
<p>
- <input type="checkbox" v-model="newnorichtext" id="account-no-rich-text">
+ <input type="checkbox" v-model="newNoRichText" id="account-no-rich-text">
<label for="account-no-rich-text">{{$t('settings.no_rich_text_description')}}</label>
</p>
- <button :disabled='newname.length <= 0' class="btn btn-default" @click="updateProfile">{{$t('general.submit')}}</button>
+ <p>
+ <input type="checkbox" v-model="newHideNetwork" id="account-hide-network">
+ <label for="account-no-rich-text">{{$t('settings.hide_network_description')}}</label>
+ </p>
+ <button :disabled='newName.length <= 0' class="btn btn-default" @click="updateProfile">{{$t('general.submit')}}</button>
</div>
<div class="setting-item">
<h2>{{$t('settings.avatar')}}</h2>
<p>{{$t('settings.current_avatar')}}</p>
<img :src="user.profile_image_url_original" class="old-avatar"></img>
<p>{{$t('settings.set_new_avatar')}}</p>
- <img class="new-avatar" v-bind:src="previews[0]" v-if="previews[0]">
+ <img class="new-avatar" v-bind:src="avatarPreview" v-if="avatarPreview">
</img>
<div>
- <input type="file" @change="uploadFile(0, $event)" ></input>
+ <input type="file" @change="uploadFile('avatar', $event)" ></input>
+ </div>
+ <i class="icon-spin4 animate-spin" v-if="avatarUploading"></i>
+ <button class="btn btn-default" v-else-if="avatarPreview" @click="submitAvatar">{{$t('general.submit')}}</button>
+ <div class='alert error' v-if="avatarUploadError">
+ Error: {{ avatarUploadError }}
+ <i class="icon-cancel" @click="clearUploadError('avatar')"></i>
</div>
- <i class="icon-spin4 animate-spin" v-if="uploading[0]"></i>
- <button class="btn btn-default" v-else-if="previews[0]" @click="submitAvatar">{{$t('general.submit')}}</button>
</div>
<div class="setting-item">
<h2>{{$t('settings.profile_banner')}}</h2>
<p>{{$t('settings.current_profile_banner')}}</p>
<img :src="user.cover_photo" class="banner"></img>
<p>{{$t('settings.set_new_profile_banner')}}</p>
- <img class="banner" v-bind:src="previews[1]" v-if="previews[1]">
+ <img class="banner" v-bind:src="bannerPreview" v-if="bannerPreview">
</img>
<div>
- <input type="file" @change="uploadFile(1, $event)" ></input>
+ <input type="file" @change="uploadFile('banner', $event)" ></input>
+ </div>
+ <i class=" icon-spin4 animate-spin uploading" v-if="bannerUploading"></i>
+ <button class="btn btn-default" v-else-if="bannerPreview" @click="submitBanner">{{$t('general.submit')}}</button>
+ <div class='alert error' v-if="bannerUploadError">
+ Error: {{ bannerUploadError }}
+ <i class="icon-cancel" @click="clearUploadError('banner')"></i>
</div>
- <i class=" icon-spin4 animate-spin uploading" v-if="uploading[1]"></i>
- <button class="btn btn-default" v-else-if="previews[1]" @click="submitBanner">{{$t('general.submit')}}</button>
</div>
<div class="setting-item">
<h2>{{$t('settings.profile_background')}}</h2>
<p>{{$t('settings.set_new_profile_background')}}</p>
- <img class="bg" v-bind:src="previews[2]" v-if="previews[2]">
+ <img class="bg" v-bind:src="backgroundPreview" v-if="backgroundPreview">
</img>
<div>
- <input type="file" @change="uploadFile(2, $event)" ></input>
+ <input type="file" @change="uploadFile('background', $event)" ></input>
+ </div>
+ <i class=" icon-spin4 animate-spin uploading" v-if="backgroundUploading"></i>
+ <button class="btn btn-default" v-else-if="backgroundPreview" @click="submitBg">{{$t('general.submit')}}</button>
+ <div class='alert error' v-if="backgroundUploadError">
+ Error: {{ backgroundUploadError }}
+ <i class="icon-cancel" @click="clearUploadError('background')"></i>
</div>
- <i class=" icon-spin4 animate-spin uploading" v-if="uploading[2]"></i>
- <button class="btn btn-default" v-else-if="previews[2]" @click="submitBg">{{$t('general.submit')}}</button>
</div>
</div>
@@ -113,7 +129,7 @@
<form v-model="followImportForm">
<input type="file" ref="followlist" v-on:change="followListChange"></input>
</form>
- <i class=" icon-spin4 animate-spin uploading" v-if="uploading[3]"></i>
+ <i class=" icon-spin4 animate-spin uploading" v-if="followListUploading"></i>
<button class="btn btn-default" v-else @click="importFollows">{{$t('general.submit')}}</button>
<div v-if="followsImported">
<i class="icon-cross" @click="dismissImported"></i>
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 893db931..1ce53796 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -29,6 +29,7 @@
"username": "Username"
},
"nav": {
+ "back": "Back",
"chat": "Local Chat",
"friend_requests": "Follow Requests",
"mentions": "Mentions",
@@ -72,7 +73,15 @@
"fullname": "Display name",
"password_confirm": "Password confirmation",
"registration": "Registration",
- "token": "Invite token"
+ "token": "Invite token",
+ "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"
+ }
},
"settings": {
"attachmentRadius": "Attachments",
@@ -116,13 +125,17 @@
"general": "General",
"hide_attachments_in_convo": "Hide attachments in conversations",
"hide_attachments_in_tl": "Hide attachments in timeline",
+ "hide_isp": "Hide instance-specific panel",
+ "preload_images": "Preload images",
"hide_post_stats": "Hide post statistics (e.g. the number of favorites)",
"hide_user_stats": "Hide user statistics (e.g. the number of followers)",
"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)",
+ "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",
@@ -139,6 +152,7 @@
"notification_visibility_mentions": "Mentions",
"notification_visibility_repeats": "Repeats",
"no_rich_text_description": "Strip rich text formatting from all posts",
+ "hide_network_description": "Don't show who I'm following and who's following me",
"nsfw_clickthrough": "Enable clickthrough NSFW attachment hiding",
"panelRadius": "Panels",
"pause_on_unfocused": "Pause streaming when tab is not focused",
@@ -170,11 +184,124 @@
"text": "Text",
"theme": "Theme",
"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",
"user_settings": "User Settings",
"values": {
"false": "no",
"true": "yes"
+ },
+ "notifications": "Notifications",
+ "enable_web_push_notifications": "Enable web push notifications",
+ "style": {
+ "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"
+ },
+ "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",
+ "badge": "Badge background",
+ "badge_notification": "Notification",
+ "panel_header": "Panel header",
+ "top_bar": "Top bar",
+ "borders": "Borders",
+ "buttons": "Buttons",
+ "inputs": "Input fields",
+ "faint_text": "Faded text"
+ },
+ "radii": {
+ "_tab_label": "Roundness"
+ },
+ "shadows": {
+ "_tab_label": "Shadow and lighting",
+ "component": "Component",
+ "override": "Override",
+ "shadow_id": "Shadow #{value}",
+ "blur": "Blur",
+ "spread": "Spread",
+ "inset": "Inset",
+ "hint": "For shadows you can also use --variable as a color value to use CSS3 variables. Please note that setting opacity won't work in this case.",
+ "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": "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",
+ "postCode": "Monospaced text in a post (rich text)"
+ },
+ "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"
+ }
}
},
"timeline": {
@@ -197,6 +324,7 @@
"followers": "Followers",
"following": "Following!",
"follows_you": "Follows you!",
+ "its_you": "It's you!",
"mute": "Mute",
"muted": "Muted",
"per_day": "per day",
@@ -214,6 +342,21 @@
"media_upload": "Upload Media",
"repeat": "Repeat",
"reply": "Reply",
- "favorite": "Favorite"
+ "favorite": "Favorite",
+ "user_settings": "User Settings"
+ },
+ "upload":{
+ "error": {
+ "base": "Upload failed.",
+ "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"
+ }
}
}
diff --git a/src/i18n/ru.json b/src/i18n/ru.json
index 9c28ccf4..80598b0c 100644
--- a/src/i18n/ru.json
+++ b/src/i18n/ru.json
@@ -19,6 +19,7 @@
"username": "Имя пользователя"
},
"nav": {
+ "back": "Назад",
"chat": "Локальный чат",
"mentions": "Упоминания",
"public_tl": "Публичная лента",
@@ -55,7 +56,15 @@
"fullname": "Отображаемое имя",
"password_confirm": "Подтверждение пароля",
"registration": "Регистрация",
- "token": "Код приглашения"
+ "token": "Код приглашения",
+ "validations": {
+ "username_required": "не должно быть пустым",
+ "fullname_required": "не должно быть пустым",
+ "email_required": "не должен быть пустым",
+ "password_required": "не должен быть пустым",
+ "password_confirmation_required": "не должно быть пустым",
+ "password_confirmation_match": "должно совпадать с паролем"
+ }
},
"settings": {
"attachmentRadius": "Прикреплённые файлы",
@@ -97,9 +106,12 @@
"general": "Общие",
"hide_attachments_in_convo": "Прятать вложения в разговорах",
"hide_attachments_in_tl": "Прятать вложения в ленте",
+ "hide_isp": "Скрыть серверную панель",
"import_followers_from_a_csv_file": "Импортировать читаемых из файла .csv",
"import_theme": "Загрузить Тему",
"inputRadius": "Поля ввода",
+ "checkboxRadius": "Чекбоксы",
+ "interface": "Интерфейс",
"interfaceLanguage": "Язык интерфейса",
"limited_availability": "Не доступно в вашем браузере",
"links": "Ссылки",
@@ -115,6 +127,7 @@
"notification_visibility_mentions": "Упоминания",
"notification_visibility_repeats": "Повторы",
"no_rich_text_description": "Убрать форматирование из всех постов",
+ "hide_network_description": "Не показывать кого я читаю и кто меня читает",
"nsfw_clickthrough": "Включить скрытие NSFW вложений",
"panelRadius": "Панели",
"pause_on_unfocused": "Приостановить загрузку когда вкладка не в фокусе",
@@ -139,8 +152,119 @@
"text": "Текст",
"theme": "Тема",
"theme_help": "Используйте шестнадцатеричные коды цветов (#rrggbb) для настройки темы.",
+ "theme_help_v2_1": "Вы так же можете перепоределить цвета определенных компонентов нажав соотв. галочку. Используйте кнопку \"Очистить всё\" чтобы снять все переопределения",
+ "theme_help_v2_2": "Под некоторыми полями ввода это идикаторы контрастности, наведите на них мышью чтобы узнать больше. Приспользовании прозрачности контраст расчитывается для наихудшего варианта.",
"tooltipRadius": "Всплывающие подсказки/уведомления",
- "user_settings": "Настройки пользователя"
+ "user_settings": "Настройки пользователя",
+ "style": {
+ "switcher": {
+ "keep_color": "Оставить цвета",
+ "keep_shadows": "Оставить тени",
+ "keep_opacity": "Оставить прозрачность",
+ "keep_roundness": "Оставить скругление",
+ "keep_fonts": "Оставить шрифты",
+ "save_load_hint": "Опции \"оставить...\" позволяют сохранить текущие настройки при выборе другой темы или импорта её из файла. Так же они влияют на то какие компоненты будут сохранены при экспорте темы. Когда все галочки сняты все компоненты будут экспортированы.",
+ "reset": "Сбросить",
+ "clear_all": "Очистить всё",
+ "clear_opacity": "Очистить прозрачность"
+ },
+ "common": {
+ "color": "Цвет",
+ "opacity": "Прозрачность",
+ "contrast": {
+ "hint": "Уровень контраста: {ratio}, что {level} {context}",
+ "level": {
+ "aa": "соответствует гайдлайну Level AA (минимальный)",
+ "aaa": "соответствует гайдлайну Level AAA (рекомендуемый)",
+ "bad": "не соответствует каким либо гайдлайнам"
+ },
+ "context": {
+ "18pt": "для крупного (18pt+) текста",
+ "text": "для текста"
+ }
+ }
+ },
+ "common_colors": {
+ "_tab_label": "Общие",
+ "main": "Общие цвета",
+ "foreground_hint": "См. вкладку \"Дополнительно\" для более детального контроля",
+ "rgbo": "Иконки, акценты, ярылки"
+ },
+ "advanced_colors": {
+ "_tab_label": "Дополнительно",
+ "alert": "Фон уведомлений",
+ "alert_error": "Ошибки",
+ "badge": "Фон значков",
+ "badge_notification": "Уведомления",
+ "panel_header": "Заголовок панели",
+ "top_bar": "Верняя полоска",
+ "borders": "Границы",
+ "buttons": "Кнопки",
+ "inputs": "Поля ввода",
+ "faint_text": "Маловажный текст"
+ },
+ "radii": {
+ "_tab_label": "Скругление"
+ },
+ "shadows": {
+ "_tab_label": "Светотень",
+ "component": "Компонент",
+ "override": "Переопределить",
+ "shadow_id": "Тень №{value}",
+ "blur": "Размытие",
+ "spread": "Разброс",
+ "inset": "Внутренняя",
+ "hint": "Для теней вы так же можете использовать --variable в качестве цвета чтобы использовать CSS3-переменные. В таком случае прозрачность работать не будет.",
+ "filter_hint": {
+ "always_drop_shadow": "Внимание, эта тень всегда использует {0} когда браузер поддерживает это",
+ "drop_shadow_syntax": "{0} не поддерживает параметр {1} и ключевое слово {2}",
+ "avatar_inset": "Одновременное использование внутренних и внешних теней на (прозрачных) аватарках может дать не те результаты что вы ожидаете",
+ "spread_zero": "Тени с разбросом > 0 будут выглядеть как если бы разброс установлен в 0",
+ "inset_classic": "Внутренние тени будут использовать {0}"
+ },
+ "components": {
+ "panel": "Панель",
+ "panelHeader": "Заголовок панели",
+ "topBar": "Верхняя полоска",
+ "avatar": "Аватарка (профиль)",
+ "avatarStatus": "Аватарка (в ленте)",
+ "popup": "Всплывающие подсказки",
+ "button": "Кнопки",
+ "buttonHover": "Кнопки (наведен курсор)",
+ "buttonPressed": "Кнопки (нажата)",
+ "buttonPressedHover": "Кнопки (нажата+наведен курсор)",
+ "input": "Поля ввода"
+ }
+ },
+ "fonts": {
+ "_tab_label": "Шрифты",
+ "help": "Выберите тип шрифта для использования в интерфейсе. При выборе варианта \"другой\" надо ввести название шрифта в точности как он называется в системе.",
+ "components": {
+ "interface": "Интерфейс",
+ "input": "Поля ввода",
+ "post": "Текст постов",
+ "postCode": "Моноширинный текст в посте (форматирование)"
+ },
+ "family": "Шрифт",
+ "size": "Размер (в пикселях)",
+ "weight": "Ширина",
+ "custom": "Другой"
+ },
+ "preview": {
+ "header": "Пример",
+ "content": "Контент",
+ "error": "Ошибка стоп 000",
+ "button": "Кнопка",
+ "text": "Еще немного {0} и масенькая {1}",
+ "mono": "контента",
+ "input": "Что нового?",
+ "faint_link": "Его придется убрать",
+ "fine_print": "Если проблемы остались — ваш гуртовщик мыши плохо стоит. {0}.",
+ "header_faint": "Все идет по плану",
+ "checkbox": "Я подтверждаю что не было ни единого разрыва",
+ "link": "ссылка"
+ }
+ }
},
"timeline": {
"collapse": "Свернуть",
diff --git a/src/lib/persisted_state.js b/src/lib/persisted_state.js
index 32fc93c6..ccd92633 100644
--- a/src/lib/persisted_state.js
+++ b/src/lib/persisted_state.js
@@ -75,6 +75,7 @@ export default function createPersistedState ({
loaded = true
} catch (e) {
console.log("Couldn't load state")
+ console.error(e)
loaded = true
}
subscriber(store)((mutation, state) => {
diff --git a/src/main.js b/src/main.js
index 378fe95c..bf92e78e 100644
--- a/src/main.js
+++ b/src/main.js
@@ -50,6 +50,32 @@ const persistedStateOptions = {
'oauth'
]
}
+
+const registerPushNotifications = store => {
+ store.subscribe((mutation, state) => {
+ const vapidPublicKey = state.instance.vapidPublicKey
+ const permission = state.interface.notificationPermission === 'granted'
+ const isUserMutation = mutation.type === 'setCurrentUser'
+
+ if (isUserMutation && vapidPublicKey && permission) {
+ return store.dispatch('registerPushNotifications')
+ }
+
+ const user = state.users.currentUser
+ const isVapidMutation = mutation.type === 'setInstanceOption' && mutation.payload.name === 'vapidPublicKey'
+
+ if (isVapidMutation && user && permission) {
+ return store.dispatch('registerPushNotifications')
+ }
+
+ const isPermMutation = mutation.type === 'setNotificationPermission' && mutation.payload === 'granted'
+
+ if (isPermMutation && user && vapidPublicKey) {
+ return store.dispatch('registerPushNotifications')
+ }
+ })
+}
+
createPersistedState(persistedStateOptions).then((persistedState) => {
const store = new Vuex.Store({
modules: {
@@ -62,10 +88,16 @@ createPersistedState(persistedStateOptions).then((persistedState) => {
chat: chatModule,
oauth: oauthModule
},
- plugins: [persistedState],
+ plugins: [persistedState, registerPushNotifications],
strict: false // Socket modifies itself, let's ignore this for now.
// strict: process.env.NODE_ENV !== 'production'
})
- afterStoreSetup({store, i18n})
+ afterStoreSetup({ store, i18n })
})
+
+// These are inlined by webpack's DefinePlugin
+/* eslint-disable */
+window.___pleromafe_mode = process.env
+window.___pleromafe_commit_hash = COMMIT_HASH
+window.___pleromafe_dev_overrides = DEV_OVERRIDES
diff --git a/src/modules/config.js b/src/modules/config.js
index f23cacb7..ccfd0190 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -1,5 +1,5 @@
import { set, delete as del } from 'vue'
-import StyleSetter from '../services/style_setter/style_setter.js'
+import { setPreset, applyTheme } from '../services/style_setter/style_setter.js'
const browserLocale = (window.navigator.language || 'en').split('-')[0]
@@ -9,6 +9,7 @@ const defaultState = {
hideAttachments: false,
hideAttachmentsInConv: false,
hideNsfw: true,
+ preloadImage: true,
loopVideo: true,
loopVideoSilentOnly: true,
autoLoad: true,
@@ -23,6 +24,7 @@ const defaultState = {
likes: true,
repeats: true
},
+ webPushNotifications: true,
muteWords: [],
highlight: {},
interfaceLanguage: browserLocale,
@@ -54,10 +56,10 @@ const config = {
commit('setOption', {name, value})
switch (name) {
case 'theme':
- StyleSetter.setPreset(value, commit)
+ setPreset(value, commit)
break
case 'customTheme':
- StyleSetter.setColors(value, commit)
+ applyTheme(value, commit)
}
}
}
diff --git a/src/modules/errors.js b/src/modules/errors.js
new file mode 100644
index 00000000..c809e1b5
--- /dev/null
+++ b/src/modules/errors.js
@@ -0,0 +1,12 @@
+import { capitalize } from 'lodash'
+
+export function humanizeErrors (errors) {
+ return Object.entries(errors).reduce((errs, [k, val]) => {
+ let message = val.reduce((acc, message) => {
+ let key = capitalize(k.replace(/_/g, ' '))
+ return acc + [key, message].join(' ') + '. '
+ }, '')
+ return [...errs, message]
+ }, [])
+}
+
diff --git a/src/modules/instance.js b/src/modules/instance.js
index 641424b6..ab88306f 100644
--- a/src/modules/instance.js
+++ b/src/modules/instance.js
@@ -1,5 +1,5 @@
import { set } from 'vue'
-import StyleSetter from '../services/style_setter/style_setter.js'
+import { setPreset } from '../services/style_setter/style_setter.js'
const defaultState = {
// Stuff from static/config.json and apiConfig
@@ -25,6 +25,8 @@ const defaultState = {
scopeCopy: true,
subjectLineBehavior: 'email',
loginMethod: 'password',
+ nsfwCensorImage: undefined,
+ vapidPublicKey: undefined,
// Nasty stuff
pleromaBackend: true,
@@ -60,7 +62,7 @@ const instance = {
dispatch('setPageTitle')
break
case 'theme':
- StyleSetter.setPreset(value, commit)
+ setPreset(value, commit)
}
}
}
diff --git a/src/modules/interface.js b/src/modules/interface.js
index 07489685..956c9cb3 100644
--- a/src/modules/interface.js
+++ b/src/modules/interface.js
@@ -3,7 +3,14 @@ import { set, delete as del } from 'vue'
const defaultState = {
settings: {
currentSaveStateNotice: null,
- noticeClearTimeout: null
+ noticeClearTimeout: null,
+ notificationPermission: null
+ },
+ browserSupport: {
+ cssFilter: window.CSS && window.CSS.supports && (
+ window.CSS.supports('filter', 'drop-shadow(0 0)') ||
+ window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)')
+ )
}
}
@@ -17,10 +24,13 @@ const interfaceMod = {
}
set(state.settings, 'currentSaveStateNotice', { error: false, data: success })
set(state.settings, 'noticeClearTimeout',
- setTimeout(() => del(state.settings, 'currentSaveStateNotice'), 2000))
+ setTimeout(() => del(state.settings, 'currentSaveStateNotice'), 2000))
} else {
set(state.settings, 'currentSaveStateNotice', { error: true, errorData: error })
}
+ },
+ setNotificationPermission (state, permission) {
+ state.notificationPermission = permission
}
},
actions: {
@@ -29,6 +39,9 @@ const interfaceMod = {
},
settingsSaved ({ commit, dispatch }, { success, error }) {
commit('settingsSaved', { success, error })
+ },
+ setNotificationPermission ({ commit }, permission) {
+ commit('setNotificationPermission', permission)
}
}
}
diff --git a/src/modules/users.js b/src/modules/users.js
index 8630ee0d..25d1c81f 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -1,6 +1,9 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { compact, map, each, merge } from 'lodash'
import { set } from 'vue'
+import registerPushNotifications from '../services/push/push.js'
+import oauthApi from '../services/new_api/oauth'
+import { humanizeErrors } from './errors'
// TODO: Unify with mergeOrAdd in statuses.js
export const mergeOrAdd = (arr, obj, item) => {
@@ -9,17 +12,28 @@ export const mergeOrAdd = (arr, obj, item) => {
if (oldItem) {
// We already have this, so only merge the new info.
merge(oldItem, item)
- return {item: oldItem, new: false}
+ return { item: oldItem, new: false }
} else {
// This is a new item, prepare it
arr.push(item)
obj[item.id] = item
- return {item, new: true}
+ if (item.screen_name && !item.screen_name.includes('@')) {
+ obj[item.screen_name] = item
+ }
+ return { item, new: true }
}
}
+const getNotificationPermission = () => {
+ const Notification = window.Notification
+
+ if (!Notification) return Promise.resolve(null)
+ if (Notification.permission === 'default') return Notification.requestPermission()
+ return Promise.resolve(Notification.permission)
+}
+
export const mutations = {
- setMuted (state, { user: {id}, muted }) {
+ setMuted (state, { user: { id }, muted }) {
const user = state.usersObject[id]
set(user, 'muted', muted)
},
@@ -43,18 +57,31 @@ export const mutations = {
setUserForStatus (state, status) {
status.user = state.usersObject[status.user.id]
},
- setColor (state, { user: {id}, highlighted }) {
+ setColor (state, { user: { id }, highlighted }) {
const user = state.usersObject[id]
set(user, 'highlight', highlighted)
+ },
+ signUpPending (state) {
+ state.signUpPending = true
+ state.signUpErrors = []
+ },
+ signUpSuccess (state) {
+ state.signUpPending = false
+ },
+ signUpFailure (state, errors) {
+ state.signUpPending = false
+ state.signUpErrors = errors
}
}
export const defaultState = {
+ loggingIn: false,
lastLoginName: false,
currentUser: false,
- loggingIn: false,
users: [],
- usersObject: {}
+ usersObject: {},
+ signUpPending: false,
+ signUpErrors: []
}
const users = {
@@ -62,8 +89,15 @@ const users = {
mutations,
actions: {
fetchUser (store, id) {
- store.rootState.api.backendInteractor.fetchUser({id})
- .then((user) => store.commit('addNewUsers', user))
+ store.rootState.api.backendInteractor.fetchUser({ id })
+ .then((user) => store.commit('addNewUsers', [user]))
+ },
+ registerPushNotifications (store) {
+ const token = store.state.currentUser.credentials
+ const vapidPublicKey = store.rootState.instance.vapidPublicKey
+ const isEnabled = store.rootState.config.webPushNotifications
+
+ registerPushNotifications(isEnabled, vapidPublicKey, token)
},
addNewStatuses (store, { statuses }) {
const users = map(statuses, 'user')
@@ -80,6 +114,34 @@ const users = {
store.commit('setUserForStatus', status)
})
},
+ async signUp (store, userInfo) {
+ store.commit('signUpPending')
+
+ let rootState = store.rootState
+
+ let response = await rootState.api.backendInteractor.register(userInfo)
+ if (response.ok) {
+ const data = {
+ oauth: rootState.oauth,
+ instance: rootState.instance.server
+ }
+ let app = await oauthApi.getOrCreateApp(data)
+ let result = await oauthApi.getTokenWithCredentials({
+ app,
+ instance: data.instance,
+ username: userInfo.username,
+ password: userInfo.password
+ })
+ store.commit('signUpSuccess')
+ store.commit('setToken', result.access_token)
+ store.dispatch('loginUser', result.access_token)
+ } else {
+ let data = await response.json()
+ let errors = humanizeErrors(JSON.parse(data.error))
+ store.commit('signUpFailure', errors)
+ throw Error(errors)
+ }
+ },
logout (store) {
store.commit('clearCurrentUser')
store.commit('setToken', false)
@@ -100,6 +162,9 @@ const users = {
commit('setCurrentUser', user)
commit('addNewUsers', [user])
+ getNotificationPermission()
+ .then(permission => commit('setNotificationPermission', permission))
+
// Set our new backend interactor
commit('setBackendInteractor', backendInteractorService(accessToken))
@@ -118,12 +183,8 @@ const users = {
store.commit('addNewUsers', mutedUsers)
})
- if ('Notification' in window && window.Notification.permission === 'default') {
- window.Notification.requestPermission()
- }
-
// Fetch our friends
- store.rootState.api.backendInteractor.fetchFriends({id: user.id})
+ store.rootState.api.backendInteractor.fetchFriends({ id: user.id })
.then((friends) => commit('addNewUsers', friends))
})
} else {
diff --git a/src/services/color_convert/color_convert.js b/src/services/color_convert/color_convert.js
index 13dd8979..7576c518 100644
--- a/src/services/color_convert/color_convert.js
+++ b/src/services/color_convert/color_convert.js
@@ -1,6 +1,15 @@
import { map } from 'lodash'
const rgb2hex = (r, g, b) => {
+ if (r === null || typeof r === 'undefined') {
+ return undefined
+ }
+ if (r[0] === '#') {
+ return r
+ }
+ if (typeof r === 'object') {
+ ({ r, g, b } = r)
+ }
[r, g, b] = map([r, g, b], (val) => {
val = Math.ceil(val)
val = val < 0 ? 0 : val
@@ -10,6 +19,91 @@ const rgb2hex = (r, g, b) => {
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`
}
+/**
+ * Converts 8-bit RGB component into linear component
+ * https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
+ * https://www.w3.org/TR/2008/REC-WCAG20-20081211/relative-luminance.xml
+ * https://en.wikipedia.org/wiki/SRGB#The_reverse_transformation
+ *
+ * @param {Number} bit - color component [0..255]
+ * @returns {Number} linear component [0..1]
+ */
+const c2linear = (bit) => {
+ // W3C gives 0.03928 while wikipedia states 0.04045
+ // what those magical numbers mean - I don't know.
+ // something about gamma-correction, i suppose.
+ // Sticking with W3C example.
+ const c = bit / 255
+ if (c < 0.03928) {
+ return c / 12.92
+ } else {
+ return Math.pow((c + 0.055) / 1.055, 2.4)
+ }
+}
+
+/**
+ * Converts sRGB into linear RGB
+ * @param {Object} srgb - sRGB color
+ * @returns {Object} linear rgb color
+ */
+const srgbToLinear = (srgb) => {
+ return 'rgb'.split('').reduce((acc, c) => { acc[c] = c2linear(srgb[c]); return acc }, {})
+}
+
+/**
+ * Calculates relative luminance for given color
+ * https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
+ * https://www.w3.org/TR/2008/REC-WCAG20-20081211/relative-luminance.xml
+ *
+ * @param {Object} srgb - sRGB color
+ * @returns {Number} relative luminance
+ */
+const relativeLuminance = (srgb) => {
+ const {r, g, b} = srgbToLinear(srgb)
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
+}
+
+/**
+ * Generates color ratio between two colors. Order is unimporant
+ * https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
+ *
+ * @param {Object} a - sRGB color
+ * @param {Object} b - sRGB color
+ * @returns {Number} color ratio
+ */
+const getContrastRatio = (a, b) => {
+ const la = relativeLuminance(a)
+ const lb = relativeLuminance(b)
+ const [l1, l2] = la > lb ? [la, lb] : [lb, la]
+
+ return (l1 + 0.05) / (l2 + 0.05)
+}
+
+/**
+ * This performs alpha blending between solid background and semi-transparent foreground
+ *
+ * @param {Object} fg - top layer color
+ * @param {Number} fga - top layer's alpha
+ * @param {Object} bg - bottom layer color
+ * @returns {Object} sRGB of resulting color
+ */
+const alphaBlend = (fg, fga, bg) => {
+ if (fga === 1 || typeof fga === 'undefined') return fg
+ return 'rgb'.split('').reduce((acc, c) => {
+ // Simplified https://en.wikipedia.org/wiki/Alpha_compositing#Alpha_blending
+ // for opaque bg and transparent fg
+ acc[c] = (fg[c] * fga + bg[c] * (1 - fga))
+ return acc
+ }, {})
+}
+
+const invert = (rgb) => {
+ return 'rgb'.split('').reduce((acc, c) => {
+ acc[c] = 255 - rgb[c]
+ return acc
+ }, {})
+}
+
const hex2rgb = (hex) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result ? {
@@ -19,16 +113,18 @@ const hex2rgb = (hex) => {
} : null
}
-const rgbstr2hex = (rgb) => {
- if (rgb[0] === '#') {
- return rgb
- }
- rgb = rgb.match(/\d+/g)
- return `#${((Number(rgb[0]) << 16) + (Number(rgb[1]) << 8) + Number(rgb[2])).toString(16)}`
+const mixrgb = (a, b) => {
+ return Object.keys(a).reduce((acc, k) => {
+ acc[k] = (a[k] + b[k]) / 2
+ return acc
+ }, {})
}
export {
rgb2hex,
hex2rgb,
- rgbstr2hex
+ mixrgb,
+ invert,
+ getContrastRatio,
+ alphaBlend
}
diff --git a/src/services/file_size_format/file_size_format.js b/src/services/file_size_format/file_size_format.js
new file mode 100644
index 00000000..add56ee0
--- /dev/null
+++ b/src/services/file_size_format/file_size_format.js
@@ -0,0 +1,17 @@
+const fileSizeFormat = (num) => {
+ var exponent
+ var unit
+ var units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
+ if (num < 1) {
+ return num + ' ' + units[0]
+ }
+
+ exponent = Math.min(Math.floor(Math.log(num) / Math.log(1024)), units.length - 1)
+ num = (num / Math.pow(1024, exponent)).toFixed(2) * 1
+ unit = units[exponent]
+ return {num: num, unit: unit}
+}
+const fileSizeFormatService = {
+ fileSizeFormat
+}
+export default fileSizeFormatService
diff --git a/src/services/push/push.js b/src/services/push/push.js
new file mode 100644
index 00000000..1ac304d1
--- /dev/null
+++ b/src/services/push/push.js
@@ -0,0 +1,69 @@
+import runtime from 'serviceworker-webpack-plugin/lib/runtime'
+
+function urlBase64ToUint8Array (base64String) {
+ const padding = '='.repeat((4 - base64String.length % 4) % 4)
+ const base64 = (base64String + padding)
+ .replace(/-/g, '+')
+ .replace(/_/g, '/')
+
+ const rawData = window.atob(base64)
+ return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)))
+}
+
+function isPushSupported () {
+ return 'serviceWorker' in navigator && 'PushManager' in window
+}
+
+function registerServiceWorker () {
+ return runtime.register()
+ .catch((err) => console.error('Unable to register service worker.', err))
+}
+
+function subscribe (registration, isEnabled, vapidPublicKey) {
+ if (!isEnabled) return Promise.reject(new Error('Web Push is disabled in config'))
+ if (!vapidPublicKey) return Promise.reject(new Error('VAPID public key is not found'))
+
+ const subscribeOptions = {
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
+ }
+ return registration.pushManager.subscribe(subscribeOptions)
+}
+
+function sendSubscriptionToBackEnd (subscription, token) {
+ return window.fetch('/api/v1/push/subscription/', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`
+ },
+ body: JSON.stringify({
+ subscription,
+ data: {
+ alerts: {
+ follow: true,
+ favourite: true,
+ mention: true,
+ reblog: true
+ }
+ }
+ })
+ })
+ .then((response) => {
+ if (!response.ok) throw new Error('Bad status code from server.')
+ return response.json()
+ })
+ .then((responseData) => {
+ if (!responseData.id) throw new Error('Bad response from server.')
+ return responseData
+ })
+}
+
+export default function registerPushNotifications (isEnabled, vapidPublicKey, token) {
+ if (isPushSupported()) {
+ registerServiceWorker()
+ .then((registration) => subscribe(registration, isEnabled, vapidPublicKey))
+ .then((subscription) => sendSubscriptionToBackEnd(subscription, token))
+ .catch((e) => console.warn(`Failed to setup Web Push Notifications: ${e.message}`))
+ }
+}
diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js
index 493d444e..10e7ed9b 100644
--- a/src/services/style_setter/style_setter.js
+++ b/src/services/style_setter/style_setter.js
@@ -1,5 +1,6 @@
import { times } from 'lodash'
-import { rgb2hex, hex2rgb } from '../color_convert/color_convert.js'
+import { brightness, invertLightness, convert, contrastRatio } from 'chromatism'
+import { rgb2hex, hex2rgb, mixrgb, getContrastRatio, alphaBlend } from '../color_convert/color_convert.js'
// While this is not used anymore right now, I left it in if we want to do custom
// styles that aren't just colors, so user can pick from a few different distinct
@@ -39,8 +40,6 @@ const setStyle = (href, commit) => {
colors[name] = color
})
- commit('setOption', { name: 'colors', value: colors })
-
body.removeChild(baseEl)
const styleEl = document.createElement('style')
@@ -53,7 +52,27 @@ const setStyle = (href, commit) => {
cssEl.addEventListener('load', setDynamic)
}
-const setColors = (col, commit) => {
+const rgb2rgba = function (rgba) {
+ return `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${rgba.a})`
+}
+
+const getTextColor = function (bg, text, preserve) {
+ const bgIsLight = convert(bg).hsl.l > 50
+ const textIsLight = convert(text).hsl.l > 50
+
+ if ((bgIsLight && textIsLight) || (!bgIsLight && !textIsLight)) {
+ const base = typeof text.a !== 'undefined' ? { a: text.a } : {}
+ const result = Object.assign(base, invertLightness(text).rgb)
+ if (!preserve && getContrastRatio(bg, result) < 4.5) {
+ return contrastRatio(bg, text).rgb
+ }
+ return result
+ }
+ return text
+}
+
+const applyTheme = (input, commit) => {
+ const { rules, theme } = generatePreset(input)
const head = document.head
const body = document.body
body.style.display = 'none'
@@ -62,56 +81,411 @@ const setColors = (col, commit) => {
head.appendChild(styleEl)
const styleSheet = styleEl.sheet
- const isDark = (col.text.r + col.text.g + col.text.b) > (col.bg.r + col.bg.g + col.bg.b)
- let colors = {}
- let radii = {}
+ styleSheet.toString()
+ styleSheet.insertRule(`body { ${rules.radii} }`, 'index-max')
+ styleSheet.insertRule(`body { ${rules.colors} }`, 'index-max')
+ styleSheet.insertRule(`body { ${rules.shadows} }`, 'index-max')
+ styleSheet.insertRule(`body { ${rules.fonts} }`, 'index-max')
+ body.style.display = 'initial'
- const mod = isDark ? -10 : 10
+ // commit('setOption', { name: 'colors', value: htmlColors })
+ // commit('setOption', { name: 'radii', value: radii })
+ commit('setOption', { name: 'customTheme', value: input })
+ commit('setOption', { name: 'colors', value: theme.colors })
+}
- colors.bg = rgb2hex(col.bg.r, col.bg.g, col.bg.b) // background
- colors.lightBg = rgb2hex((col.bg.r + col.fg.r) / 2, (col.bg.g + col.fg.g) / 2, (col.bg.b + col.fg.b) / 2) // hilighted bg
- colors.btn = rgb2hex(col.fg.r, col.fg.g, col.fg.b) // panels & buttons
- colors.input = `rgba(${col.fg.r}, ${col.fg.g}, ${col.fg.b}, .5)`
- colors.border = rgb2hex(col.fg.r - mod, col.fg.g - mod, col.fg.b - mod) // borders
- colors.faint = `rgba(${col.text.r}, ${col.text.g}, ${col.text.b}, .5)`
- colors.fg = rgb2hex(col.text.r, col.text.g, col.text.b) // text
- colors.lightFg = rgb2hex(col.text.r - mod * 5, col.text.g - mod * 5, col.text.b - mod * 5) // strong text
+const getCssShadow = (input, usesDropShadow) => {
+ if (input.length === 0) {
+ return 'none'
+ }
- colors['base07'] = rgb2hex(col.text.r - mod * 2, col.text.g - mod * 2, col.text.b - mod * 2)
+ return input
+ .filter(_ => usesDropShadow ? _.inset : _)
+ .map((shad) => [
+ shad.x,
+ shad.y,
+ shad.blur,
+ shad.spread
+ ].map(_ => _ + 'px').concat([
+ getCssColor(shad.color, shad.alpha),
+ shad.inset ? 'inset' : ''
+ ]).join(' ')).join(', ')
+}
- colors.link = rgb2hex(col.link.r, col.link.g, col.link.b) // links
- colors.icon = rgb2hex((col.bg.r + col.text.r) / 2, (col.bg.g + col.text.g) / 2, (col.bg.b + col.text.b) / 2) // icons
+const getCssShadowFilter = (input) => {
+ if (input.length === 0) {
+ return 'none'
+ }
- colors.cBlue = col.cBlue && rgb2hex(col.cBlue.r, col.cBlue.g, col.cBlue.b)
- colors.cRed = col.cRed && rgb2hex(col.cRed.r, col.cRed.g, col.cRed.b)
- colors.cGreen = col.cGreen && rgb2hex(col.cGreen.r, col.cGreen.g, col.cGreen.b)
- colors.cOrange = col.cOrange && rgb2hex(col.cOrange.r, col.cOrange.g, col.cOrange.b)
+ return input
+ // drop-shadow doesn't support inset or spread
+ .filter((shad) => !shad.inset && Number(shad.spread) === 0)
+ .map((shad) => [
+ shad.x,
+ shad.y,
+ // drop-shadow's blur is twice as strong compared to box-shadow
+ shad.blur / 2
+ ].map(_ => _ + 'px').concat([
+ getCssColor(shad.color, shad.alpha)
+ ]).join(' '))
+ .map(_ => `drop-shadow(${_})`)
+ .join(' ')
+}
- colors.cAlertRed = col.cRed && `rgba(${col.cRed.r}, ${col.cRed.g}, ${col.cRed.b}, .5)`
+const getCssColor = (input, a) => {
+ let rgb = {}
+ if (typeof input === 'object') {
+ rgb = input
+ } else if (typeof input === 'string') {
+ if (input.startsWith('#')) {
+ rgb = hex2rgb(input)
+ } else if (input.startsWith('--')) {
+ return `var(${input})`
+ } else {
+ return input
+ }
+ }
+ return rgb2rgba({ ...rgb, a })
+}
- radii.btnRadius = col.btnRadius
- radii.inputRadius = col.inputRadius
- radii.panelRadius = col.panelRadius
- radii.avatarRadius = col.avatarRadius
- radii.avatarAltRadius = col.avatarAltRadius
- radii.tooltipRadius = col.tooltipRadius
- radii.attachmentRadius = col.attachmentRadius
+const generateColors = (input) => {
+ const colors = {}
+ const opacity = Object.assign({
+ alert: 0.5,
+ input: 0.5,
+ faint: 0.5
+ }, Object.entries(input.opacity || {}).reduce((acc, [k, v]) => {
+ if (typeof v !== 'undefined') {
+ acc[k] = v
+ }
+ return acc
+ }, {}))
+ const col = Object.entries(input.colors || input).reduce((acc, [k, v]) => {
+ if (typeof v === 'object') {
+ acc[k] = v
+ } else {
+ acc[k] = hex2rgb(v)
+ }
+ return acc
+ }, {})
- styleSheet.toString()
- styleSheet.insertRule(`body { ${Object.entries(colors).filter(([k, v]) => v).map(([k, v]) => `--${k}: ${v}`).join(';')} }`, 'index-max')
- styleSheet.insertRule(`body { ${Object.entries(radii).filter(([k, v]) => v).map(([k, v]) => `--${k}: ${v}px`).join(';')} }`, 'index-max')
- body.style.display = 'initial'
+ const isLightOnDark = convert(col.bg).hsl.l < convert(col.text).hsl.l
+ const mod = isLightOnDark ? 1 : -1
+
+ colors.text = col.text
+ colors.lightText = brightness(20 * mod, colors.text).rgb
+ colors.link = col.link
+ colors.faint = col.faint || Object.assign({}, col.text)
+
+ colors.bg = col.bg
+ colors.lightBg = col.lightBg || brightness(5, colors.bg).rgb
+
+ colors.fg = col.fg
+ colors.fgText = col.fgText || getTextColor(colors.fg, colors.text)
+ colors.fgLink = col.fgLink || getTextColor(colors.fg, colors.link, true)
+
+ colors.border = col.border || brightness(2 * mod, colors.fg).rgb
+
+ colors.btn = col.btn || Object.assign({}, col.fg)
+ colors.btnText = col.btnText || getTextColor(colors.btn, colors.fgText)
+
+ colors.input = col.input || Object.assign({}, col.fg)
+ colors.inputText = col.inputText || getTextColor(colors.input, colors.lightText)
+
+ colors.panel = col.panel || Object.assign({}, col.fg)
+ colors.panelText = col.panelText || getTextColor(colors.panel, colors.fgText)
+ colors.panelLink = col.panelLink || getTextColor(colors.panel, colors.fgLink)
+ colors.panelFaint = col.panelFaint || getTextColor(colors.panel, colors.faint)
- commit('setOption', { name: 'colors', value: colors })
- commit('setOption', { name: 'radii', value: radii })
- commit('setOption', { name: 'customTheme', value: col })
+ colors.topBar = col.topBar || Object.assign({}, col.fg)
+ colors.topBarText = col.topBarText || getTextColor(colors.topBar, colors.fgText)
+ colors.topBarLink = col.topBarLink || getTextColor(colors.topBar, colors.fgLink)
+
+ colors.faintLink = col.faintLink || Object.assign({}, col.link)
+
+ colors.icon = mixrgb(colors.bg, colors.text)
+
+ colors.cBlue = col.cBlue || hex2rgb('#0000FF')
+ colors.cRed = col.cRed || hex2rgb('#FF0000')
+ colors.cGreen = col.cGreen || hex2rgb('#00FF00')
+ colors.cOrange = col.cOrange || hex2rgb('#E3FF00')
+
+ colors.alertError = col.alertError || Object.assign({}, colors.cRed)
+ colors.alertErrorText = getTextColor(alphaBlend(colors.alertError, opacity.alert, colors.bg), colors.text)
+ colors.alertErrorPanelText = getTextColor(alphaBlend(colors.alertError, opacity.alert, colors.panel), colors.panelText)
+
+ colors.badgeNotification = col.badgeNotification || Object.assign({}, colors.cRed)
+ colors.badgeNotificationText = contrastRatio(colors.badgeNotification).rgb
+
+ Object.entries(opacity).forEach(([ k, v ]) => {
+ if (typeof v === 'undefined') return
+ if (k === 'alert') {
+ colors.alertError.a = v
+ return
+ }
+ if (k === 'faint') {
+ colors[k + 'Link'].a = v
+ colors['panelFaint'].a = v
+ }
+ if (k === 'bg') {
+ colors['lightBg'].a = v
+ }
+ if (colors[k]) {
+ colors[k].a = v
+ } else {
+ console.error('Wrong key ' + k)
+ }
+ })
+
+ const htmlColors = Object.entries(colors)
+ .reduce((acc, [k, v]) => {
+ if (!v) return acc
+ acc.solid[k] = rgb2hex(v)
+ acc.complete[k] = typeof v.a === 'undefined' ? rgb2hex(v) : rgb2rgba(v)
+ return acc
+ }, { complete: {}, solid: {} })
+ return {
+ rules: {
+ colors: Object.entries(htmlColors.complete)
+ .filter(([k, v]) => v)
+ .map(([k, v]) => `--${k}: ${v}`)
+ .join(';')
+ },
+ theme: {
+ colors: htmlColors.solid,
+ opacity
+ }
+ }
}
-const setPreset = (val, commit) => {
- window.fetch('/static/styles.json')
+const generateRadii = (input) => {
+ let inputRadii = input.radii || {}
+ // v1 -> v2
+ if (typeof input.btnRadius !== 'undefined') {
+ inputRadii = Object
+ .entries(input)
+ .filter(([k, v]) => k.endsWith('Radius'))
+ .reduce((acc, e) => { acc[e[0].split('Radius')[0]] = e[1]; return acc }, {})
+ }
+ const radii = Object.entries(inputRadii).filter(([k, v]) => v).reduce((acc, [k, v]) => {
+ acc[k] = v
+ return acc
+ }, {
+ btn: 4,
+ input: 4,
+ checkbox: 2,
+ panel: 10,
+ avatar: 5,
+ avatarAlt: 50,
+ tooltip: 2,
+ attachment: 5
+ })
+
+ return {
+ rules: {
+ radii: Object.entries(radii).filter(([k, v]) => v).map(([k, v]) => `--${k}Radius: ${v}px`).join(';')
+ },
+ theme: {
+ radii
+ }
+ }
+}
+
+const generateFonts = (input) => {
+ const fonts = Object.entries(input.fonts || {}).filter(([k, v]) => v).reduce((acc, [k, v]) => {
+ acc[k] = Object.entries(v).filter(([k, v]) => v).reduce((acc, [k, v]) => {
+ acc[k] = v
+ return acc
+ }, acc[k])
+ return acc
+ }, {
+ interface: {
+ family: 'sans-serif'
+ },
+ input: {
+ family: 'inherit'
+ },
+ post: {
+ family: 'inherit'
+ },
+ postCode: {
+ family: 'monospace'
+ }
+ })
+
+ return {
+ rules: {
+ fonts: Object
+ .entries(fonts)
+ .filter(([k, v]) => v)
+ .map(([k, v]) => `--${k}Font: ${v.family}`).join(';')
+ },
+ theme: {
+ fonts
+ }
+ }
+}
+
+const generateShadows = (input) => {
+ const border = (top, shadow) => ({
+ x: 0,
+ y: top ? 1 : -1,
+ blur: 0,
+ spread: 0,
+ color: shadow ? '#000000' : '#FFFFFF',
+ alpha: 0.2,
+ inset: true
+ })
+ const buttonInsetFakeBorders = [border(true, false), border(false, true)]
+ const inputInsetFakeBorders = [border(true, true), border(false, false)]
+ const hoverGlow = {
+ x: 0,
+ y: 0,
+ blur: 4,
+ spread: 0,
+ color: '--faint',
+ alpha: 1
+ }
+
+ const shadows = {
+ panel: [{
+ x: 1,
+ y: 1,
+ blur: 4,
+ spread: 0,
+ color: '#000000',
+ alpha: 0.6
+ }],
+ topBar: [{
+ x: 0,
+ y: 0,
+ blur: 4,
+ spread: 0,
+ color: '#000000',
+ alpha: 0.6
+ }],
+ popup: [{
+ x: 2,
+ y: 2,
+ blur: 3,
+ spread: 0,
+ color: '#000000',
+ alpha: 0.5
+ }],
+ avatar: [{
+ x: 0,
+ y: 1,
+ blur: 8,
+ spread: 0,
+ color: '#000000',
+ alpha: 0.7
+ }],
+ avatarStatus: [],
+ panelHeader: [],
+ button: [{
+ x: 0,
+ y: 0,
+ blur: 2,
+ spread: 0,
+ color: '#000000',
+ alpha: 1
+ }, ...buttonInsetFakeBorders],
+ buttonHover: [hoverGlow, ...buttonInsetFakeBorders],
+ buttonPressed: [hoverGlow, ...inputInsetFakeBorders],
+ input: [...inputInsetFakeBorders, {
+ x: 0,
+ y: 0,
+ blur: 2,
+ inset: true,
+ spread: 0,
+ color: '#000000',
+ alpha: 1
+ }],
+ ...(input.shadows || {})
+ }
+
+ return {
+ rules: {
+ shadows: Object
+ .entries(shadows)
+ // TODO for v2.1: if shadow doesn't have non-inset shadows with spread > 0 - optionally
+ // convert all non-inset shadows into filter: drop-shadow() to boost performance
+ .map(([k, v]) => [
+ `--${k}Shadow: ${getCssShadow(v)}`,
+ `--${k}ShadowFilter: ${getCssShadowFilter(v)}`,
+ `--${k}ShadowInset: ${getCssShadow(v, true)}`
+ ].join(';'))
+ .join(';')
+ },
+ theme: {
+ shadows
+ }
+ }
+}
+
+const composePreset = (colors, radii, shadows, fonts) => {
+ return {
+ rules: {
+ ...shadows.rules,
+ ...colors.rules,
+ ...radii.rules,
+ ...fonts.rules
+ },
+ theme: {
+ ...shadows.theme,
+ ...colors.theme,
+ ...radii.theme,
+ ...fonts.theme
+ }
+ }
+}
+
+const generatePreset = (input) => {
+ const shadows = generateShadows(input)
+ const colors = generateColors(input)
+ const radii = generateRadii(input)
+ const fonts = generateFonts(input)
+
+ return composePreset(colors, radii, shadows, fonts)
+}
+
+const getThemes = () => {
+ return window.fetch('/static/styles.json')
.then((data) => data.json())
.then((themes) => {
- const theme = themes[val] ? themes[val] : themes['pleroma-dark']
+ return Promise.all(Object.entries(themes).map(([k, v]) => {
+ if (typeof v === 'object') {
+ return Promise.resolve([k, v])
+ } else if (typeof v === 'string') {
+ return window.fetch(v)
+ .then((data) => data.json())
+ .then((theme) => {
+ return [k, theme]
+ })
+ .catch((e) => {
+ console.error(e)
+ return []
+ })
+ }
+ }))
+ })
+ .then((promises) => {
+ return promises
+ .filter(([k, v]) => v)
+ .reduce((acc, [k, v]) => {
+ acc[k] = v
+ return acc
+ }, {})
+ })
+}
+
+const setPreset = (val, commit) => {
+ getThemes().then((themes) => {
+ const theme = themes[val] ? themes[val] : themes['pleroma-dark']
+ const isV1 = Array.isArray(theme)
+ const data = isV1 ? {} : theme.theme
+
+ if (isV1) {
const bgRgb = hex2rgb(theme[1])
const fgRgb = hex2rgb(theme[2])
const textRgb = hex2rgb(theme[3])
@@ -122,7 +496,7 @@ const setPreset = (val, commit) => {
const cBlueRgb = hex2rgb(theme[7] || '#0000FF')
const cOrangeRgb = hex2rgb(theme[8] || '#E3FF00')
- const col = {
+ data.colors = {
bg: bgRgb,
fg: fgRgb,
text: textRgb,
@@ -132,23 +506,32 @@ const setPreset = (val, commit) => {
cGreen: cGreenRgb,
cOrange: cOrangeRgb
}
+ }
- // This is a hack, this function is only called during initial load.
- // We want to cancel loading the theme from config.json if we're already
- // loading a theme from the persisted state.
- // Needed some way of dealing with the async way of things.
- // load config -> set preset -> wait for styles.json to load ->
- // load persisted state -> set colors -> styles.json loaded -> set colors
- if (!window.themeLoaded) {
- setColors(col, commit)
- }
- })
+ // This is a hack, this function is only called during initial load.
+ // We want to cancel loading the theme from config.json if we're already
+ // loading a theme from the persisted state.
+ // Needed some way of dealing with the async way of things.
+ // load config -> set preset -> wait for styles.json to load ->
+ // load persisted state -> set colors -> styles.json loaded -> set colors
+ if (!window.themeLoaded) {
+ applyTheme(data, commit)
+ }
+ })
}
-const StyleSetter = {
+export {
setStyle,
setPreset,
- setColors
+ applyTheme,
+ getTextColor,
+ generateColors,
+ generateRadii,
+ generateShadows,
+ generateFonts,
+ generatePreset,
+ getThemes,
+ composePreset,
+ getCssShadow,
+ getCssShadowFilter
}
-
-export default StyleSetter
diff --git a/src/services/user_highlighter/user_highlighter.js b/src/services/user_highlighter/user_highlighter.js
index ebb25eca..f6ddfb9c 100644
--- a/src/services/user_highlighter/user_highlighter.js
+++ b/src/services/user_highlighter/user_highlighter.js
@@ -11,7 +11,7 @@ const highlightStyle = (prefs) => {
if (type === 'striped') {
return {
backgroundImage: [
- 'repeating-linear-gradient(-45deg,',
+ 'repeating-linear-gradient(135deg,',
`${tintColor} ,`,
`${tintColor} 20px,`,
`${tintColor2} 20px,`,
diff --git a/src/sw.js b/src/sw.js
new file mode 100644
index 00000000..6cecb3f3
--- /dev/null
+++ b/src/sw.js
@@ -0,0 +1,38 @@
+/* eslint-env serviceworker */
+
+import localForage from 'localforage'
+
+function isEnabled () {
+ return localForage.getItem('vuex-lz')
+ .then(data => data.config.webPushNotifications)
+}
+
+function getWindowClients () {
+ return clients.matchAll({ includeUncontrolled: true })
+ .then((clientList) => clientList.filter(({ type }) => type === 'window'))
+}
+
+self.addEventListener('push', (event) => {
+ if (event.data) {
+ event.waitUntil(isEnabled().then((isEnabled) => {
+ return isEnabled && getWindowClients().then((list) => {
+ const data = event.data.json()
+
+ if (list.length === 0) return self.registration.showNotification(data.title, data)
+ })
+ }))
+ }
+})
+
+self.addEventListener('notificationclick', (event) => {
+ event.notification.close()
+
+ event.waitUntil(getWindowClients().then((list) => {
+ for (var i = 0; i < list.length; i++) {
+ var client = list[i]
+ if (client.url === '/' && 'focus' in client) { return client.focus() }
+ }
+
+ if (clients.openWindow) return clients.openWindow('/')
+ }))
+})