diff options
Diffstat (limited to 'src/components')
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> |
