aboutsummaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/attachment/attachment.js39
-rw-r--r--src/components/attachment/attachment.vue28
-rw-r--r--src/components/chat_panel/chat_panel.js2
-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/conversation/conversation.vue6
-rw-r--r--src/components/delete_button/delete_button.vue2
-rw-r--r--src/components/dm_timeline/dm_timeline.js14
-rw-r--r--src/components/dm_timeline/dm_timeline.vue5
-rw-r--r--src/components/export_import/export_import.vue87
-rw-r--r--src/components/favorite_button/favorite_button.js3
-rw-r--r--src/components/favorite_button/favorite_button.vue8
-rw-r--r--src/components/features_panel/features_panel.js14
-rw-r--r--src/components/features_panel/features_panel.vue29
-rw-r--r--src/components/follow_requests/follow_requests.js23
-rw-r--r--src/components/follow_requests/follow_requests.vue12
-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.js5
-rw-r--r--src/components/instance_specific_panel/instance_specific_panel.vue2
-rw-r--r--src/components/interface_language_switcher/interface_language_switcher.vue41
-rw-r--r--src/components/login_form/login_form.js36
-rw-r--r--src/components/login_form/login_form.vue17
-rw-r--r--src/components/media_upload/media_upload.js6
-rw-r--r--src/components/media_upload/media_upload.vue4
-rw-r--r--src/components/nav_panel/nav_panel.js1
-rw-r--r--src/components/nav_panel/nav_panel.vue20
-rw-r--r--src/components/notification/notification.js17
-rw-r--r--src/components/notification/notification.vue24
-rw-r--r--src/components/notifications/notifications.js41
-rw-r--r--src/components/notifications/notifications.scss99
-rw-r--r--src/components/notifications/notifications.vue18
-rw-r--r--src/components/oauth_callback/oauth_callback.js20
-rw-r--r--src/components/oauth_callback/oauth_callback.vue5
-rw-r--r--src/components/opacity_input/opacity_input.vue38
-rw-r--r--src/components/post_status_form/post_status_form.js99
-rw-r--r--src/components/post_status_form/post_status_form.vue124
-rw-r--r--src/components/range_input/range_input.vue48
-rw-r--r--src/components/registration/registration.js66
-rw-r--r--src/components/registration/registration.vue150
-rw-r--r--src/components/retweet_button/retweet_button.js8
-rw-r--r--src/components/retweet_button/retweet_button.vue15
-rw-r--r--src/components/settings/settings.js118
-rw-r--r--src/components/settings/settings.vue287
-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.js168
-rw-r--r--src/components/status/status.vue165
-rw-r--r--src/components/still-image/still-image.js6
-rw-r--r--src/components/still-image/still-image.vue2
-rw-r--r--src/components/style_switcher/preview.vue78
-rw-r--r--src/components/style_switcher/style_switcher.js620
-rw-r--r--src/components/style_switcher/style_switcher.scss335
-rw-r--r--src/components/style_switcher/style_switcher.vue479
-rw-r--r--src/components/tab_switcher/tab_switcher.jsx47
-rw-r--r--src/components/tab_switcher/tab_switcher.scss65
-rw-r--r--src/components/timeline/timeline.js26
-rw-r--r--src/components/timeline/timeline.vue59
-rw-r--r--src/components/user_card/user_card.js11
-rw-r--r--src/components/user_card/user_card.vue29
-rw-r--r--src/components/user_card_content/user_card_content.js63
-rw-r--r--src/components/user_card_content/user_card_content.vue322
-rw-r--r--src/components/user_finder/user_finder.js17
-rw-r--r--src/components/user_finder/user_finder.vue6
-rw-r--r--src/components/user_panel/user_panel.js1
-rw-r--r--src/components/user_panel/user_panel.vue10
-rw-r--r--src/components/user_profile/user_profile.js1
-rw-r--r--src/components/user_profile/user_profile.vue22
-rw-r--r--src/components/user_search/user_search.js33
-rw-r--r--src/components/user_search/user_search.vue12
-rw-r--r--src/components/user_settings/user_settings.js141
-rw-r--r--src/components/user_settings/user_settings.vue190
-rw-r--r--src/components/who_to_follow_panel/who_to_follow_panel.js111
-rw-r--r--src/components/who_to_follow_panel/who_to_follow_panel.vue37
75 files changed, 4309 insertions, 896 deletions
diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js
index ad46d0a1..97c4f283 100644
--- a/src/components/attachment/attachment.js
+++ b/src/components/attachment/attachment.js
@@ -13,9 +13,11 @@ const Attachment = {
return {
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,
- img: document.createElement('img')
+ img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img')
}
},
components: {
@@ -45,14 +47,35 @@ const Attachment = {
}
},
toggleHidden () {
- if (this.img.onload) {
- this.img.onload()
+ if (this.img && !this.preloadImage) {
+ if (this.img.onload) {
+ this.img.onload()
+ } else {
+ this.loading = true
+ this.img.src = this.attachment.url
+ this.img.onload = () => {
+ this.loading = false
+ this.showHidden = !this.showHidden
+ }
+ }
} else {
- this.loading = true
- this.img.src = this.attachment.url
- this.img.onload = () => {
- this.loading = false
- this.showHidden = !this.showHidden
+ this.showHidden = !this.showHidden
+ }
+ },
+ onVideoDataLoad (e) {
+ if (typeof e.srcElement.webkitAudioDecodedByteCount !== 'undefined') {
+ // non-zero if video has audio track
+ if (e.srcElement.webkitAudioDecodedByteCount > 0) {
+ this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly
+ }
+ } else if (typeof e.srcElement.mozHasAudio !== 'undefined') {
+ // true if video has audio track
+ if (e.srcElement.mozHasAudio) {
+ this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly
+ }
+ } else if (typeof e.srcElement.audioTracks !== 'undefined') {
+ if (e.srcElement.audioTracks.length > 0) {
+ this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly
}
}
}
diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue
index b2f63668..5eaa0d1d 100644
--- a/src/components/attachment/attachment.vue
+++ b/src/components/attachment/attachment.vue
@@ -2,19 +2,18 @@
<div v-if="size==='hide'">
<a class="placeholder" v-if="type !== 'html'" target="_blank" :href="attachment.url">[{{nsfw ? "NSFW/" : ""}}{{type.toUpperCase()}}]</a>
</div>
- <div v-else class="attachment" :class="{[type]: true, loading, 'small-attachment': isSmall, 'fullwidth': fullwidth}" v-show="!isEmpty">
+ <div v-else class="attachment" :class="{[type]: true, loading, 'small-attachment': isSmall, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}" v-show="!isEmpty">
<a class="image-attachment" v-if="hidden" @click.prevent="toggleHidden()">
<img :key="nsfwImage" :src="nsfwImage"/>
</a>
<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">
+ <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" :src="attachment.url" controls loop></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>
@@ -38,7 +37,6 @@
.attachments {
display: flex;
flex-wrap: wrap;
- margin-right: -0.7em;
.attachment.media-upload-container {
flex: 0 0 auto;
@@ -50,6 +48,14 @@
margin-right: 0.5em;
}
+ .nsfw-placeholder {
+ cursor: pointer;
+
+ &.loading {
+ cursor: progress;
+ }
+ }
+
.small-attachment {
&.image, &.video {
max-width: 35%;
@@ -58,6 +64,7 @@
}
.attachment {
+ position: relative;
flex: 1 0 30%;
margin: 0.5em 0.7em 0.6em 0.0em;
align-self: flex-start;
@@ -85,10 +92,6 @@
display: flex;
}
- &.loading {
- cursor: progress;
- }
-
.hider {
position: absolute;
margin: 10px;
@@ -96,6 +99,9 @@
background: rgba(230,230,230,0.6);
font-weight: bold;
z-index: 4;
+ line-height: 1;
+ border-radius: $fallback--tooltipRadius;
+ border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
}
.small {
@@ -154,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.js b/src/components/chat_panel/chat_panel.js
index d528d0a1..d8736d17 100644
--- a/src/components/chat_panel/chat_panel.js
+++ b/src/components/chat_panel/chat_panel.js
@@ -3,7 +3,7 @@ const chatPanel = {
return {
currentMessage: '',
channel: null,
- collapsed: false
+ collapsed: true
}
},
computed: {
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/conversation/conversation.vue b/src/components/conversation/conversation.vue
index 308e5e7d..5528fef6 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -1,9 +1,9 @@
<template>
<div class="timeline panel panel-default">
<div class="panel-heading conversation-heading">
- {{ $t('timeline.conversation') }}
- <span v-if="collapsable" style="float:right;">
- <small><a href="#" @click.prevent="$emit('toggleExpanded')">Collapse</a></small>
+ <span class="title"> {{ $t('timeline.conversation') }} </span>
+ <span v-if="collapsable">
+ <a href="#" @click.prevent="$emit('toggleExpanded')">{{ $t('timeline.collapse') }}</a>
</span>
</div>
<div class="panel-body">
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/dm_timeline/dm_timeline.js b/src/components/dm_timeline/dm_timeline.js
new file mode 100644
index 00000000..8b5393a9
--- /dev/null
+++ b/src/components/dm_timeline/dm_timeline.js
@@ -0,0 +1,14 @@
+import Timeline from '../timeline/timeline.vue'
+
+const DMs = {
+ computed: {
+ timeline () {
+ return this.$store.state.statuses.timelines.dms
+ }
+ },
+ components: {
+ Timeline
+ }
+}
+
+export default DMs
diff --git a/src/components/dm_timeline/dm_timeline.vue b/src/components/dm_timeline/dm_timeline.vue
new file mode 100644
index 00000000..f03da4d3
--- /dev/null
+++ b/src/components/dm_timeline/dm_timeline.vue
@@ -0,0 +1,5 @@
+<template>
+ <Timeline :title="$t('nav.dms')" v-bind:timeline="timeline" v-bind:timeline-name="'dms'"/>
+</template>
+
+<script src="./dm_timeline.js"></script>
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/favorite_button/favorite_button.js b/src/components/favorite_button/favorite_button.js
index 1266be90..a2b4cb65 100644
--- a/src/components/favorite_button/favorite_button.js
+++ b/src/components/favorite_button/favorite_button.js
@@ -2,6 +2,9 @@ const FavoriteButton = {
props: ['status', 'loggedIn'],
data () {
return {
+ hidePostStatsLocal: typeof this.$store.state.config.hidePostStats === 'undefined'
+ ? this.$store.state.instance.hidePostStats
+ : this.$store.state.config.hidePostStats,
animated: false
}
},
diff --git a/src/components/favorite_button/favorite_button.vue b/src/components/favorite_button/favorite_button.vue
index 1e1a6970..1decd070 100644
--- a/src/components/favorite_button/favorite_button.vue
+++ b/src/components/favorite_button/favorite_button.vue
@@ -1,11 +1,11 @@
<template>
<div v-if="loggedIn">
- <i :class='classes' class='favorite-button fav-active' @click.prevent='favorite()'/>
- <span v-if='status.fave_num > 0'>{{status.fave_num}}</span>
+ <i :class='classes' class='favorite-button fav-active' @click.prevent='favorite()' :title="$t('tool_tip.favorite')"/>
+ <span v-if='!hidePostStatsLocal && status.fave_num > 0'>{{status.fave_num}}</span>
</div>
<div v-else>
- <i :class='classes' class='favorite-button'/>
- <span v-if='status.fave_num > 0'>{{status.fave_num}}</span>
+ <i :class='classes' class='favorite-button' :title="$t('tool_tip.favorite')"/>
+ <span v-if='!hidePostStatsLocal && status.fave_num > 0'>{{status.fave_num}}</span>
</div>
</template>
diff --git a/src/components/features_panel/features_panel.js b/src/components/features_panel/features_panel.js
new file mode 100644
index 00000000..e0b7a118
--- /dev/null
+++ b/src/components/features_panel/features_panel.js
@@ -0,0 +1,14 @@
+const FeaturesPanel = {
+ computed: {
+ chat: function () {
+ return this.$store.state.instance.chatAvailable && (!this.$store.state.chatDisabled)
+ },
+ gopher: function () { return this.$store.state.instance.gopherAvailable },
+ whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled },
+ mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable },
+ scopeOptions: function () { return this.$store.state.instance.scopeOptionsEnabled },
+ textlimit: function () { return this.$store.state.instance.textlimit }
+ }
+}
+
+export default FeaturesPanel
diff --git a/src/components/features_panel/features_panel.vue b/src/components/features_panel/features_panel.vue
new file mode 100644
index 00000000..445143e9
--- /dev/null
+++ b/src/components/features_panel/features_panel.vue
@@ -0,0 +1,29 @@
+<template>
+ <div class="features-panel">
+ <div class="panel panel-default base01-background">
+ <div class="panel-heading timeline-heading base02-background base04">
+ <div class="title">
+ {{$t('features_panel.title')}}
+ </div>
+ </div>
+ <div class="panel-body features-panel">
+ <ul>
+ <li v-if="chat">{{$t('features_panel.chat')}}</li>
+ <li v-if="gopher">{{$t('features_panel.gopher')}}</li>
+ <li v-if="whoToFollow">{{$t('features_panel.who_to_follow')}}</li>
+ <li v-if="mediaProxy">{{$t('features_panel.media_proxy')}}</li>
+ <li v-if="scopeOptions">{{$t('features_panel.scope_options')}}</li>
+ <li>{{$t('features_panel.text_limit')}} = {{textlimit}}</li>
+ </ul>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script src="./features_panel.js" ></script>
+
+<style lang="scss">
+ .features-panel li {
+ line-height: 24px;
+ }
+</style>
diff --git a/src/components/follow_requests/follow_requests.js b/src/components/follow_requests/follow_requests.js
new file mode 100644
index 00000000..11a228aa
--- /dev/null
+++ b/src/components/follow_requests/follow_requests.js
@@ -0,0 +1,23 @@
+import UserCard from '../user_card/user_card.vue'
+
+const FollowRequests = {
+ components: {
+ UserCard
+ },
+ created () {
+ this.updateRequests()
+ },
+ computed: {
+ requests () {
+ return this.$store.state.api.followRequests
+ }
+ },
+ methods: {
+ updateRequests () {
+ this.$store.state.api.backendInteractor.fetchFollowRequests()
+ .then((requests) => { this.$store.commit('setFollowRequests', requests) })
+ }
+ }
+}
+
+export default FollowRequests
diff --git a/src/components/follow_requests/follow_requests.vue b/src/components/follow_requests/follow_requests.vue
new file mode 100644
index 00000000..87dc4194
--- /dev/null
+++ b/src/components/follow_requests/follow_requests.vue
@@ -0,0 +1,12 @@
+<template>
+ <div class="settings panel panel-default">
+ <div class="panel-heading">
+ {{$t('nav.friend_requests')}}
+ </div>
+ <div class="panel-body">
+ <user-card v-for="request in requests" :key="request.id" :user="request" :showFollows="false" :showApproval="true"></user-card>
+ </div>
+ </div>
+</template>
+
+<script src="./follow_requests.js"></script>
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 abd408c8..9bb5e945 100644
--- a/src/components/instance_specific_panel/instance_specific_panel.js
+++ b/src/components/instance_specific_panel/instance_specific_panel.js
@@ -1,7 +1,10 @@
const InstanceSpecificPanel = {
computed: {
instanceSpecificPanelContent () {
- return this.$store.state.config.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
new file mode 100644
index 00000000..3f58af2c
--- /dev/null
+++ b/src/components/interface_language_switcher/interface_language_switcher.vue
@@ -0,0 +1,41 @@
+<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">
+ {{ languageNames[i] }}
+ </option>
+ </select>
+ <i class="icon-down-open"/>
+ </label>
+ </div>
+</template>
+
+<script>
+ import languagesObject from '../../i18n/messages'
+ import ISO6391 from 'iso-639-1'
+ import _ from 'lodash'
+
+ export default {
+ computed: {
+ languageCodes () {
+ return Object.keys(languagesObject)
+ },
+
+ languageNames () {
+ return _.map(this.languageCodes, ISO6391.getName)
+ },
+
+ language: {
+ get: function () { return this.$store.state.config.interfaceLanguage },
+ set: function (val) {
+ this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
+ this.$i18n.locale = val
+ }
+ }
+ }
+ }
+</script>
diff --git a/src/components/login_form/login_form.js b/src/components/login_form/login_form.js
index a117b76f..49868aed 100644
--- a/src/components/login_form/login_form.js
+++ b/src/components/login_form/login_form.js
@@ -1,22 +1,40 @@
+import oauthApi from '../../services/new_api/oauth.js'
const LoginForm = {
data: () => ({
user: {},
authError: false
}),
computed: {
+ loginMethod () { return this.$store.state.instance.loginMethod },
loggingIn () { return this.$store.state.users.loggingIn },
- registrationOpen () { return this.$store.state.config.registrationOpen }
+ registrationOpen () { return this.$store.state.instance.registrationOpen }
},
methods: {
+ oAuthLogin () {
+ oauthApi.login({
+ oauth: this.$store.state.oauth,
+ instance: this.$store.state.instance.server,
+ commit: this.$store.commit
+ })
+ },
submit () {
- this.$store.dispatch('loginUser', this.user).then(
- () => {},
- (error) => {
- this.authError = error
- this.user.username = ''
- this.user.password = ''
- }
- )
+ 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')
+ })
+ })
}
}
}
diff --git a/src/components/login_form/login_form.vue b/src/components/login_form/login_form.vue
index 67fa95a8..12971882 100644
--- a/src/components/login_form/login_form.vue
+++ b/src/components/login_form/login_form.vue
@@ -5,10 +5,10 @@
{{$t('login.login')}}
</div>
<div class="panel-body">
- <form v-on:submit.prevent='submit(user)' class='login-form'>
+ <form v-if="loginMethod == 'password'" v-on:submit.prevent='submit(user)' class='login-form'>
<div class='form-group'>
<label for='username'>{{$t('login.username')}}</label>
- <input :disabled="loggingIn" v-model='user.username' class='form-control' id='username' placeholder='e.g. lain'>
+ <input :disabled="loggingIn" v-model='user.username' class='form-control' id='username' v-bind:placeholder="$t('login.placeholder')">
</div>
<div class='form-group'>
<label for='password'>{{$t('login.password')}}</label>
@@ -20,8 +20,17 @@
<button :disabled="loggingIn" type='submit' class='btn btn-default'>{{$t('login.login')}}</button>
</div>
</div>
- <div v-if="authError" class='form-group'>
- <div class='alert error'>{{authError}}</div>
+ </form>
+
+ <form v-if="loginMethod == 'token'" v-on:submit.prevent='oAuthLogin' class="login-form">
+ <div class="form-group">
+ <p>{{$t('login.description')}}</p>
+ </div>
+ <div class='form-group'>
+ <div class='login-bottom'>
+ <div><router-link :to="{name: 'registration'}" v-if='registrationOpen' class='register'>{{$t('login.register')}}</router-link></div>
+ <button :disabled="loggingIn" type='submit' class='btn btn-default'>{{$t('login.login')}}</button>
+ </div>
</div>
</form>
</div>
diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js
index 8b4e7ad4..66337c3f 100644
--- a/src/components/media_upload/media_upload.js
+++ b/src/components/media_upload/media_upload.js
@@ -6,8 +6,10 @@ const mediaUpload = {
const input = this.$el.querySelector('input')
input.addEventListener('change', ({target}) => {
- const file = target.files[0]
- this.uploadFile(file)
+ for (var i = 0; i < target.files.length; i++) {
+ let file = target.files[i]
+ this.uploadFile(file)
+ }
})
},
data () {
diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue
index 8b931d2d..768d3565 100644
--- a/src/components/media_upload/media_upload.vue
+++ b/src/components/media_upload/media_upload.vue
@@ -1,9 +1,9 @@
<template>
<div class="media-upload" @drop.prevent @dragover.prevent="fileDrag" @drop="fileDrop">
- <label class="btn btn-default">
+ <label class="btn btn-default" :title="$t('tool_tip.media_upload')">
<i class="icon-spin4 animate-spin" v-if="uploading"></i>
<i class="icon-upload" v-if="!uploading"></i>
- <input type=file style="position: fixed; top: -100em"></input>
+ <input type="file" style="position: fixed; top: -100em" multiple="true"></input>
</label>
</div>
</template>
diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js
index ea5d7ea4..19ce56c3 100644
--- a/src/components/nav_panel/nav_panel.js
+++ b/src/components/nav_panel/nav_panel.js
@@ -1,4 +1,5 @@
const NavPanel = {
+ props: [ 'activatePanel' ],
computed: {
currentUser () {
return this.$store.state.users.currentUser
diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue
index 6f949afb..b224c5f3 100644
--- a/src/components/nav_panel/nav_panel.vue
+++ b/src/components/nav_panel/nav_panel.vue
@@ -3,22 +3,32 @@
<div class="panel panel-default">
<ul>
<li v-if='currentUser'>
- <router-link to='/main/friends'>
+ <router-link @click.native="activatePanel('timeline')" to='/main/friends'>
{{ $t("nav.timeline") }}
</router-link>
</li>
<li v-if='currentUser'>
- <router-link :to="{ name: 'mentions', params: { username: currentUser.screen_name } }">
+ <router-link @click.native="activatePanel('timeline')" :to="{ name: 'mentions', params: { username: currentUser.screen_name } }">
{{ $t("nav.mentions") }}
</router-link>
</li>
+ <li v-if='currentUser'>
+ <router-link @click.native="activatePanel('timeline')" :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
+ {{ $t("nav.dms") }}
+ </router-link>
+ </li>
+ <li v-if='currentUser && currentUser.locked'>
+ <router-link @click.native="activatePanel('timeline')" to='/friend-requests'>
+ {{ $t("nav.friend_requests") }}
+ </router-link>
+ </li>
<li>
- <router-link to='/main/public'>
+ <router-link @click.native="activatePanel('timeline')" to='/main/public'>
{{ $t("nav.public_tl") }}
</router-link>
</li>
<li>
- <router-link to='/main/all'>
+ <router-link @click.native="activatePanel('timeline')" to='/main/all'>
{{ $t("nav.twkn") }}
</router-link>
</li>
@@ -45,8 +55,6 @@
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
- background-color: $fallback--bg;
- background-color: var(--bg, $fallback--bg);
padding: 0;
&:first-child a {
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index 3a274374..345fe3ee 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -1,15 +1,18 @@
import Status from '../status/status.vue'
import StillImage from '../still-image/still-image.vue'
import UserCardContent from '../user_card_content/user_card_content.vue'
+import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
const Notification = {
data () {
return {
- userExpanded: false
+ userExpanded: false,
+ betterShadow: this.$store.state.interface.browserSupport.cssFilter
}
},
props: [
- 'notification'
+ 'notification',
+ 'activatePanel'
],
components: {
Status, StillImage, UserCardContent
@@ -18,6 +21,16 @@ const Notification = {
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
}
+ },
+ computed: {
+ userClass () {
+ return highlightClass(this.notification.action.user)
+ },
+ userStyle () {
+ const highlight = this.$store.state.config.highlight
+ const user = this.notification.action.user
+ return highlightStyle(highlight[user.screen_name])
+ }
}
}
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index eed598a8..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>
- <div class="non-mention" v-else>
+ <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">
@@ -10,13 +10,14 @@
</div>
<span class="notification-details">
<div class="name-and-action">
- <span class="username" :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span>
- <span v-if="notification.type === 'favorite'">
+ <span class="username" v-if="!!notification.action.user.name_html" :title="'@'+notification.action.user.screen_name" v-html="notification.action.user.name_html"></span>
+ <span class="username" v-else :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span>
+ <span v-if="notification.type === 'like'">
<i class="fa icon-star lit"></i>
<small>{{$t('notifications.favorited_you')}}</small>
</span>
<span v-if="notification.type === 'repeat'">
- <i class="fa icon-retweet lit"></i>
+ <i class="fa icon-retweet lit" :title="$t('tool_tip.repeat')"></i>
<small>{{$t('notifications.repeated_you')}}</small>
</span>
<span v-if="notification.type === 'follow'">
@@ -24,12 +25,17 @@
<small>{{$t('notifications.followed_you')}}</small>
</span>
</div>
- <small class="timeago"><router-link :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>
- <status v-else class="faint" :compact="true" :statusoid="notification.status" :noHeading="true"></status>
+ <template v-else>
+ <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>
+ </template>
</div>
</div>
</template>
diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js
index f8314bfc..4b7a591d 100644
--- a/src/components/notifications/notifications.js
+++ b/src/components/notifications/notifications.js
@@ -1,25 +1,39 @@
import Notification from '../notification/notification.vue'
+import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js'
-import { sortBy, take, filter } from 'lodash'
+import { sortBy, filter } from 'lodash'
const Notifications = {
- data () {
- return {
- visibleNotificationCount: 20
- }
+ props: [ 'activatePanel' ],
+ created () {
+ const store = this.$store
+ const credentials = store.state.users.currentUser.credentials
+
+ notificationsFetcher.startFetching({ store, credentials })
},
computed: {
+ visibleTypes () {
+ return [
+ this.$store.state.config.notificationVisibility.likes && 'like',
+ this.$store.state.config.notificationVisibility.mentions && 'mention',
+ this.$store.state.config.notificationVisibility.repeats && 'repeat',
+ this.$store.state.config.notificationVisibility.follows && 'follow'
+ ].filter(_ => _)
+ },
notifications () {
- return this.$store.state.statuses.notifications
+ return this.$store.state.statuses.notifications.data
+ },
+ error () {
+ return this.$store.state.statuses.notifications.error
},
unseenNotifications () {
- return filter(this.notifications, ({seen}) => !seen)
+ return filter(this.visibleNotifications, ({seen}) => !seen)
},
visibleNotifications () {
// Don't know why, but sortBy([seen, -action.id]) doesn't work.
let sortedNotifications = sortBy(this.notifications, ({action}) => -action.id)
sortedNotifications = sortBy(sortedNotifications, 'seen')
- return take(sortedNotifications, this.visibleNotificationCount)
+ return sortedNotifications.filter((notification) => this.visibleTypes.includes(notification.type))
},
unseenCount () {
return this.unseenNotifications.length
@@ -39,7 +53,16 @@ const Notifications = {
},
methods: {
markAsSeen () {
- this.$store.commit('markNotificationsAsSeen', this.visibleNotifications)
+ this.$store.dispatch('markNotificationsAsSeen', this.visibleNotifications)
+ },
+ fetchOlderNotifications () {
+ const store = this.$store
+ const credentials = store.state.users.currentUser.credentials
+ notificationsFetcher.fetchAndUpdate({
+ store,
+ credentials,
+ older: true
+ })
}
}
}
diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss
index 9cbb1226..a6468e01 100644
--- a/src/components/notifications/notifications.scss
+++ b/src/components/notifications/notifications.scss
@@ -4,50 +4,28 @@
// a bit of a hack to allow scrolling below notifications
padding-bottom: 15em;
- .panel {
- background: $fallback--bg;
- background: var(--bg, $fallback--bg)
+ .loadmore-error {
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
}
- .panel-body {
- border-color: $fallback--border;
- border-color: var(--border, $fallback--border)
- }
-
- .panel-heading {
- // force the text to stay centered, while keeping
- // the button in the right side of the panel heading
+ .notification {
position: relative;
- background: $fallback--btn;
- background: var(--btn, $fallback--btn);
- color: $fallback--fg;
- color: var(--fg, $fallback--fg);
- .read-button {
+
+ .notification-overlay {
position: absolute;
- right: 0.7em;
- height: 1.8em;
- line-height: 100%;
+ top: 0;
+ right: 0;
+ left: 0;
+ bottom: 0;
+ pointer-events: none;
}
- }
- .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);
- min-width: 1.3em;
- border-radius: 1.3em;
- margin: 0 0.2em 0 -0.4em;
- color: white;
- font-size: 0.9em;
- text-align: center;
- line-height: 1.3em;
- }
-
- .unseen {
- border-left: 4px solid $fallback--cRed;
- border-left: 4px solid var(--cRed, $fallback--cRed);
- padding-left: 0;
+ &.unseen {
+ .notification-overlay {
+ background-image: linear-gradient(135deg, var(--badgeNotification, $fallback--cRed) 4px, transparent 10px)
+ }
+ }
}
}
@@ -55,23 +33,39 @@
box-sizing: border-box;
display: flex;
border-bottom: 1px solid;
- border-bottom-color: inherit;
- padding-left: 4px;
+ border-color: $fallback--border;
+ border-color: var(--border, $fallback--border);
+
+ .broken-favorite {
+ border-radius: $fallback--tooltipRadius;
+ border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
+ 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;
}
}
- &:hover .animated.avatar {
+ &:hover .animated.avatar-compact {
canvas {
display: none;
}
@@ -98,7 +92,10 @@
.status {
padding: 0.25em 0;
color: $fallback--faint;
- color: var($fallback--faint, --faint);
+ color: var(--faint, $fallback--faint);
+ a {
+ color: var(--faintLink);
+ }
}
padding: 0;
.media-body {
@@ -147,6 +144,13 @@
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
+
+ img {
+ width: 14px;
+ height: 14px;
+ vertical-align: middle;
+ object-fit: contain
+ }
}
.timeago {
float: right;
@@ -196,15 +200,4 @@
margin-bottom: 0.3em;
}
}
-
- // ugly as heck
- &:last-child {
- border-bottom: none;
- border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
- border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
- .status-el {
- border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
- border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
- }
- }
}
diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue
index 4fa6e925..bef48567 100644
--- a/src/components/notifications/notifications.vue
+++ b/src/components/notifications/notifications.vue
@@ -2,15 +2,27 @@
<div class="notifications">
<div class="panel panel-default">
<div class="panel-heading">
- <span class="unseen-count" v-if="unseenCount">{{unseenCount}}</span>
- {{$t('notifications.notifications')}}
+ <div class="title">
+ {{$t('notifications.notifications')}}
+ <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')}}
+ </div>
<button v-if="unseenCount" @click.prevent="markAsSeen" class="read-button">{{$t('notifications.read')}}</button>
</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">
+ <a href="#" v-on:click.prevent='fetchOlderNotifications()' v-if="!notifications.loading">
+ <div class="new-status-notification text-center panel-footer">{{$t('notifications.load_older')}}</div>
+ </a>
+ <div class="new-status-notification text-center panel-footer" v-else>...</div>
+ </div>
</div>
</div>
</template>
diff --git a/src/components/oauth_callback/oauth_callback.js b/src/components/oauth_callback/oauth_callback.js
new file mode 100644
index 00000000..7a5132ad
--- /dev/null
+++ b/src/components/oauth_callback/oauth_callback.js
@@ -0,0 +1,20 @@
+import oauth from '../../services/new_api/oauth.js'
+
+const oac = {
+ props: ['code'],
+ mounted () {
+ if (this.code) {
+ oauth.getToken({
+ app: this.$store.state.oauth,
+ instance: this.$store.state.instance.server,
+ code: this.code
+ }).then((result) => {
+ this.$store.commit('setToken', result.access_token)
+ this.$store.dispatch('loginUser', result.access_token)
+ this.$router.push('/main/friends')
+ })
+ }
+ }
+}
+
+export default oac
diff --git a/src/components/oauth_callback/oauth_callback.vue b/src/components/oauth_callback/oauth_callback.vue
new file mode 100644
index 00000000..9c806916
--- /dev/null
+++ b/src/components/oauth_callback/oauth_callback.vue
@@ -0,0 +1,5 @@
+<template>
+ <h1>...</h1>
+</template>
+
+<script src="./oauth_callback.js"></script>
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 6c95873c..f9252f73 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -23,22 +23,33 @@ const PostStatusForm = {
props: [
'replyTo',
'repliedUser',
- 'attentions'
+ 'attentions',
+ 'copyMessageScope',
+ 'subject'
],
components: {
MediaUpload
},
mounted () {
this.resize(this.$refs.textarea)
+
+ if (this.replyTo) {
+ this.$refs.textarea.focus()
+ }
},
data () {
- let statusText = ''
+ const preset = this.$route.query.message
+ let statusText = preset || ''
if (this.replyTo) {
const currentUser = this.$store.state.users.currentUser
statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser)
}
+ const scope = (this.copyMessageScope && this.$store.state.config.scopeCopy || this.copyMessageScope === 'direct')
+ ? this.copyMessageScope
+ : this.$store.state.users.currentUser.default_scope
+
return {
dropFiles: [],
submitDisabled: false,
@@ -46,18 +57,33 @@ const PostStatusForm = {
posting: false,
highlighted: 0,
newStatus: {
+ spoilerText: this.subject || '',
status: statusText,
- files: []
+ contentType: 'text/plain',
+ nsfw: false,
+ files: [],
+ visibility: scope
},
caret: 0
}
},
computed: {
+ vis () {
+ return {
+ public: { selected: this.newStatus.visibility === 'public' },
+ unlisted: { selected: this.newStatus.visibility === 'unlisted' },
+ private: { selected: this.newStatus.visibility === 'private' },
+ direct: { selected: this.newStatus.visibility === 'direct' }
+ }
+ },
candidates () {
const firstchar = this.textAtCaret.charAt(0)
if (firstchar === '@') {
- const matchedUsers = filter(this.users, (user) => (String(user.name + user.screen_name)).toUpperCase()
- .match(this.textAtCaret.slice(1).toUpperCase()))
+ const query = this.textAtCaret.slice(1).toUpperCase()
+ const matchedUsers = filter(this.users, (user) => {
+ return user.screen_name.toUpperCase().startsWith(query) ||
+ user.name && user.name.toUpperCase().startsWith(query)
+ })
if (matchedUsers.length <= 0) {
return false
}
@@ -71,16 +97,16 @@ const PostStatusForm = {
}))
} else if (firstchar === ':') {
if (this.textAtCaret === ':') { return }
- const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.match(this.textAtCaret.slice(1)))
+ const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1)))
if (matchedEmoji.length <= 0) {
return false
}
return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({
- // eslint-disable-next-line camelcase
screen_name: `:${shortcode}:`,
name: '',
utf: utf || '',
- img: image_url,
+ // eslint-disable-next-line camelcase
+ img: utf ? '' : this.$store.state.instance.server + image_url,
highlighted: index === this.highlighted
}))
} else {
@@ -98,25 +124,43 @@ const PostStatusForm = {
return this.$store.state.users.users
},
emoji () {
- return this.$store.state.config.emoji || []
+ return this.$store.state.instance.emoji || []
},
customEmoji () {
- return this.$store.state.config.customEmoji || []
+ return this.$store.state.instance.customEmoji || []
},
statusLength () {
return this.newStatus.status.length
},
+ spoilerTextLength () {
+ return this.newStatus.spoilerText.length
+ },
statusLengthLimit () {
- return this.$store.state.config.textlimit
+ return this.$store.state.instance.textlimit
},
hasStatusLengthLimit () {
return this.statusLengthLimit > 0
},
charactersLeft () {
- return this.statusLengthLimit - this.statusLength
+ return this.statusLengthLimit - (this.statusLength + this.spoilerTextLength)
},
isOverLengthLimit () {
- return this.hasStatusLengthLimit && (this.statusLength > this.statusLengthLimit)
+ return this.hasStatusLengthLimit && (this.charactersLeft < 0)
+ },
+ scopeOptionsEnabled () {
+ return this.$store.state.instance.scopeOptionsEnabled
+ },
+ alwaysShowSubject () {
+ if (typeof this.$store.state.config.alwaysShowSubjectInput !== 'undefined') {
+ return this.$store.state.config.alwaysShowSubjectInput
+ } else if (typeof this.$store.state.instance.alwaysShowSubjectInput !== 'undefined') {
+ return this.$store.state.instance.alwaysShowSubjectInput
+ } else {
+ return this.$store.state.instance.scopeOptionsEnabled
+ }
+ },
+ formattingOptionsEnabled () {
+ return this.$store.state.instance.formattingOptionsEnabled
}
},
methods: {
@@ -184,14 +228,21 @@ const PostStatusForm = {
this.posting = true
statusPoster.postStatus({
status: newStatus.status,
+ spoilerText: newStatus.spoilerText || null,
+ visibility: newStatus.visibility,
+ sensitive: newStatus.nsfw,
media: newStatus.files,
store: this.$store,
- inReplyToStatusId: this.replyTo
+ inReplyToStatusId: this.replyTo,
+ contentType: newStatus.contentType
}).then((data) => {
if (!data.error) {
this.newStatus = {
status: '',
- files: []
+ spoilerText: '',
+ files: [],
+ visibility: newStatus.visibility,
+ contentType: newStatus.contentType
}
this.$emit('posted')
let el = this.$el.querySelector('textarea')
@@ -238,18 +289,20 @@ const PostStatusForm = {
e.dataTransfer.dropEffect = 'copy'
},
resize (e) {
- const target = e.target || e
- target.style.height = 'auto'
- const heightPx = target.scrollHeight - 10
- if (heightPx > 54) {
- target.style.height = `${target.scrollHeight - 10}px`
- }
- if (target.value === '') {
- target.style.height = '16px'
+ if (!e.target) { return }
+ const vertPadding = Number(window.getComputedStyle(e.target)['padding-top'].substr(0, 1)) +
+ Number(window.getComputedStyle(e.target)['padding-bottom'].substr(0, 1))
+ e.target.style.height = 'auto'
+ e.target.style.height = `${e.target.scrollHeight - vertPadding}px`
+ if (e.target.value === '') {
+ e.target.style.height = '16px'
}
},
clearError () {
this.error = null
+ },
+ changeVis (visibility) {
+ this.newStatus.visibility = visibility
}
}
}
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 88627e3a..fcf5c873 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -2,6 +2,20 @@
<div class="post-status-form">
<form @submit.prevent="postStatus(newStatus)">
<div class="form-group" >
+ <i18n
+ v-if="!this.$store.state.users.currentUser.locked && this.newStatus.visibility == 'private'"
+ path="post_status.account_not_locked_warning"
+ tag="p"
+ class="visibility-notice">
+ <router-link to="/user-settings">{{ $t('post_status.account_not_locked_warning_link') }}</router-link>
+ </i18n>
+ <p v-if="this.newStatus.visibility == 'direct'" class="visibility-notice">{{ $t('post_status.direct_warning') }}</p>
+ <input
+ v-if="newStatus.spoilerText || alwaysShowSubject"
+ type="text"
+ :placeholder="$t('post_status.content_warning')"
+ v-model="newStatus.spoilerText"
+ class="form-cw">
<textarea
ref="textarea"
@click="setCaret"
@@ -18,16 +32,30 @@
@input="resize"
@paste="paste">
</textarea>
+ <div class="visibility-tray">
+ <span class="text-format" v-if="formattingOptionsEnabled">
+ <label for="post-content-type" class="select">
+ <select id="post-content-type" v-model="newStatus.contentType" class="form-control">
+ <option value="text/plain">{{$t('post_status.content_type.plain_text')}}</option>
+ <option value="text/html">HTML</option>
+ <option value="text/markdown">Markdown</option>
+ </select>
+ <i class="icon-down-open"></i>
+ </label>
+ </span>
+
+ <div v-if="scopeOptionsEnabled">
+ <i v-on:click="changeVis('direct')" class="icon-mail-alt" :class="vis.direct" :title="$t('post_status.scope.direct')"></i>
+ <i v-on:click="changeVis('private')" class="icon-lock" :class="vis.private" :title="$t('post_status.scope.private')"></i>
+ <i v-on:click="changeVis('unlisted')" class="icon-lock-open-alt" :class="vis.unlisted" :title="$t('post_status.scope.unlisted')"></i>
+ <i v-on:click="changeVis('public')" class="icon-globe" :class="vis.public" :title="$t('post_status.scope.public')"></i>
+ </div>
+ </div>
</div>
<div style="position:relative;" v-if="candidates">
<div class="autocomplete-panel">
<div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))">
- <div v-if="candidate.highlighted" class="autocomplete">
- <span v-if="candidate.img"><img :src="candidate.img"></span>
- <span v-else>{{candidate.utf}}</span>
- <span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
- </div>
- <div v-else class="autocomplete">
+ <div class="autocomplete" :class="{ highlighted: candidate.highlighted }">
<span v-if="candidate.img"><img :src="candidate.img"></img></span>
<span v-else>{{candidate.utf}}</span>
<span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
@@ -50,14 +78,20 @@
<i class="icon-cancel" @click="clearError"></i>
</div>
<div class="attachments">
- <div class="media-upload-container attachment" v-for="file in newStatus.files">
+ <div class="media-upload-wrapper" v-for="file in newStatus.files">
<i class="fa icon-cancel" @click="removeMediaFile(file)"></i>
- <img class="thumbnail media-upload" :src="file.image" v-if="type(file) === 'image'"></img>
- <video v-if="type(file) === 'video'" :src="file.image" controls></video>
- <audio v-if="type(file) === 'audio'" :src="file.image" controls></audio>
- <a v-if="type(file) === 'unknown'" :href="file.image">{{file.url}}</a>
+ <div class="media-upload-container attachment">
+ <img class="thumbnail media-upload" :src="file.image" v-if="type(file) === 'image'"></img>
+ <video v-if="type(file) === 'video'" :src="file.image" controls></video>
+ <audio v-if="type(file) === 'audio'" :src="file.image" controls></audio>
+ <a v-if="type(file) === 'unknown'" :href="file.image">{{file.url}}</a>
+ </div>
</div>
</div>
+ <div class="upload_settings" v-if="newStatus.files.length > 0">
+ <input type="checkbox" id="filesSensitive" v-model="newStatus.nsfw">
+ <label for="filesSensitive">{{$t('post_status.attachments_sensitive')}}</label>
+ </div>
</form>
</div>
</template>
@@ -105,14 +139,49 @@
text-align: center;
}
+ .media-upload-wrapper {
+ flex: 0 0 auto;
+ max-width: 100%;
+ min-width: 50px;
+ margin-right: .2em;
+ margin-bottom: .5em;
+
+ .icon-cancel {
+ display: inline-block;
+ position: static;
+ margin: 0;
+ padding-bottom: 0;
+ margin-left: $fallback--attachmentRadius;
+ margin-left: var(--attachmentRadius, $fallback--attachmentRadius);
+ background-color: $fallback--fg;
+ background-color: var(--btn, $fallback--fg);
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+ }
+
.attachments {
padding: 0 0.5em;
.attachment {
+ margin: 0;
position: relative;
+ flex: 0 0 auto;
border: 1px solid $fallback--border;
border: 1px solid var(--border, $fallback--border);
- margin: 0.5em 0.8em 0.2em 0;
+ text-align: center;
+
+ audio {
+ min-width: 300px;
+ flex: 1 0 auto;
+ }
+
+ a {
+ display: block;
+ text-align: left;
+ line-height: 1.2;
+ padding: .5em;
+ }
}
i {
@@ -135,10 +204,6 @@
cursor: not-allowed;
}
- .icon-cancel {
- cursor: pointer;
- }
-
form {
display: flex;
flex-direction: column;
@@ -152,7 +217,15 @@
line-height:24px;
}
- form textarea {
+ form textarea.form-cw {
+ line-height:16px;
+ resize: none;
+ overflow: hidden;
+ transition: min-height 200ms 100ms;
+ min-height: 1px;
+ }
+
+ form textarea.form-control {
line-height:16px;
resize: none;
overflow: hidden;
@@ -161,7 +234,7 @@
box-sizing: content-box;
}
- form textarea:focus {
+ form textarea.form-control:focus {
min-height: 48px;
}
@@ -185,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--btn;
- background: var(--btn, $fallback--btn);
- color: $fallback--lightFg;
- color: var(--lightFg, $fallback--lightFg);
+ background: $fallback--bg;
+ background: var(--bg, $fallback--bg);
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
}
.autocomplete {
@@ -216,6 +291,11 @@
color: $fallback--faint;
color: var(--faint, $fallback--faint);
}
+
+ &.highlighted {
+ background-color: $fallback--fg;
+ background-color: var(--lightBg, $fallback--fg);
+ }
}
}
</style>
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 771b3b27..e5ead8bc 100644
--- a/src/components/registration/registration.js
+++ b/src/components/registration/registration.js
@@ -1,35 +1,61 @@
+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.config.registrationOpen || !!this.$store.state.users.currentUser) {
+ if ((!this.registrationOpen && !this.token) || this.signedIn) {
this.$router.push('/main/all')
}
},
computed: {
- termsofservice () { return this.$store.state.config.tos }
+ 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.$store.state.api.backendInteractor.register(this.user).then(
- (response) => {
- if (response.ok) {
- this.$store.dispatch('loginUser', this.user)
- this.$router.push('/main/all')
- this.registering = false
- } else {
- this.registering = false
- response.json().then((data) => {
- this.error = data.error
- })
- }
+ this.user.token = this.token
+
+ 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 00f665af..8cb1392b 100644
--- a/src/components/registration/registration.vue
+++ b/src/components/registration/registration.vue
@@ -7,46 +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>
@@ -56,6 +100,7 @@
<script src="./registration.js"></script>
<style lang="scss">
@import '../../_variables.scss';
+$validations-cRed: #f04124;
.registration-form {
display: flex;
@@ -85,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 {
@@ -98,8 +192,6 @@
}
.btn {
- //align-self: flex-start;
- //width: 10em;
margin-top: 0.6em;
height: 28px;
}
diff --git a/src/components/retweet_button/retweet_button.js b/src/components/retweet_button/retweet_button.js
index 4a43542d..eb4e4b41 100644
--- a/src/components/retweet_button/retweet_button.js
+++ b/src/components/retweet_button/retweet_button.js
@@ -1,7 +1,10 @@
const RetweetButton = {
- props: ['status', 'loggedIn'],
+ props: ['status', 'loggedIn', 'visibility'],
data () {
return {
+ hidePostStatsLocal: typeof this.$store.state.config.hidePostStats === 'undefined'
+ ? this.$store.state.instance.hidePostStats
+ : this.$store.state.config.hidePostStats,
animated: false
}
},
@@ -9,6 +12,8 @@ const RetweetButton = {
retweet () {
if (!this.status.repeated) {
this.$store.dispatch('retweet', {id: this.status.id})
+ } else {
+ this.$store.dispatch('unretweet', {id: this.status.id})
}
this.animated = true
setTimeout(() => {
@@ -20,6 +25,7 @@ const RetweetButton = {
classes () {
return {
'retweeted': this.status.repeated,
+ 'retweeted-empty': !this.status.repeated,
'animate-spin': this.animated
}
}
diff --git a/src/components/retweet_button/retweet_button.vue b/src/components/retweet_button/retweet_button.vue
index 1bee3d08..c957fb77 100644
--- a/src/components/retweet_button/retweet_button.vue
+++ b/src/components/retweet_button/retweet_button.vue
@@ -1,11 +1,16 @@
<template>
<div v-if="loggedIn">
- <i :class='classes' class='icon-retweet rt-active' v-on:click.prevent='retweet()'></i>
- <span v-if='status.repeat_num > 0'>{{status.repeat_num}}</span>
+ <template v-if="visibility !== 'private' && visibility !== 'direct'">
+ <i :class='classes' class='retweet-button icon-retweet rt-active' v-on:click.prevent='retweet()' :title="$t('tool_tip.repeat')"></i>
+ <span v-if='!hidePostStatsLocal && status.repeat_num > 0'>{{status.repeat_num}}</span>
+ </template>
+ <template v-else>
+ <i :class='classes' class='icon-lock' :title="$t('timeline.no_retweet_hint')"></i>
+ </template>
</div>
- <div v-else>
- <i :class='classes' class='icon-retweet'></i>
- <span v-if='status.repeat_num > 0'>{{status.repeat_num}}</span>
+ <div v-else-if="!loggedIn">
+ <i :class='classes' class='icon-retweet' :title="$t('tool_tip.repeat')"></i>
+ <span v-if='!hidePostStatsLocal && status.repeat_num > 0'>{{status.repeat_num}}</span>
</div>
</template>
diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js
index a26111d6..681ccda8 100644
--- a/src/components/settings/settings.js
+++ b/src/components/settings/settings.js
@@ -1,25 +1,73 @@
+/* eslint-env browser */
+import TabSwitcher from '../tab_switcher/tab_switcher.jsx'
import StyleSwitcher from '../style_switcher/style_switcher.vue'
+import InterfaceLanguageSwitcher from '../interface_language_switcher/interface_language_switcher.vue'
import { filter, trim } from 'lodash'
const settings = {
data () {
+ const user = this.$store.state.config
+ const instance = this.$store.state.instance
+
return {
- hideAttachmentsLocal: this.$store.state.config.hideAttachments,
- hideAttachmentsInConvLocal: this.$store.state.config.hideAttachmentsInConv,
- hideNsfwLocal: this.$store.state.config.hideNsfw,
- muteWordsString: this.$store.state.config.muteWords.join('\n'),
- autoLoadLocal: this.$store.state.config.autoLoad,
- streamingLocal: this.$store.state.config.streaming,
- hoverPreviewLocal: this.$store.state.config.hoverPreview,
- stopGifs: this.$store.state.config.stopGifs
+ hideAttachmentsLocal: user.hideAttachments,
+ hideAttachmentsInConvLocal: user.hideAttachmentsInConv,
+ hideNsfwLocal: user.hideNsfw,
+ hideISPLocal: user.hideISP,
+ preloadImage: user.preloadImage,
+ hidePostStatsLocal: typeof user.hidePostStats === 'undefined'
+ ? instance.hidePostStats
+ : user.hidePostStats,
+ hidePostStatsDefault: this.$t('settings.values.' + instance.hidePostStats),
+ hideUserStatsLocal: typeof user.hideUserStats === 'undefined'
+ ? instance.hideUserStats
+ : user.hideUserStats,
+ hideUserStatsDefault: this.$t('settings.values.' + instance.hideUserStats),
+ notificationVisibilityLocal: user.notificationVisibility,
+ replyVisibilityLocal: user.replyVisibility,
+ loopVideoLocal: user.loopVideo,
+ loopVideoSilentOnlyLocal: user.loopVideoSilentOnly,
+ muteWordsString: user.muteWords.join('\n'),
+ autoLoadLocal: user.autoLoad,
+ streamingLocal: user.streaming,
+ pauseOnUnfocusedLocal: user.pauseOnUnfocused,
+ hoverPreviewLocal: user.hoverPreview,
+ collapseMessageWithSubjectLocal: typeof user.collapseMessageWithSubject === 'undefined'
+ ? instance.collapseMessageWithSubject
+ : user.collapseMessageWithSubject,
+ collapseMessageWithSubjectDefault: this.$t('settings.values.' + instance.collapseMessageWithSubject),
+ subjectLineBehaviorLocal: typeof user.subjectLineBehavior === 'undefined'
+ ? instance.subjectLineBehavior
+ : user.subjectLineBehavior,
+ subjectLineBehaviorDefault: instance.subjectLineBehavior,
+ alwaysShowSubjectInputLocal: typeof user.alwaysShowSubjectInput === 'undefined'
+ ? instance.alwaysShowSubjectInput
+ : user.alwaysShowSubjectInput,
+ alwaysShowSubjectInputDefault: instance.alwaysShowSubjectInput,
+ scopeCopyLocal: user.scopeCopy,
+ scopeCopyDefault: this.$t('settings.values.' + instance.scopeCopy),
+ stopGifs: user.stopGifs,
+ webPushNotificationsLocal: user.webPushNotifications,
+ loopSilentAvailable:
+ // Firefox
+ Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') ||
+ // Chrome-likes
+ Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'webkitAudioDecodedByteCount') ||
+ // Future spec, still not supported in Nightly 63 as of 08/2018
+ Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'audioTracks')
}
},
components: {
- StyleSwitcher
+ TabSwitcher,
+ StyleSwitcher,
+ InterfaceLanguageSwitcher
},
computed: {
user () {
return this.$store.state.users.currentUser
+ },
+ currentSaveStateNotice () {
+ return this.$store.state.interface.settings.currentSaveStateNotice
}
},
watch: {
@@ -29,15 +77,51 @@ const settings = {
hideAttachmentsInConvLocal (value) {
this.$store.dispatch('setOption', { name: 'hideAttachmentsInConv', value })
},
+ hidePostStatsLocal (value) {
+ this.$store.dispatch('setOption', { name: 'hidePostStats', value })
+ },
+ hideUserStatsLocal (value) {
+ this.$store.dispatch('setOption', { name: 'hideUserStats', value })
+ },
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 })
+ },
+ 'notificationVisibilityLocal.follows' (value) {
+ this.$store.dispatch('setOption', { name: 'notificationVisibility', value: this.$store.state.config.notificationVisibility })
+ },
+ 'notificationVisibilityLocal.repeats' (value) {
+ this.$store.dispatch('setOption', { name: 'notificationVisibility', value: this.$store.state.config.notificationVisibility })
+ },
+ 'notificationVisibilityLocal.mentions' (value) {
+ this.$store.dispatch('setOption', { name: 'notificationVisibility', value: this.$store.state.config.notificationVisibility })
+ },
+ replyVisibilityLocal (value) {
+ this.$store.dispatch('setOption', { name: 'replyVisibility', value })
+ },
+ loopVideoLocal (value) {
+ this.$store.dispatch('setOption', { name: 'loopVideo', value })
+ },
+ loopVideoSilentOnlyLocal (value) {
+ this.$store.dispatch('setOption', { name: 'loopVideoSilentOnly', value })
+ },
autoLoadLocal (value) {
this.$store.dispatch('setOption', { name: 'autoLoad', value })
},
streamingLocal (value) {
this.$store.dispatch('setOption', { name: 'streaming', value })
},
+ pauseOnUnfocusedLocal (value) {
+ this.$store.dispatch('setOption', { name: 'pauseOnUnfocused', value })
+ },
hoverPreviewLocal (value) {
this.$store.dispatch('setOption', { name: 'hoverPreview', value })
},
@@ -45,8 +129,24 @@ const settings = {
value = filter(value.split('\n'), (word) => trim(word).length > 0)
this.$store.dispatch('setOption', { name: 'muteWords', value })
},
+ collapseMessageWithSubjectLocal (value) {
+ this.$store.dispatch('setOption', { name: 'collapseMessageWithSubject', value })
+ },
+ scopeCopyLocal (value) {
+ this.$store.dispatch('setOption', { name: 'scopeCopy', value })
+ },
+ alwaysShowSubjectInputLocal (value) {
+ this.$store.dispatch('setOption', { name: 'alwaysShowSubjectInput', value })
+ },
+ subjectLineBehaviorLocal (value) {
+ this.$store.dispatch('setOption', { name: 'subjectLineBehavior', value })
+ },
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 b4514ba1..3f920de5 100644
--- a/src/components/settings/settings.vue
+++ b/src/components/settings/settings.vue
@@ -1,53 +1,234 @@
<template>
- <div class="settings panel panel-default">
- <div class="panel-heading">
+<div class="settings panel panel-default">
+ <div class="panel-heading">
+ <div class="title">
{{$t('settings.settings')}}
</div>
- <div class="panel-body">
- <div class="setting-item">
- <h2>{{$t('settings.theme')}}</h2>
- <style-switcher></style-switcher>
- </div>
- <div class="setting-item">
- <h2>{{$t('settings.filtering')}}</h2>
- <p>{{$t('settings.filtering_explanation')}}</p>
- <textarea id="muteWords" v-model="muteWordsString"></textarea>
- </div>
- <div class="setting-item">
- <h2>{{$t('settings.attachments')}}</h2>
- <ul class="setting-list">
+
+ <transition name="fade">
+ <template v-if="currentSaveStateNotice">
+ <div @click.prevent class="alert error" v-if="currentSaveStateNotice.error">
+ {{ $t('settings.saving_err') }}
+ </div>
+
+ <div @click.prevent class="alert transparent" v-if="!currentSaveStateNotice.error">
+ {{ $t('settings.saving_ok') }}
+ </div>
+ </template>
+ </transition>
+ </div>
+ <div class="panel-body">
+<keep-alive>
+ <tab-switcher>
+ <div :label="$t('settings.general')" >
+ <div class="setting-item">
+ <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>
+ <ul class="setting-list">
+ <li>
+ <input type="checkbox" id="collapseMessageWithSubject" v-model="collapseMessageWithSubjectLocal">
+ <label for="collapseMessageWithSubject">
+ {{$t('settings.collapse_subject')}} {{$t('settings.instance_default', { value: collapseMessageWithSubjectDefault })}}
+ </label>
+ </li>
+ <li>
+ <input type="checkbox" id="streaming" v-model="streamingLocal">
+ <label for="streaming">{{$t('settings.streaming')}}</label>
+ <ul class="setting-list suboptions" :class="[{disabled: !streamingLocal}]">
+ <li>
+ <input :disabled="!streamingLocal" type="checkbox" id="pauseOnUnfocused" v-model="pauseOnUnfocusedLocal">
+ <label for="pauseOnUnfocused">{{$t('settings.pause_on_unfocused')}}</label>
+ </li>
+ </ul>
+ </li>
+ <li>
+ <input type="checkbox" id="autoload" v-model="autoLoadLocal">
+ <label for="autoload">{{$t('settings.autoload')}}</label>
+ </li>
+ <li>
+ <input type="checkbox" id="hoverPreview" v-model="hoverPreviewLocal">
+ <label for="hoverPreview">{{$t('settings.reply_link_preview')}}</label>
+ </li>
+ </ul>
+ </div>
+
+ <div class="setting-item">
+ <h2>{{$t('settings.composing')}}</h2>
+ <ul class="setting-list">
+ <li>
+ <input type="checkbox" id="scopeCopy" v-model="scopeCopyLocal">
+ <label for="scopeCopy">
+ {{$t('settings.scope_copy')}} {{$t('settings.instance_default', { value: scopeCopyDefault })}}
+ </label>
+ </li>
+ <li>
+ <input type="checkbox" id="subjectHide" v-model="alwaysShowSubjectInputLocal">
+ <label for="subjectHide">
+ {{$t('settings.subject_input_always_show')}} {{$t('settings.instance_default', { value: alwaysShowSubjectInputDefault })}}
+ </label>
+ </li>
<li>
- <input type="checkbox" id="hideAttachments" v-model="hideAttachmentsLocal">
- <label for="hideAttachments">{{$t('settings.hide_attachments_in_tl')}}</label>
+ <div>
+ {{$t('settings.subject_line_behavior')}}
+ <label for="subjectLineBehavior" class="select">
+ <select id="subjectLineBehavior" v-model="subjectLineBehaviorLocal">
+ <option value="email">
+ {{$t('settings.subject_line_email')}}
+ {{subjectLineBehaviorDefault == 'email' ? $t('settings.instance_default_simple') : ''}}
+ </option>
+ <option value="masto">
+ {{$t('settings.subject_line_mastodon')}}
+ {{subjectLineBehaviorDefault == 'mastodon' ? $t('settings.instance_default_simple') : ''}}
+ </option>
+ <option value="noop">
+ {{$t('settings.subject_line_noop')}}
+ {{subjectLineBehaviorDefault == 'noop' ? $t('settings.instance_default_simple') : ''}}
+ </option>
+ </select>
+ <i class="icon-down-open"/>
+ </label>
+ </div>
</li>
+ </ul>
+ </div>
+
+ <div class="setting-item">
+ <h2>{{$t('settings.attachments')}}</h2>
+ <ul class="setting-list">
<li>
- <input type="checkbox" id="hideAttachmentsInConv" v-model="hideAttachmentsInConvLocal">
- <label for="hideAttachmentsInConv">{{$t('settings.hide_attachments_in_convo')}}</label>
+ <input type="checkbox" id="hideAttachments" v-model="hideAttachmentsLocal">
+ <label for="hideAttachments">{{$t('settings.hide_attachments_in_tl')}}</label>
</li>
<li>
- <input type="checkbox" id="hideNsfw" v-model="hideNsfwLocal">
- <label for="hideNsfw">{{$t('settings.nsfw_clickthrough')}}</label>
+ <input type="checkbox" id="hideAttachmentsInConv" v-model="hideAttachmentsInConvLocal">
+ <label for="hideAttachmentsInConv">{{$t('settings.hide_attachments_in_convo')}}</label>
</li>
<li>
- <input type="checkbox" id="autoload" v-model="autoLoadLocal">
- <label for="autoload">{{$t('settings.autoload')}}</label>
+ <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="streaming" v-model="streamingLocal">
- <label for="streaming">{{$t('settings.streaming')}}</label>
+ <input type="checkbox" id="stopGifs" v-model="stopGifs">
+ <label for="stopGifs">{{$t('settings.stop_gifs')}}</label>
</li>
<li>
- <input type="checkbox" id="hoverPreview" v-model="hoverPreviewLocal">
- <label for="hoverPreview">{{$t('settings.reply_link_preview')}}</label>
+ <input type="checkbox" id="loopVideo" v-model="loopVideoLocal">
+ <label for="loopVideo">{{$t('settings.loop_video')}}</label>
+ <ul class="setting-list suboptions" :class="[{disabled: !streamingLocal}]">
+ <li>
+ <input :disabled="!loopVideoLocal || !loopSilentAvailable" type="checkbox" id="loopVideoSilentOnly" v-model="loopVideoSilentOnlyLocal">
+ <label for="loopVideoSilentOnly">{{$t('settings.loop_video_silent_only')}}</label>
+ <div v-if="!loopSilentAvailable" class="unavailable">
+ <i class="icon-globe"/>! {{$t('settings.limited_availability')}}
+ </div>
+ </li>
+ </ul>
</li>
+ </ul>
+ </div>
+
+ <div class="setting-item">
+ <h2>{{$t('settings.notifications')}}</h2>
+ <ul class="setting-list">
<li>
- <input type="checkbox" id="stopGifs" v-model="stopGifs">
- <label for="stopGifs">{{$t('settings.stop_gifs')}}</label>
+ <input type="checkbox" id="webPushNotifications" v-model="webPushNotificationsLocal">
+ <label for="webPushNotifications">
+ {{$t('settings.enable_web_push_notifications')}}
+ </label>
</li>
- </ul>
+ </ul>
+ </div>
</div>
- </div>
+
+ <div :label="$t('settings.theme')" >
+ <div class="setting-item">
+ <style-switcher></style-switcher>
+ </div>
+ </div>
+
+ <div :label="$t('settings.filtering')" >
+ <div class="setting-item">
+ <div class="select-multiple">
+ <span class="label">{{$t('settings.notification_visibility')}}</span>
+ <ul class="option-list">
+ <li>
+ <input type="checkbox" id="notification-visibility-likes" v-model="notificationVisibilityLocal.likes">
+ <label for="notification-visibility-likes">
+ {{$t('settings.notification_visibility_likes')}}
+ </label>
+ </li>
+ <li>
+ <input type="checkbox" id="notification-visibility-repeats" v-model="notificationVisibilityLocal.repeats">
+ <label for="notification-visibility-repeats">
+ {{$t('settings.notification_visibility_repeats')}}
+ </label>
+ </li>
+ <li>
+ <input type="checkbox" id="notification-visibility-follows" v-model="notificationVisibilityLocal.follows">
+ <label for="notification-visibility-follows">
+ {{$t('settings.notification_visibility_follows')}}
+ </label>
+ </li>
+ <li>
+ <input type="checkbox" id="notification-visibility-mentions" v-model="notificationVisibilityLocal.mentions">
+ <label for="notification-visibility-mentions">
+ {{$t('settings.notification_visibility_mentions')}}
+ </label>
+ </li>
+ </ul>
+ </label>
+ </div>
+ <div>
+ {{$t('settings.replies_in_timeline')}}
+ <label for="replyVisibility" class="select">
+ <select id="replyVisibility" v-model="replyVisibilityLocal">
+ <option value="all" selected>{{$t('settings.reply_visibility_all')}}</option>
+ <option value="following">{{$t('settings.reply_visibility_following')}}</option>
+ <option value="self">{{$t('settings.reply_visibility_self')}}</option>
+ </select>
+ <i class="icon-down-open"/>
+ </label>
+ </div>
+ <div>
+ <input type="checkbox" id="hidePostStats" v-model="hidePostStatsLocal">
+ <label for="hidePostStats">
+ {{$t('settings.hide_post_stats')}} {{$t('settings.instance_default', { value: hidePostStatsDefault })}}
+ </label>
+ </div>
+ <div>
+ <input type="checkbox" id="hideUserStats" v-model="hideUserStatsLocal">
+ <label for="hideUserStats">
+ {{$t('settings.hide_user_stats')}} {{$t('settings.instance_default', { value: hideUserStatsDefault })}}
+ </label>
+ </div>
+ </div>
+ <div class="setting-item">
+ <p>{{$t('settings.filtering_explanation')}}</p>
+ <textarea id="muteWords" v-model="muteWordsString"></textarea>
+ </div>
+ </div>
+
+ </tab-switcher>
+</keep-alive>
</div>
+</div>
</template>
<script src="./settings.js">
@@ -57,13 +238,39 @@
@import '../../_variables.scss';
.setting-item {
+ border-bottom: 2px solid var(--fg, $fallback--fg);
margin: 1em 1em 1.4em;
+ padding-bottom: 1.4em;
+
+ > div {
+ margin-bottom: .5em;
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ &:last-child {
+ border-bottom: none;
+ padding-bottom: 0;
+ margin-bottom: 1em;
+ }
+
+ select {
+ min-width: 10em;
+ }
+
textarea {
width: 100%;
height: 100px;
}
+ .unavailable,
+ .unavailable i {
+ color: var(--cRed, $fallback--cRed);
+ color: $fallback--cRed;
+ }
+
.old-avatar {
width: 128px;
border-radius: $fallback--avatarRadius;
@@ -79,15 +286,27 @@
}
.btn {
- margin-top: 1em;
min-height: 28px;
- width: 10em;
+ min-width: 10em;
+ padding: 0 2em;
}
}
-.setting-list {
+.select-multiple {
+ display: flex;
+ .option-list {
+ margin: 0;
+ padding-left: .5em;
+ }
+}
+.setting-list,
+.option-list{
list-style-type: none;
+ padding-left: 2em;
li {
margin-bottom: 0.5em;
}
+ .suboptions {
+ margin-top: 0.3em
+ }
}
</style>
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 73f4a7aa..9a63d047 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -6,6 +6,7 @@ import PostStatusForm from '../post_status_form/post_status_form.vue'
import UserCardContent from '../user_card_content/user_card_content.vue'
import StillImage from '../still-image/still-image.vue'
import { filter, find } from 'lodash'
+import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
const Status = {
name: 'Status',
@@ -19,27 +20,62 @@ const Status = {
'replies',
'noReplyLinks',
'noHeading',
- 'inlineExpanded'
+ 'inlineExpanded',
+ 'activatePanel'
],
- data: () => ({
- replying: false,
- expanded: false,
- unmuted: false,
- userExpanded: false,
- preview: null,
- showPreview: false,
- showingTall: false
- }),
+ data () {
+ return {
+ replying: false,
+ expanded: false,
+ unmuted: false,
+ userExpanded: false,
+ preview: null,
+ showPreview: false,
+ showingTall: false,
+ expandingSubject: typeof this.$store.state.config.collapseMessageWithSubject === 'undefined'
+ ? !this.$store.state.instance.collapseMessageWithSubject
+ : !this.$store.state.config.collapseMessageWithSubject,
+ betterShadow: this.$store.state.interface.browserSupport.cssFilter
+ }
+ },
computed: {
+ localCollapseSubjectDefault () {
+ return typeof this.$store.state.config.collapseMessageWithSubject === 'undefined'
+ ? this.$store.state.instance.collapseMessageWithSubject
+ : this.$store.state.config.collapseMessageWithSubject
+ },
muteWords () {
return this.$store.state.config.muteWords
},
+ repeaterClass () {
+ const user = this.statusoid.user
+ return highlightClass(user)
+ },
+ userClass () {
+ 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
+ return highlightStyle(highlight[user.screen_name])
+ },
+ userStyle () {
+ if (this.noHeading) return
+ const user = this.retweet ? (this.statusoid.retweeted_status.user) : this.statusoid.user
+ const highlight = this.$store.state.config.highlight
+ return highlightStyle(highlight[user.screen_name])
+ },
hideAttachments () {
return (this.$store.state.config.hideAttachments && !this.inConversation) ||
(this.$store.state.config.hideAttachmentsInConv && this.inConversation)
},
retweet () { return !!this.statusoid.retweeted_status },
retweeter () { return this.statusoid.user.name },
+ retweeterHtml () { return this.statusoid.user.name_html },
status () {
if (this.retweet) {
return this.statusoid.retweeted_status
@@ -59,7 +95,6 @@ const Status = {
return hits
},
muted () { return !this.unmuted && (this.status.user.muted || this.muteWordHits.length > 0) },
- isReply () { return !!this.status.in_reply_to_status_id },
isFocused () {
// retweet or root of an expanded conversation
if (this.focused) {
@@ -77,12 +112,90 @@ const Status = {
//
// Using max-height + overflow: auto for status components resulted in false positives
// very often with japanese characters, and it was very annoying.
+ tallStatus () {
+ const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80
+ return lengthScore > 20
+ },
+ isReply () {
+ if (this.status.in_reply_to_status_id) {
+ return true
+ }
+ // For private replies where we can't see the OP, in_reply_to_status_id will be null.
+ // So instead, check that the post starts with a @mention.
+ if (this.status.visibility === 'private') {
+ var textBody = this.status.text
+ if (this.status.summary !== null) {
+ textBody = textBody.substring(this.status.summary.length, textBody.length)
+ }
+ return textBody.startsWith('@')
+ }
+ return false
+ },
+ hideReply () {
+ if (this.$store.state.config.replyVisibility === 'all') {
+ return false
+ }
+ if (this.inlineExpanded || this.expanded || this.inConversation || !this.isReply) {
+ return false
+ }
+ if (this.status.user.id === this.$store.state.users.currentUser.id) {
+ return false
+ }
+ if (this.status.activity_type === 'repeat') {
+ return false
+ }
+ var checkFollowing = this.$store.state.config.replyVisibility === 'following'
+ for (var i = 0; i < this.status.attentions.length; ++i) {
+ if (this.status.user.id === this.status.attentions[i].id) {
+ continue
+ }
+ if (checkFollowing && this.status.attentions[i].following) {
+ return false
+ }
+ if (this.status.attentions[i].id === this.$store.state.users.currentUser.id) {
+ return false
+ }
+ }
+ return this.status.attentions.length > 0
+ },
+ hideSubjectStatus () {
+ if (this.tallStatus && !this.localCollapseSubjectDefault) {
+ return false
+ }
+ return !this.expandingSubject && this.status.summary
+ },
hideTallStatus () {
+ if (this.status.summary && this.localCollapseSubjectDefault) {
+ return false
+ }
if (this.showingTall) {
return false
}
- const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80
- return lengthScore > 20
+ return this.tallStatus
+ },
+ showingMore () {
+ return this.showingTall || (this.status.summary && this.expandingSubject)
+ },
+ nsfwClickthrough () {
+ if (!this.status.nsfw) {
+ return false
+ }
+ if (this.status.summary && this.localCollapseSubjectDefault) {
+ return false
+ }
+ return true
+ },
+ replySubject () {
+ if (!this.status.summary) return ''
+ const behavior = this.$store.state.config.subjectLineBehavior
+ const startsWithRe = this.status.summary.match(/^re[: ]/i)
+ if (behavior !== 'noop' && startsWithRe || behavior === 'masto') {
+ return this.status.summary
+ } else if (behavior === 'email') {
+ return 're: '.concat(this.status.summary)
+ } else if (behavior === 'noop') {
+ return ''
+ }
},
attachmentSize () {
if ((this.$store.state.config.hideAttachments && !this.inConversation) ||
@@ -104,6 +217,18 @@ const Status = {
StillImage
},
methods: {
+ visibilityIcon (visibility) {
+ switch (visibility) {
+ case 'private':
+ return 'icon-lock'
+ case 'unlisted':
+ return 'icon-lock-open-alt'
+ case 'direct':
+ return 'icon-mail-alt'
+ default:
+ return 'icon-globe'
+ }
+ },
linkClicked ({target}) {
if (target.tagName === 'SPAN') {
target = target.parentNode
@@ -130,8 +255,16 @@ const Status = {
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
},
- toggleShowTall () {
- this.showingTall = !this.showingTall
+ toggleShowMore () {
+ if (this.showingTall) {
+ this.showingTall = false
+ } else if (this.expandingSubject) {
+ this.expandingSubject = false
+ } else if (this.hideTallStatus) {
+ this.showingTall = true
+ } else if (this.hideSubjectStatus) {
+ this.expandingSubject = true
+ }
},
replyEnter (id, event) {
this.showPreview = true
@@ -167,6 +300,11 @@ const Status = {
}
}
}
+ },
+ filters: {
+ capitalize: function (str) {
+ return str.charAt(0).toUpperCase() + str.slice(1)
+ }
}
}
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index f1163fd9..067980ac 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -1,26 +1,27 @@
<template>
- <div class="status-el" :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="media container retweet-info">
- <StillImage v-if="retweet" class='avatar' :src="statusoid.user.profile_image_url_original"/>
+ <div v-if="retweet && !noHeading" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info">
+ <StillImage v-if="retweet" class='avatar' :class='{ "better-shadow": betterShadow }' :src="statusoid.user.profile_image_url_original"/>
<div class="media-body faint">
- <a :href="statusoid.user.statusnet_profile_url" style="font-weight: bold;" :title="'@'+statusoid.user.screen_name">{{retweeter}}</a>
- <i class='fa icon-retweet retweeted'></i>
+ <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>
+ <i class='fa icon-retweet retweeted' :title="$t('tool_tip.repeat')"></i>
{{$t('timeline.repeated')}}
</div>
</div>
- <div class="media status">
+ <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">
@@ -30,16 +31,17 @@
<div v-if="!noHeading" class="media-body container media-heading">
<div class="media-heading-left">
<div class="name-and-links">
- <h4 class="user-name">{{status.user.name}}</h4>
+ <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>
- <a v-if="isReply && !noReplyLinks" href="#" @click.prevent="gotoOriginal(status.in_reply_to_status_id)">
+ <a v-if="isReply && !noReplyLinks" href="#" @click.prevent="gotoOriginal(status.in_reply_to_status_id)" :title="$t('tool_tip.reply')">
<i class="icon-reply" @mouseenter="replyEnter(status.in_reply_to_status_id, $event)" @mouseout="replyLeave()"></i>
</a>
</span>
@@ -52,42 +54,51 @@
</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>
- <a :href="status.external_url" target="_blank" v-if="!status.is_local" class="source_url"><i class="icon-link-ext"></i></a>
+ <div class="visibility-icon" v-if="status.visibility">
+ <i :class="visibilityIcon(status.visibility)" :title="status.visibility | capitalize"></i>
+ </div>
+ <a :href="status.external_url" target="_blank" v-if="!status.is_local" class="source_url" title="Source">
+ <i class="icon-link-ext-alt"></i>
+ </a>
<template v-if="expandable">
- <a href="#" @click.prevent="toggleExpanded"><i class="icon-plus-squared"></i></a>
+ <a href="#" @click.prevent="toggleExpanded" title="Expand">
+ <i class="icon-plus-squared"></i>
+ </a>
</template>
<a href="#" @click.prevent="toggleMute" v-if="unmuted"><i class="icon-eye-off"></i></a>
</div>
</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>
</div>
<div :class="{'tall-status': hideTallStatus}" class="status-content-wrapper">
- <a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="hideTallStatus" href="#" @click.prevent="toggleShowTall">Show more</a>
- <div @click.prevent="linkClicked" class="status-content media-body" v-html="status.statusnet_html"></div>
- <a v-if="showingTall" href="#" class="tall-status-unhider" @click.prevent="toggleShowTall">Show less</a>
+ <a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="hideTallStatus" href="#" @click.prevent="toggleShowMore">Show more</a>
+ <div @click.prevent="linkClicked" class="status-content media-body" v-html="status.statusnet_html" v-if="!hideSubjectStatus"></div>
+ <div @click.prevent="linkClicked" class="status-content media-body" v-html="status.summary" v-else></div>
+ <a v-if="hideSubjectStatus" href="#" class="cw-status-hider" @click.prevent="toggleShowMore">Show more</a>
+ <a v-if="showingMore" href="#" class="status-unhider" @click.prevent="toggleShowMore">Show less</a>
</div>
- <div v-if='status.attachments' class='attachments media-body'>
- <attachment :size="attachmentSize" :status-id="status.id" :nsfw="status.nsfw" :attachment="attachment" v-for="attachment in status.attachments" :key="attachment.id">
+ <div v-if='status.attachments && !hideSubjectStatus' class='attachments media-body'>
+ <attachment :size="attachmentSize" :status-id="status.id" :nsfw="nsfwClickthrough" :attachment="attachment" v-for="attachment in status.attachments" :key="attachment.id">
</attachment>
</div>
<div v-if="!noHeading && !noReplyLinks" class='status-actions media-body'>
<div v-if="loggedIn">
- <a href="#" v-on:click.prevent="toggleReplying">
+ <a href="#" v-on:click.prevent="toggleReplying" :title="$t('tool_tip.reply')">
<i class="icon-reply" :class="{'icon-reply-active': replying}"></i>
</a>
</div>
- <retweet-button :loggedIn='loggedIn' :status='status'></retweet-button>
+ <retweet-button :visibility='status.visibility' :loggedIn='loggedIn' :status='status'></retweet-button>
<favorite-button :loggedIn='loggedIn' :status='status'></favorite-button>
<delete-button :status='status'></delete-button>
</div>
@@ -95,7 +106,7 @@
</div>
<div class="container" v-if="replying">
<div class="reply-left"/>
- <post-status-form class="reply-body" :reply-to="status.id" :attentions="status.attentions" :repliedUser="status.user" v-on:posted="toggleReplying"/>
+ <post-status-form class="reply-body" :reply-to="status.id" :attentions="status.attentions" :repliedUser="status.user" :copy-message-scope="status.visibility" :subject="replySubject" v-on:posted="toggleReplying"/>
</div>
</template>
</div>
@@ -135,9 +146,11 @@
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;
+
.status {
flex: 1;
border: 0;
@@ -152,6 +165,7 @@
text-align: center;
border-width: 1px;
border-style: solid;
+
i {
font-size: 2em;
}
@@ -165,8 +179,6 @@
border-left-width: 0px;
line-height: 18px;
min-width: 0;
- background-color: $fallback--bg;
- background-color: var(--bg, $fallback--bg);
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
@@ -189,8 +201,13 @@
margin: 0 0 0.25em 0.8em;
}
+ .usercard {
+ margin-bottom: .7em
+ }
+
.media-heading {
flex-wrap: nowrap;
+ line-height: 18px;
}
.media-heading-left {
@@ -213,12 +230,22 @@
flex: 1 0;
display: flex;
flex-wrap: wrap;
- align-content: center;
+ align-items: baseline;
+
+ .user-name {
+ margin-right: .45em;
+
+ img {
+ width: 14px;
+ height: 14px;
+ vertical-align: middle;
+ object-fit: contain
+ }
+ }
}
+
.links {
display: flex;
- padding-top: 1px;
- margin-left: 0.2em;
font-size: 12px;
color: $fallback--link;
color: var(--link, $fallback--link);
@@ -242,19 +269,25 @@
}
.media-heading-right {
+ display: inline-flex;
flex-shrink: 0;
- display: flex;
flex-wrap: nowrap;
- max-height: 1.5em;
- margin-left: 0.25em;
+ margin-left: .25em;
+ align-self: baseline;
+
.timeago {
margin-right: 0.2em;
font-size: 12px;
- padding-top: 1px;
+ align-self: last baseline;
}
- i {
+
+ > * {
margin-left: 0.2em;
}
+ a:hover i {
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ }
}
a {
@@ -284,13 +317,15 @@
}
}
- .tall-status-unhider {
+ .status-unhider, .cw-status-hider {
width: 100%;
text-align: center;
}
.status-content {
margin-right: 0.5em;
+ font-family: var(--postFont, sans-serif);
+
img, video {
max-width: 100%;
max-height: 400px;
@@ -303,16 +338,45 @@
font-style: italic;
}
+ pre {
+ overflow: auto;
+ }
+
+ code, samp, kbd, var, pre {
+ font-family: var(--postCodeFont, monospace);
+ }
+
p {
margin: 0;
margin-top: 0.2em;
margin-bottom: 0.5em;
}
+
+ h1 {
+ font-size: 1.1em;
+ line-height: 1.2em;
+ margin: 1.4em 0;
+ }
+
+ h2 {
+ font-size: 1.1em;
+ margin: 1.0em 0;
+ }
+
+ h3 {
+ font-size: 1em;
+ margin: 1.2em 0;
+ }
+
+ h4 {
+ margin: 1.1em 0;
+ }
}
.retweet-info {
padding: 0.4em 0.6em 0 0.6em;
- margin: 0 0 -0.5em 0;
+ margin: 0;
+
.avatar {
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
@@ -328,9 +392,22 @@
display: flex;
align-content: center;
flex-wrap: wrap;
+
+ .user-name {
+ font-weight: bold;
+
+ img {
+ width: 14px;
+ height: 14px;
+ vertical-align: middle;
+ object-fit: contain
+ }
+ }
+
i {
padding: 0 0.2em;
}
+
a {
max-width: 100%;
overflow: hidden;
@@ -387,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%;
@@ -424,6 +513,9 @@
.status {
display: flex;
padding: 0.6em;
+ &.is-retweet {
+ padding-top: 0.1em;
+ }
}
.status-conversation:last-child {
@@ -459,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/still-image/still-image.js b/src/components/still-image/still-image.js
index 0839aca5..5ad06dc2 100644
--- a/src/components/still-image/still-image.js
+++ b/src/components/still-image/still-image.js
@@ -18,7 +18,11 @@ const StillImage = {
onLoad () {
const canvas = this.$refs.canvas
if (!canvas) return
- canvas.getContext('2d').drawImage(this.$refs.src, 1, 1, canvas.width, canvas.height)
+ const width = this.$refs.src.naturalWidth
+ const height = this.$refs.src.naturalHeight
+ canvas.width = width
+ canvas.height = height
+ canvas.getContext('2d').drawImage(this.$refs.src, 0, 0, width, height)
}
}
}
diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue
index a37c678d..1dcb7ce6 100644
--- a/src/components/still-image/still-image.vue
+++ b/src/components/still-image/still-image.vue
@@ -23,6 +23,7 @@
img {
width: 100%;
height: 100%;
+ object-fit: contain;
}
&.animated {
@@ -60,6 +61,7 @@
right: 0;
width: 100%;
height: 100%;
+ object-fit: contain;
}
}
</style>
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 08bc7113..6a4e1cba 100644
--- a/src/components/style_switcher/style_switcher.js
+++ b/src/components/style_switcher/style_switcher.js
@@ -1,19 +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,
- 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: '',
@@ -24,86 +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.bgColorLocal = rgbstr2hex(this.$store.state.config.colors.bg)
- this.btnColorLocal = rgbstr2hex(this.$store.state.config.colors.btn)
- this.textColorLocal = rgbstr2hex(this.$store.state.config.colors.fg)
- this.linkColorLocal = rgbstr2hex(this.$store.state.config.colors.link)
-
- this.redColorLocal = rgbstr2hex(this.$store.state.config.colors.cRed)
- this.blueColorLocal = rgbstr2hex(this.$store.state.config.colors.cBlue)
- this.greenColorLocal = rgbstr2hex(this.$store.state.config.colors.cGreen)
- this.orangeColorLocal = rgbstr2hex(this.$store.state.config.colors.cOrange)
-
- this.btnRadiusLocal = this.$store.state.config.radii.btnRadius || 4
- this.panelRadiusLocal = this.$store.state.config.radii.panelRadius || 10
- this.avatarRadiusLocal = this.$store.state.config.radii.avatarRadius || 5
- this.avatarAltRadiusLocal = this.$store.state.config.radii.avatarAltRadius || 50
- this.tooltipRadiusLocal = this.$store.state.config.radii.tooltipRadius || 2
- this.attachmentRadiusLocal = this.$store.state.config.radii.attachmentRadius || 5
+ this.normalizeLocalState(this.$store.state.config.customTheme)
+ if (typeof this.shadowSelected === 'undefined') {
+ this.shadowSelected = this.shadowsAvailable[0]
+ }
+ },
+ 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: 2, theme
+ }
+ }
+ },
+ components: {
+ ColorInput,
+ OpacityInput,
+ RangeInput,
+ ContrastRatio,
+ ShadowControl,
+ FontControl,
+ TabSwitcher,
+ Preview,
+ ExportImport
},
methods: {
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,
- panelRadius: this.panelRadiusLocal,
- avatarRadius: this.avatarRadiusLocal,
- avatarAltRadius: this.avatarAltRadiusLocal,
- tooltipRadius: this.tooltipRadiusLocal,
- attachmentRadius: this.attachmentRadiusLocal
- }})
+ 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)
+ },
+
+ // 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)
+ })
+ },
+
+ 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 9c39b245..84963c81 100644
--- a/src/components/style_switcher/style_switcher.vue
+++ b/src/components/style_switcher/style_switcher.vue
@@ -1,231 +1,276 @@
<template>
- <div>
- <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[0]}}</option>
- </select>
- <i class="icon-down-open"/>
- </label>
+<div class="style-switcher">
+ <div class="presets-container">
+ <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="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 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 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="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="preview-container">
+ <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 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 :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-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 :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>
- <div :style="{
- '--btnRadius': btnRadiusLocal + '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 :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>
- <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>
+ <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>
- <button class="btn" @click="setCustomTheme">{{$t('general.apply')}}</button>
+
+ <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" :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;
-}
-
-.radius-container,
-.color-container {
- display: flex;
-
- p {
- margin-top: 2em;
- margin-bottom: .5em;
- }
-}
-.radius-container {
- flex-direction: column;
-}
-
-.color-container {
- flex-wrap: wrap;
- justify-content: space-between;
-}
-
-.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;
-}
-
-.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
new file mode 100644
index 00000000..9e3dee04
--- /dev/null
+++ b/src/components/tab_switcher/tab_switcher.jsx
@@ -0,0 +1,47 @@
+import Vue from 'vue'
+
+import './tab_switcher.scss'
+
+export default Vue.component('tab-switcher', {
+ name: 'TabSwitcher',
+ data () {
+ return {
+ active: 0
+ }
+ },
+ methods: {
+ activateTab(index) {
+ return () => this.active = index;
+ }
+ },
+ render(h) {
+ const tabs = this.$slots.default
+ .filter(slot => slot.data)
+ .map((slot, index) => {
+ const classes = ['tab']
+
+ if (index === this.active) {
+ classes.push('active')
+ }
+ return (<button onClick={this.activateTab(index)} class={ classes.join(' ') }>{slot.data.attrs.label}</button>)
+ });
+ 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">
+ {tabs}
+ </div>
+ <div class="contents">
+ {contents}
+ </div>
+ </div>
+ )
+ }
+})
diff --git a/src/components/tab_switcher/tab_switcher.scss b/src/components/tab_switcher/tab_switcher.scss
new file mode 100644
index 00000000..fbd3321b
--- /dev/null
+++ b/src/components/tab_switcher/tab_switcher.scss
@@ -0,0 +1,65 @@
+@import '../../_variables.scss';
+
+.tab-switcher {
+ .contents {
+ .hidden {
+ display: none;
+ }
+ }
+ .tabs {
+ display: flex;
+ position: relative;
+ justify-content: center;
+ width: 100%;
+ overflow-y: hidden;
+ overflow-x: auto;
+ padding-top: 5px;
+ height: 32px;
+ box-sizing: border-box;
+
+ &::after, &::before {
+ display: block;
+ content: '';
+ flex: 1 1 auto;
+ }
+
+ .tab, &::after, &::before {
+ border-bottom: 1px solid;
+ 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: 5px 1em 99px;
+ white-space: nowrap;
+
+ &:not(.active) {
+ 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 {
+ background: transparent;
+ border-bottom: none;
+ z-index: 5;
+ }
+ }
+ }
+}
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 74ab85d3..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: [
@@ -13,7 +14,8 @@ const Timeline = {
],
data () {
return {
- paused: false
+ paused: false,
+ unfocused: false
}
},
computed: {
@@ -65,8 +67,15 @@ const Timeline = {
this.fetchFollowers()
}
},
+ mounted () {
+ if (typeof document.hidden !== 'undefined') {
+ document.addEventListener('visibilitychange', this.handleVisibilityChange, false)
+ this.unfocused = document.hidden
+ }
+ },
destroyed () {
window.removeEventListener('scroll', this.scrollLoad)
+ if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false)
this.$store.commit('setLoading', { timeline: this.timelineName, value: false })
},
methods: {
@@ -80,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 })
@@ -93,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 })
@@ -105,13 +114,17 @@ const Timeline = {
.then((friends) => this.$store.dispatch('addFriends', { friends }))
},
scrollLoad (e) {
- const height = Math.max(document.body.offsetHeight, document.body.scrollHeight)
+ const bodyBRect = document.body.getBoundingClientRect()
+ const height = Math.max(bodyBRect.height, -(bodyBRect.y))
if (this.timeline.loading === false &&
this.$store.state.config.autoLoad &&
this.$el.offsetHeight > 0 &&
(window.innerHeight + window.pageYOffset) >= (height - 750)) {
this.fetchOlderStatuses()
}
+ },
+ handleVisibilityChange () {
+ this.unfocused = document.hidden
}
},
watch: {
@@ -121,7 +134,10 @@ const Timeline = {
}
if (count > 0) {
// only 'stream' them when you're scrolled to the top
- if (window.pageYOffset < 15 && !this.paused) {
+ if (window.pageYOffset < 15 &&
+ !this.paused &&
+ !(this.unfocused && this.$store.state.config.pauseOnUnfocused)
+ ) {
this.showNewStatuses()
} else {
this.paused = true
diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue
index c4e0fbce..bc7f74c2 100644
--- a/src/components/timeline/timeline.vue
+++ b/src/components/timeline/timeline.vue
@@ -4,13 +4,13 @@
<div class="title">
{{title}}
</div>
- <button @click.prevent="showNewStatuses" class="loadmore-button" v-if="timeline.newStatusCount > 0 && !timelineError">
- {{$t('timeline.show_new')}}{{newStatusCountStr}}
- </button>
<div @click.prevent class="loadmore-error alert error" v-if="timelineError">
{{$t('timeline.error_fetching')}}
</div>
- <div @click.prevent class="loadmore-text" v-if="!timeline.newStatusCount > 0 && !timelineError">
+ <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 faint" v-if="!timeline.newStatusCount > 0 && !timelineError">
{{$t('timeline.up_to_date')}}
</div>
</div>
@@ -57,53 +57,8 @@
@import '../../_variables.scss';
.timeline {
- .timeline-heading {
- position: relative;
- display: flex;
- }
-
- .title {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- max-width: 70%;
- }
-
- .loadmore-button {
- position: absolute;
- right: 0.6em;
- font-size: 14px;
-
- min-width: 6em;
- height: 1.8em;
- line-height: 100%;
- }
-
.loadmore-text {
- position: absolute;
- right: 0.6em;
- font-size: 14px;
- min-width: 6em;
- font-family: sans-serif;
- text-align: center;
- padding: 0 0.5em 0 0.5em;
- opacity: 0.8;
- background-color: transparent;
- color: $fallback--faint;
- color: var(--faint, $fallback--faint);
- }
-
- .loadmore-error {
- position: absolute;
- right: 0.6em;
- font-size: 14px;
- min-width: 6em;
- font-family: sans-serif;
- text-align: center;
- padding: 0 0.25em 0 0.25em;
- margin: 0;
- color: $fallback--fg;
- color: var(--fg, $fallback--fg);
+ opacity: 1;
}
}
@@ -116,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 a7a871c3..a019627a 100644
--- a/src/components/user_card/user_card.js
+++ b/src/components/user_card/user_card.js
@@ -3,7 +3,8 @@ import UserCardContent from '../user_card_content/user_card_content.vue'
const UserCard = {
props: [
'user',
- 'showFollows'
+ 'showFollows',
+ 'showApproval'
],
data () {
return {
@@ -16,6 +17,14 @@ const UserCard = {
methods: {
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
+ },
+ approveUser () {
+ this.$store.state.api.backendInteractor.approveUser(this.user.id)
+ this.$store.dispatch('removeFollowRequest', this.user)
+ },
+ denyUser () {
+ this.$store.state.api.backendInteractor.denyUser(this.user.id)
+ this.$store.dispatch('removeFollowRequest', this.user)
}
}
}
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index 51d6965f..5a8e5531 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -7,13 +7,25 @@
<user-card-content :user="user" :switcher="false"></user-card-content>
</div>
<div class="name-and-screen-name" v-else>
- <div :title="user.name" class="user-name">
+ <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') }}
+ </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') }}
+ {{ $t('user_card.follows_you') }}
</span>
</div>
- <a :href="user.statusnet_profile_url" target="blank"><div class="user-screen-name">@{{ user.screen_name }}</div></a>
+ <router-link class='user-screen-name' :to="{ name: 'user-profile', params: { id: user.id } }">
+ @{{user.screen_name}}
+ </router-link>
+ </div>
+ <div class="approval" v-if="showApproval">
+ <button class="btn btn-default" @click="approveUser">{{ $t('user_card.approve') }}</button>
+ <button class="btn btn-default" @click="denyUser">{{ $t('user_card.deny') }}</button>
</div>
</div>
</template>
@@ -63,16 +75,25 @@
border-radius: var(--panelRadius, $fallback--panelRadius);
border-style: solid;
border-color: $fallback--border;
- border-color: var(--border, $fallback--border);
+ border-color: var(--border, $fallback--border);
border-width: 1px;
overflow: hidden;
.panel-heading {
background: transparent;
+ flex-direction: column;
+ align-items: stretch;
}
p {
margin-bottom: 0;
}
}
+
+.approval {
+ button {
+ width: 100%;
+ margin-bottom: 0.5em;
+ }
+}
</style>
diff --git a/src/components/user_card_content/user_card_content.js b/src/components/user_card_content/user_card_content.js
index 1e8c91de..e7f19953 100644
--- a/src/components/user_card_content/user_card_content.js
+++ b/src/components/user_card_content/user_card_content.js
@@ -2,16 +2,30 @@ import StillImage from '../still-image/still-image.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js'
export default {
- props: [ 'user', 'switcher', '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,
+ 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)
- console.log(rgb)
+ const rgb = (typeof color === 'string') ? hex2rgb(color) : color
+ const tintColor = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .5)`
return {
- backgroundColor: `rgb(${Math.floor(rgb[0] * 0.53)}, ${Math.floor(rgb[1] * 0.56)}, ${Math.floor(rgb[2] * 0.59)})`,
- backgroundImage: `url(${this.user.cover_photo})`
+ 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})`,
+ `url(${this.user.cover_photo})`
+ ].join(', ')
}
}
},
@@ -29,6 +43,29 @@ export default {
dailyAvg () {
const days = Math.ceil((new Date() - new Date(this.user.created_at)) / (60 * 60 * 24 * 1000))
return Math.round(this.user.statuses_count / days)
+ },
+ userHighlightType: {
+ get () {
+ const data = this.$store.state.config.highlight[this.user.screen_name]
+ return data && data.type || 'disabled'
+ },
+ set (type) {
+ const data = this.$store.state.config.highlight[this.user.screen_name]
+ if (type !== 'disabled') {
+ this.$store.dispatch('setHighlight', { user: this.user.screen_name, color: data && data.color || '#FFFFFF', type })
+ } else {
+ this.$store.dispatch('setHighlight', { user: this.user.screen_name, color: undefined })
+ }
+ }
+ },
+ userHighlightColor: {
+ get () {
+ const data = this.$store.state.config.highlight[this.user.screen_name]
+ return data && data.color
+ },
+ set (color) {
+ this.$store.dispatch('setHighlight', { user: this.user.screen_name, color })
+ }
}
},
components: {
@@ -61,8 +98,18 @@ export default {
store.state.api.backendInteractor.setUserMute(this.user)
},
setProfileView (v) {
- const store = this.$store
- store.commit('setProfileView', { v })
+ if (this.switcher) {
+ 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 ca8428ca..18504277 100644
--- a/src/components/user_card_content/user_card_content.vue
+++ b/src/components/user_card_content/user_card_content.vue
@@ -1,98 +1,114 @@
<template>
- <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>
+<div id="heading" class="profile-panel-background" :style="headingStyle">
+ <div class="panel-heading text-center">
+ <div class='user-info'>
+ <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 @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>
- <a :href="user.statusnet_profile_url" target="_blank" style="float: right; margin-top:16px;" 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"/>
+ <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 @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>
- <div class="name-and-screen-name">
- <div :title="user.name" class='user-name'>{{user.name}}</div>
- <router-link :to="{ name: 'user-profile', params: { id: user.id } }">
- <div class='user-screen-name'>@{{user.screen_name}}</div>
- </router-link>
- </div>
</div>
- <div v-if="isOtherUser" class="user-interactions">
- <div v-if="user.follows_you && loggedIn" class="following">
- {{ $t('user_card.follows_you') }}
- </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 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>
+ <div class="user-meta">
+ <div v-if="user.follows_you && loggedIn && isOtherUser" class="following">
+ {{ $t('user_card.follows_you') }}
+ </div>
+ <div class="floater" v-if="switcher || isOtherUser">
+ <!-- id's need to be unique, otherwise vue confuses which user-card checkbox belongs to -->
+ <input class="userHighlightText" type="text" :id="'userHighlightColorTx'+user.id" v-if="userHighlightType !== 'disabled'" v-model="userHighlightColor"/>
+ <input class="userHighlightCl" type="color" :id="'userHighlightColor'+user.id" v-if="userHighlightType !== 'disabled'" v-model="userHighlightColor"/>
+ <label for="style-switcher" class='userHighlightSel select'>
+ <select class="userHighlightSel" :id="'userHighlightSel'+user.id" v-model="userHighlightType">
+ <option value="disabled">No highlight</option>
+ <option value="solid">Solid bg</option>
+ <option value="striped">Striped bg</option>
+ <option value="side">Side stripe</option>
+ </select>
+ <i class="icon-down-open"/>
+ </label>
</div>
</div>
- </div>
- <div class="panel-body profile-panel-body">
- <div class="user-counts">
- <div class="user-count">
- <a href="#" v-on:click.prevent="setProfileView('statuses')" v-if="switcher"><h5>{{ $t('user_card.statuses') }}</h5></a>
- <h5 v-else>{{ $t('user_card.statuses') }}</h5>
- <span>{{user.statuses_count}} <br><span class="dailyAvg">{{dailyAvg}} {{ $t('user_card.per_day') }}</span></span>
+ <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="user-count">
- <a href="#" v-on:click.prevent="setProfileView('friends')" v-if="switcher"><h5>{{ $t('user_card.followees') }}</h5></a>
- <h5 v-else>{{ $t('user_card.followees') }}</h5>
- <span>{{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">
- <a href="#" v-on:click.prevent="setProfileView('followers')" v-if="switcher"><h5>{{ $t('user_card.followers') }}</h5></a>
- <h5 v-else>{{ $t('user_card.followers') }}</h5>
- <span>{{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}}</p>
</div>
</div>
+ <div class="panel-body profile-panel-body" v-if="switcher">
+ <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>
@@ -104,31 +120,34 @@
background-size: cover;
border-radius: $fallback--panelRadius;
border-radius: var(--panelRadius, $fallback--panelRadius);
+ overflow: hidden;
.panel-heading {
padding: 0.6em 0em;
text-align: center;
+ box-shadow: none;
}
}
.profile-panel-body {
- top: -0em;
- padding-top: 4em;
word-wrap: break-word;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), $fallback--bg 80%);
- background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--bg, $fallback--bg) 80%)
+ background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--bg, $fallback--bg) 80%);
+
+ .profile-bio {
+ text-align: center;
+ }
}
.user-info {
- color: white;
- padding: 0 16px 16px 16px;
- margin-bottom: -4em;
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
+ padding: 0 16px;
.container {
- padding: 16px 10px 4px 10px;
+ padding: 16px 10px 6px 10px;
display: flex;
max-height: 56px;
- overflow: hidden;
.avatar {
border-radius: $fallback--avatarRadius;
@@ -137,8 +156,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;
}
@@ -154,10 +179,9 @@
}
}
- text-shadow: 0px 1px 1.5px rgba(0, 0, 0, 1.0);
-
.usersettings {
- color: #fff;
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
opacity: .8;
}
@@ -168,21 +192,53 @@
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{
- color: white;
text-overflow: ellipsis;
overflow: hidden;
}
.user-screen-name {
- color: white;
- font-weight: lighter;
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
+ display: inline-block;
+ font-weight: light;
font-size: 15px;
padding-right: 0.1em;
}
+ .user-meta {
+ margin-bottom: .4em;
+
+ .following {
+ font-size: 14px;
+ flex: 0 0 100%;
+ margin: 0;
+ padding-left: 16px;
+ text-align: left;
+ float: left;
+ }
+ .floater {
+ margin: 0;
+ }
+
+ &::after {
+ display: block;
+ content: '';
+ clear: both;
+ }
+ }
.user-interactions {
display: flex;
flex-flow: row wrap;
@@ -191,17 +247,6 @@
div {
flex: 1;
}
- margin-top: 0.7em;
- margin-bottom: -1.0em;
-
- .following {
- color: white;
- font-size: 14px;
- flex: 0 0 100%;
- margin: -0.7em 0.0em 0.3em 0.0em;
- padding-left: 16px;
- text-align: left;
- }
.mute {
max-width: 220px;
@@ -238,12 +283,37 @@
.user-counts {
display: flex;
line-height:16px;
- padding: 1em 1.5em 0em 1em;
+ padding: .5em 1.5em 0em 1.5em;
text-align: center;
+ justify-content: space-between;
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
+
+ &.clickable {
+ .user-count {
+ cursor: pointer;
+
+ &:hover:not(.selected) {
+ transition: border-bottom 100ms;
+ border-bottom: 3px solid $fallback--link;
+ border-bottom: 3px solid var(--link, $fallback--link);
+ }
+ }
+ }
}
.user-count {
flex: 1;
+ padding: .5em 0 .5em 0;
+ margin: 0 .5em;
+
+ &.selected {
+ transition: none;
+ border-bottom: 5px solid $fallback--link;
+ border-bottom: 5px solid var(--link, $fallback--link);
+ border-radius: $fallback--btnRadius;
+ border-radius: var(--btnRadius, $fallback--btnRadius);
+ }
h5 {
font-size:1em;
@@ -256,7 +326,37 @@
}
.dailyAvg {
- font-size: 0.8em;
- opacity: 0.5;
+ margin-left: 1em;
+ font-size: 0.7em;
+ color: #CCC;
+}
+.floater {
+ float: right;
+ margin-top: 16px;
+
+ .userHighlightCl {
+ padding: 2px 10px;
+ }
+ .userHighlightSel,
+ .userHighlightSel.select {
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+ .userHighlightSel.select i {
+ line-height: 22px;
+ }
+
+ .userHighlightText {
+ width: 70px;
+ }
+
+ .userHighlightCl,
+ .userHighlightText,
+ .userHighlightSel,
+ .userHighlightSel.select {
+ height: 22px;
+ vertical-align: top;
+ margin-right: 0
+ }
}
</style>
diff --git a/src/components/user_finder/user_finder.js b/src/components/user_finder/user_finder.js
index a743b5f6..74f79d1b 100644
--- a/src/components/user_finder/user_finder.js
+++ b/src/components/user_finder/user_finder.js
@@ -7,25 +7,10 @@ const UserFinder = {
}),
methods: {
findUser (username) {
- username = username[0] === '@' ? username.slice(1) : username
- this.loading = true
- this.$store.state.api.backendInteractor.externalProfile(username)
- .then((user) => {
- this.loading = false
- this.hidden = true
- if (!user.error) {
- this.$store.commit('addNewUsers', [user])
- this.$router.push({name: 'user-profile', params: {id: user.id}})
- } else {
- this.error = true
- }
- })
+ this.$router.push({ name: 'user-search', query: { query: username } })
},
toggleHidden () {
this.hidden = !this.hidden
- },
- dismissError () {
- this.error = false
}
}
}
diff --git a/src/components/user_finder/user_finder.vue b/src/components/user_finder/user_finder.vue
index 69bd1d21..8786f6c7 100644
--- a/src/components/user_finder/user_finder.vue
+++ b/src/components/user_finder/user_finder.vue
@@ -1,11 +1,7 @@
<template>
<span class="user-finder-container">
- <span class="alert error" v-if="error">
- <i class="icon-cancel user-finder-icon" @click="dismissError"/>
- {{$t('finder.error_fetching_user')}}
- </span>
<i class="icon-spin4 user-finder-icon animate-spin-slow" v-if="loading" />
- <a href="#" v-if="hidden"><i class="icon-user-plus user-finder-icon" @click.prevent.stop="toggleHidden"/></a>
+ <a href="#" v-if="hidden" :title="$t('finder.find_user')" ><i class="icon-user-plus user-finder-icon" @click.prevent.stop="toggleHidden" /></a>
<span v-else>
<input class="user-finder-input" @keyup.enter="findUser(username)" v-model="username" :placeholder="$t('finder.find_user')" id="user-finder-input" type="text"/>
<i class="icon-cancel user-finder-icon" @click.prevent.stop="toggleHidden"/>
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 3d4f873d..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>
@@ -14,8 +14,10 @@
<style lang="scss">
.user-panel {
- .profile-panel-background .panel-heading {
- background: transparent;
- }
+ .profile-panel-background .panel-heading {
+ background: transparent;
+ flex-direction: column;
+ align-items: stretch;
+ }
}
</style>
diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index 26be1801..1d79713d 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -27,6 +27,7 @@ const UserProfile = {
},
watch: {
userId () {
+ this.$store.dispatch('stopFetching', 'user')
this.$store.commit('clearTimeline', { timeline: 'user' })
this.$store.dispatch('startFetching', ['user', this.userId])
}
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index 838a43ab..4d2853a6 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -1,7 +1,17 @@
<template>
<div>
<div v-if="user" class="user-profile panel panel-default">
- <user-card-content :user="user" :switcher="true"></user-card-content>
+ <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>
@@ -17,6 +27,16 @@
padding-bottom: 10px;
.panel-heading {
background: transparent;
+ flex-direction: column;
+ 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_search/user_search.js b/src/components/user_search/user_search.js
new file mode 100644
index 00000000..1e488f0c
--- /dev/null
+++ b/src/components/user_search/user_search.js
@@ -0,0 +1,33 @@
+import UserCard from '../user_card/user_card.vue'
+import userSearchApi from '../../services/new_api/user_search.js'
+const userSearch = {
+ components: {
+ UserCard
+ },
+ props: [
+ 'query'
+ ],
+ data () {
+ return {
+ users: []
+ }
+ },
+ mounted () {
+ this.search(this.query)
+ },
+ watch: {
+ query (newV) {
+ this.search(newV)
+ }
+ },
+ methods: {
+ search (query) {
+ userSearchApi.search({query, store: this.$store})
+ .then((res) => {
+ this.users = res
+ })
+ }
+ }
+}
+
+export default userSearch
diff --git a/src/components/user_search/user_search.vue b/src/components/user_search/user_search.vue
new file mode 100644
index 00000000..20ae84fc
--- /dev/null
+++ b/src/components/user_search/user_search.vue
@@ -0,0 +1,12 @@
+<template>
+ <div class="user-search panel panel-default">
+ <div class="panel-heading">
+ {{$t('nav.user_search')}}
+ </div>
+ <div class="panel-body">
+ <user-card v-for="user in users" :key="user.id" :user="user" :showFollows="true"></user-card>
+ </div>
+ </div>
+</template>
+
+<script src="./user_search.js"></script>
diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js
index 25ee1f35..8e57894c 100644
--- a/src/components/user_settings/user_settings.js
+++ b/src/components/user_settings/user_settings.js
@@ -1,38 +1,85 @@
+import TabSwitcher from '../tab_switcher/tab_switcher.jsx'
import StyleSwitcher from '../style_switcher/style_switcher.vue'
const UserSettings = {
data () {
return {
- newname: this.$store.state.users.currentUser.name,
- newbio: this.$store.state.users.currentUser.description,
+ 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 ]
+ previews: [ null, null, null ],
+ deletingAccount: false,
+ deleteAccountConfirmPasswordInput: '',
+ deleteAccountError: false,
+ changePasswordInputs: [ '', '', '' ],
+ changedPassword: false,
+ changePasswordError: false,
+ activeTab: 'profile'
}
},
components: {
- StyleSwitcher
+ StyleSwitcher,
+ TabSwitcher
},
computed: {
user () {
return this.$store.state.users.currentUser
},
pleromaBackend () {
- return this.$store.state.config.pleromaBackend
+ return this.$store.state.instance.pleromaBackend
+ },
+ scopeOptionsEnabled () {
+ return this.$store.state.instance.scopeOptionsEnabled
+ },
+ vis () {
+ return {
+ 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
- this.$store.state.api.backendInteractor.updateProfile({params: {name, description}}).then((user) => {
- if (!user.error) {
- this.$store.commit('addNewUsers', [user])
- this.$store.commit('setCurrentUser', user)
- }
- })
+ const description = this.newBio
+ const locked = this.newLocked
+ // Backend notation.
+ /* eslint-disable camelcase */
+ 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
},
uploadFile (slot, e) {
const file = e.target.files[0]
@@ -137,6 +184,37 @@ const UserSettings = {
this.uploading[3] = false
})
},
+ /* This function takes an Array of Users
+ * and outputs a file with all the addresses for the user to download
+ */
+ exportPeople (users, filename) {
+ // Get all the friends addresses
+ var UserAddresses = users.map(function (user) {
+ // check is it's a local user
+ if (user && user.is_local) {
+ // append the instance address
+ // eslint-disable-next-line no-undef
+ user.screen_name += '@' + location.hostname
+ }
+ return user.screen_name
+ }).join('\n')
+ // Make the user download the file
+ var fileToDownload = document.createElement('a')
+ fileToDownload.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(UserAddresses))
+ fileToDownload.setAttribute('download', filename)
+ fileToDownload.style.display = 'none'
+ document.body.appendChild(fileToDownload)
+ fileToDownload.click()
+ document.body.removeChild(fileToDownload)
+ },
+ exportFollows () {
+ this.enableFollowsExport = false
+ this.$store.state.api.backendInteractor
+ .fetchFriends({id: this.$store.state.users.currentUser.id})
+ .then((friendList) => {
+ this.exportPeople(friendList, 'friends.csv')
+ })
+ },
followListChange () {
// eslint-disable-next-line no-undef
let formData = new FormData()
@@ -146,6 +224,45 @@ const UserSettings = {
dismissImported () {
this.followsImported = false
this.followImportError = false
+ },
+ confirmDelete () {
+ this.deletingAccount = true
+ },
+ deleteAccount () {
+ this.$store.state.api.backendInteractor.deleteAccount({password: this.deleteAccountConfirmPasswordInput})
+ .then((res) => {
+ if (res.status === 'success') {
+ this.$store.dispatch('logout')
+ this.$router.push('/main/all')
+ } else {
+ this.deleteAccountError = res.error
+ }
+ })
+ },
+ changePassword () {
+ const params = {
+ password: this.changePasswordInputs[0],
+ newPassword: this.changePasswordInputs[1],
+ newPasswordConfirmation: this.changePasswordInputs[2]
+ }
+ this.$store.state.api.backendInteractor.changePassword(params)
+ .then((res) => {
+ if (res.status === 'success') {
+ this.changedPassword = true
+ this.changePasswordError = false
+ this.logout()
+ } else {
+ this.changedPassword = false
+ this.changePasswordError = res.error
+ }
+ })
+ },
+ activateTab (tabName) {
+ this.activeTab = tabName
+ },
+ logout () {
+ this.$store.dispatch('logout')
+ this.$router.replace('/')
}
}
}
diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue
index ed1864cc..11629440 100644
--- a/src/components/user_settings/user_settings.vue
+++ b/src/components/user_settings/user_settings.vue
@@ -4,68 +4,139 @@
{{$t('settings.user_settings')}}
</div>
<div class="panel-body profile-edit">
- <div class="setting-item">
- <h3>{{$t('settings.name_bio')}}</h3>
- <p>{{$t('settings.name')}}</p>
- <input class='name-changer' id='username' v-model="newname"></input>
- <p>{{$t('settings.bio')}}</p>
- <textarea class="bio" v-model="newbio"></textarea>
- <button :disabled='newname.length <= 0' class="btn btn-default" @click="updateProfile">{{$t('general.submit')}}</button>
- </div>
- <div class="setting-item">
- <h3>{{$t('settings.avatar')}}</h3>
- <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>
- <div>
- <input type="file" @change="uploadFile(0, $event)" ></input>
+ <tab-switcher>
+ <div :label="$t('settings.profile_tab')">
+ <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>
+ <p>{{$t('settings.bio')}}</p>
+ <textarea class="bio" v-model="newBio"></textarea>
+ <p>
+ <input type="checkbox" v-model="newLocked" id="account-locked">
+ <label for="account-locked">{{$t('settings.lock_account_description')}}</label>
+ </p>
+ <div v-if="scopeOptionsEnabled">
+ <label for="default-vis">{{$t('settings.default_vis')}}</label>
+ <div id="default-vis" class="visibility-tray">
+ <i v-on:click="changeVis('direct')" class="icon-mail-alt" :class="vis.direct" :title="$t('post_status.scope.direct')" ></i>
+ <i v-on:click="changeVis('private')" class="icon-lock" :class="vis.private" :title="$t('post_status.scope.private')"></i>
+ <i v-on:click="changeVis('unlisted')" class="icon-lock-open-alt" :class="vis.unlisted" :title="$t('post_status.scope.unlisted')"></i>
+ <i v-on:click="changeVis('public')" class="icon-globe" :class="vis.public" :title="$t('post_status.scope.public')"></i>
+ </div>
+ </div>
+ <p>
+ <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>
+ <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>
+ <div>
+ <input type="file" @change="uploadFile(0, $event)" ></input>
+ </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>
+ <div>
+ <input type="file" @change="uploadFile(1, $event)" ></input>
+ </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>
+ <div>
+ <input type="file" @change="uploadFile(2, $event)" ></input>
+ </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>
- <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">
- <h3>{{$t('settings.profile_banner')}}</h3>
- <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>
- <div>
- <input type="file" @change="uploadFile(1, $event)" ></input>
- </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">
- <h3>{{$t('settings.profile_background')}}</h3>
- <p>{{$t('settings.set_new_profile_background')}}</p>
- <img class="bg" v-bind:src="previews[2]" v-if="previews[2]">
- </img>
- <div>
- <input type="file" @change="uploadFile(2, $event)" ></input>
- </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 class="setting-item" v-if="pleromaBackend">
- <h3>{{$t('settings.follow_import')}}</h3>
- <p>{{$t('settings.import_followers_from_a_csv_file')}}</p>
- <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>
- <button class="btn btn-default" v-else @click="importFollows">{{$t('general.submit')}}</button>
- <div v-if="followsImported">
- <i class="icon-cross" @click="dismissImported"></i>
- <p>{{$t('settings.follows_imported')}}</p>
+
+ <div :label="$t('settings.security_tab')">
+ <div class="setting-item">
+ <h2>{{$t('settings.change_password')}}</h2>
+ <div>
+ <p>{{$t('settings.current_password')}}</p>
+ <input type="password" v-model="changePasswordInputs[0]">
+ </div>
+ <div>
+ <p>{{$t('settings.new_password')}}</p>
+ <input type="password" v-model="changePasswordInputs[1]">
+ </div>
+ <div>
+ <p>{{$t('settings.confirm_new_password')}}</p>
+ <input type="password" v-model="changePasswordInputs[2]">
+ </div>
+ <button class="btn btn-default" @click="changePassword">{{$t('general.submit')}}</button>
+ <p v-if="changedPassword">{{$t('settings.changed_password')}}</p>
+ <p v-else-if="changePasswordError !== false">{{$t('settings.change_password_error')}}</p>
+ <p v-if="changePasswordError">{{changePasswordError}}</p>
+ </div>
+
+ <div class="setting-item">
+ <h2>{{$t('settings.delete_account')}}</h2>
+ <p v-if="!deletingAccount">{{$t('settings.delete_account_description')}}</p>
+ <div v-if="deletingAccount">
+ <p>{{$t('settings.delete_account_instructions')}}</p>
+ <p>{{$t('login.password')}}</p>
+ <input type="password" v-model="deleteAccountConfirmPasswordInput">
+ <button class="btn btn-default" @click="deleteAccount">{{$t('settings.delete_account')}}</button>
+ </div>
+ <p v-if="deleteAccountError !== false">{{$t('settings.delete_account_error')}}</p>
+ <p v-if="deleteAccountError">{{deleteAccountError}}</p>
+ <button class="btn btn-default" v-if="!deletingAccount" @click="confirmDelete">{{$t('general.submit')}}</button>
+ </div>
</div>
- <div v-else-if="followImportError">
- <i class="icon-cross" @click="dismissImported"</i>
- <p>{{$t('settings.follow_import_error')}}</p>
+
+ <div :label="$t('settings.data_import_export_tab')" v-if="pleromaBackend">
+ <div class="setting-item">
+ <h2>{{$t('settings.follow_import')}}</h2>
+ <p>{{$t('settings.import_followers_from_a_csv_file')}}</p>
+ <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>
+ <button class="btn btn-default" v-else @click="importFollows">{{$t('general.submit')}}</button>
+ <div v-if="followsImported">
+ <i class="icon-cross" @click="dismissImported"></i>
+ <p>{{$t('settings.follows_imported')}}</p>
+ </div>
+ <div v-else-if="followImportError">
+ <i class="icon-cross" @click="dismissImported"></i>
+ <p>{{$t('settings.follow_import_error')}}</p>
+ </div>
+ </div>
+ <div class="setting-item" v-if="enableFollowsExport">
+ <h2>{{$t('settings.follow_export')}}</h2>
+ <button class="btn btn-default" @click="exportFollows">{{$t('settings.follow_export_button')}}</button>
+ </div>
+ <div class="setting-item" v-else>
+ <h2>{{$t('settings.follow_export_processing')}}</h2>
+ </div>
</div>
- </div>
+ </tab-switcher>
</div>
</div>
</template>
@@ -81,6 +152,7 @@
input[type=file] {
padding: 5px;
+ height: auto;
}
.banner {
diff --git a/src/components/who_to_follow_panel/who_to_follow_panel.js b/src/components/who_to_follow_panel/who_to_follow_panel.js
new file mode 100644
index 00000000..49b8f5b6
--- /dev/null
+++ b/src/components/who_to_follow_panel/who_to_follow_panel.js
@@ -0,0 +1,111 @@
+import apiService from '../../services/api/api.service.js'
+
+function showWhoToFollow (panel, reply) {
+ var users = reply
+ var cn
+ var index
+ var step = 7
+ cn = Math.floor(Math.random() * step)
+ for (index = 0; index < 3; index++) {
+ var user
+ user = users[cn]
+ var img
+ if (user.avatar) {
+ img = user.avatar
+ } else {
+ img = '/images/avi.png'
+ }
+ var name = user.acct
+ if (index === 0) {
+ panel.img1 = img
+ panel.name1 = name
+ panel.$store.state.api.backendInteractor.externalProfile(name)
+ .then((externalUser) => {
+ if (!externalUser.error) {
+ panel.$store.commit('addNewUsers', [externalUser])
+ panel.id1 = externalUser.id
+ }
+ })
+ } else if (index === 1) {
+ panel.img2 = img
+ panel.name2 = name
+ panel.$store.state.api.backendInteractor.externalProfile(name)
+ .then((externalUser) => {
+ if (!externalUser.error) {
+ panel.$store.commit('addNewUsers', [externalUser])
+ panel.id2 = externalUser.id
+ }
+ })
+ } else if (index === 2) {
+ panel.img3 = img
+ panel.name3 = name
+ panel.$store.state.api.backendInteractor.externalProfile(name)
+ .then((externalUser) => {
+ if (!externalUser.error) {
+ panel.$store.commit('addNewUsers', [externalUser])
+ panel.id3 = externalUser.id
+ }
+ })
+ }
+ cn = (cn + step) % users.length
+ }
+}
+
+function getWhoToFollow (panel) {
+ var credentials = panel.$store.state.users.currentUser.credentials
+ if (credentials) {
+ panel.name1 = 'Loading...'
+ panel.name2 = 'Loading...'
+ panel.name3 = 'Loading...'
+ apiService.suggestions({credentials: credentials})
+ .then((reply) => {
+ showWhoToFollow(panel, reply)
+ })
+ }
+}
+
+const WhoToFollowPanel = {
+ data: () => ({
+ img1: '/images/avi.png',
+ name1: '',
+ id1: 0,
+ img2: '/images/avi.png',
+ name2: '',
+ id2: 0,
+ img3: '/images/avi.png',
+ name3: '',
+ id3: 0
+ }),
+ computed: {
+ user: function () {
+ return this.$store.state.users.currentUser.screen_name
+ },
+ moreUrl: function () {
+ var host = window.location.hostname
+ var user = this.user
+ var suggestionsWeb = this.$store.state.instance.suggestionsWeb
+ var url
+ url = suggestionsWeb.replace(/{{host}}/g, encodeURIComponent(host))
+ url = url.replace(/{{user}}/g, encodeURIComponent(user))
+ return url
+ },
+ suggestionsEnabled () {
+ return this.$store.state.instance.suggestionsEnabled
+ }
+ },
+ watch: {
+ user: function (user, oldUser) {
+ if (this.suggestionsEnabled) {
+ getWhoToFollow(this)
+ }
+ }
+ },
+ mounted:
+ function () {
+ if (this.suggestionsEnabled) {
+ getWhoToFollow(this)
+ }
+ }
+}
+
+export default WhoToFollowPanel
diff --git a/src/components/who_to_follow_panel/who_to_follow_panel.vue b/src/components/who_to_follow_panel/who_to_follow_panel.vue
new file mode 100644
index 00000000..d031318d
--- /dev/null
+++ b/src/components/who_to_follow_panel/who_to_follow_panel.vue
@@ -0,0 +1,37 @@
+<template>
+ <div class="who-to-follow-panel">
+ <div class="panel panel-default base01-background">
+ <div class="panel-heading timeline-heading base02-background base04">
+ <div class="title">
+ {{$t('who_to_follow.who_to_follow')}}
+ </div>
+ </div>
+ <div class="panel-body who-to-follow">
+ <p>
+ <img v-bind:src="img1"/> <router-link :to="{ name: 'user-profile', params: { id: id1 } }">{{ name1 }}</router-link><br>
+ <img v-bind:src="img2"/> <router-link :to="{ name: 'user-profile', params: { id: id2 } }">{{ name2 }}</router-link><br>
+ <img v-bind:src="img3"/> <router-link :to="{ name: 'user-profile', params: { id: id3 } }">{{ name3 }}</router-link><br>
+ <img v-bind:src="$store.state.instance.logo"> <a v-bind:href="moreUrl" target="_blank">{{$t('who_to_follow.more')}}</a>
+ </p>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script src="./who_to_follow_panel.js" ></script>
+
+<style lang="scss">
+ .who-to-follow * {
+ vertical-align: middle;
+ }
+ .who-to-follow img {
+ width: 32px;
+ height: 32px;
+ }
+ .who-to-follow p {
+ line-height: 40px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+</style>