diff options
Diffstat (limited to 'src/components')
132 files changed, 6846 insertions, 2776 deletions
diff --git a/src/components/about/about.vue b/src/components/about/about.vue index 13dec87c..62ae16ea 100644 --- a/src/components/about/about.vue +++ b/src/components/about/about.vue @@ -1,8 +1,8 @@ <template> <div class="sidebar"> - <instance-specific-panel></instance-specific-panel> - <features-panel v-if="showFeaturesPanel"></features-panel> - <terms-of-service-panel></terms-of-service-panel> + <instance-specific-panel /> + <features-panel v-if="showFeaturesPanel" /> + <terms-of-service-panel /> </div> </template> diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js index 3b7f08dc..e93921fe 100644 --- a/src/components/attachment/attachment.js +++ b/src/components/attachment/attachment.js @@ -51,7 +51,7 @@ const Attachment = { } }, methods: { - linkClicked ({target}) { + linkClicked ({ target }) { if (target.tagName === 'A') { window.open(target.href, '_blank') } diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue index c58bebd3..ec326c45 100644 --- a/src/components/attachment/attachment.vue +++ b/src/components/attachment/attachment.vue @@ -1,54 +1,106 @@ <template> - <div v-if="usePlaceHolder" @click="openModal"> - <a class="placeholder" + <div + v-if="usePlaceHolder" + @click="openModal" + > + <a v-if="type !== 'html'" - target="_blank" :href="attachment.url" + class="placeholder" + target="_blank" + :href="attachment.url" > - [{{nsfw ? "NSFW/" : ""}}{{type.toUpperCase()}}] + [{{ nsfw ? "NSFW/" : "" }}{{ type.toUpperCase() }}] </a> </div> <div - v-else class="attachment" - :class="{[type]: true, loading, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}" + v-else v-show="!isEmpty" + class="attachment" + :class="{[type]: true, loading, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}" > - <a class="image-attachment" v-if="hidden" :href="attachment.url" @click.prevent="toggleHidden"> - <img class="nsfw" :key="nsfwImage" :src="nsfwImage" :class="{'small': isSmall}"/> - <i v-if="type === 'video'" class="play-icon icon-play-circled"></i> + <a + v-if="hidden" + class="image-attachment" + :href="attachment.url" + @click.prevent="toggleHidden" + > + <img + :key="nsfwImage" + class="nsfw" + :src="nsfwImage" + :class="{'small': isSmall}" + > + <i + v-if="type === 'video'" + class="play-icon icon-play-circled" + /> </a> - <div class="hider" v-if="nsfw && hideNsfwLocal && !hidden"> - <a href="#" @click.prevent="toggleHidden">Hide</a> + <div + v-if="nsfw && hideNsfwLocal && !hidden" + class="hider" + > + <a + href="#" + @click.prevent="toggleHidden" + >Hide</a> </div> - <a v-if="type === 'image' && (!hidden || preloadImage)" - @click="openModal" + <a + v-if="type === 'image' && (!hidden || preloadImage)" class="image-attachment" :class="{'hidden': hidden && preloadImage }" - :href="attachment.url" target="_blank" + :href="attachment.url" + target="_blank" :title="attachment.description" + @click="openModal" > - <StillImage :referrerpolicy="referrerpolicy" :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url"/> + <StillImage + :referrerpolicy="referrerpolicy" + :mimetype="attachment.mimetype" + :src="attachment.large_thumb_url || attachment.url" + /> </a> - <a class="video-container" - @click="openModal" + <a v-if="type === 'video' && !hidden" + class="video-container" :class="{'small': isSmall}" :href="allowPlay ? undefined : attachment.url" + @click="openModal" > - <VideoAttachment class="video" :attachment="attachment" :controls="allowPlay" /> - <i v-if="!allowPlay" class="play-icon icon-play-circled"></i> + <VideoAttachment + class="video" + :attachment="attachment" + :controls="allowPlay" + /> + <i + v-if="!allowPlay" + class="play-icon icon-play-circled" + /> </a> - <audio v-if="type === 'audio'" :src="attachment.url" controls></audio> + <audio + v-if="type === 'audio'" + :src="attachment.url" + controls + /> - <div @click.prevent="linkClicked" v-if="type === 'html' && attachment.oembed" class="oembed"> - <div v-if="attachment.thumb_url" class="image"> - <img :src="attachment.thumb_url"/> + <div + v-if="type === 'html' && attachment.oembed" + class="oembed" + @click.prevent="linkClicked" + > + <div + v-if="attachment.thumb_url" + class="image" + > + <img :src="attachment.thumb_url"> </div> <div class="text"> - <h1><a :href="attachment.url">{{attachment.oembed.title}}</a></h1> - <div v-html="attachment.oembed.oembedHTML"></div> + <!-- eslint-disable vue/no-v-html --> + <h1><a :href="attachment.url">{{ attachment.oembed.title }}</a></h1> + <div v-html="attachment.oembed.oembedHTML" /> + <!-- eslint-enable vue/no-v-html --> </div> </div> </div> @@ -68,6 +120,7 @@ max-height: 200px; max-width: 100%; display: flex; + align-items: center; video { max-width: 100%; } diff --git a/src/components/autosuggest/autosuggest.js b/src/components/autosuggest/autosuggest.js index d4efe912..f58f17bb 100644 --- a/src/components/autosuggest/autosuggest.js +++ b/src/components/autosuggest/autosuggest.js @@ -2,11 +2,11 @@ const debounceMilliseconds = 500 export default { props: { - query: { // function to query results and return a promise + query: { // function to query results and return a promise type: Function, required: true }, - filter: { // function to filter results in real time + filter: { // function to filter results in real time type: Function }, placeholder: { diff --git a/src/components/autosuggest/autosuggest.vue b/src/components/autosuggest/autosuggest.vue index 91657a2d..1f86e996 100644 --- a/src/components/autosuggest/autosuggest.vue +++ b/src/components/autosuggest/autosuggest.vue @@ -1,8 +1,22 @@ <template> - <div class="autosuggest" v-click-outside="onClickOutside"> - <input v-model="term" :placeholder="placeholder" @click="onInputClick" class="autosuggest-input" /> - <div class="autosuggest-results" v-if="resultsVisible && filtered.length > 0"> - <slot v-for="item in filtered" :item="item" /> + <div + v-click-outside="onClickOutside" + class="autosuggest" + > + <input + v-model="term" + :placeholder="placeholder" + class="autosuggest-input" + @click="onInputClick" + > + <div + v-if="resultsVisible && filtered.length > 0" + class="autosuggest-results" + > + <slot + v-for="item in filtered" + :item="item" + /> </div> </div> </template> diff --git a/src/components/avatar_list/avatar_list.vue b/src/components/avatar_list/avatar_list.vue index c0238570..e1b6e971 100644 --- a/src/components/avatar_list/avatar_list.vue +++ b/src/components/avatar_list/avatar_list.vue @@ -1,7 +1,15 @@ <template> <div class="avatars"> - <router-link :to="userProfileLink(user)" class="avatars-item" v-for="user in slicedUsers"> - <UserAvatar :user="user" class="avatar-small" /> + <router-link + v-for="user in slicedUsers" + :key="user.id" + :to="userProfileLink(user)" + class="avatars-item" + > + <UserAvatar + :user="user" + class="avatar-small" + /> </router-link> </div> </template> diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue index 634d62b3..8a02174e 100644 --- a/src/components/basic_user_card/basic_user_card.vue +++ b/src/components/basic_user_card/basic_user_card.vue @@ -7,20 +7,45 @@ @click.prevent.native="toggleUserExpanded" /> </router-link> - <div class="basic-user-card-expanded-content" v-if="userExpanded"> - <UserCard :user="user" :rounded="true" :bordered="true"/> + <div + v-if="userExpanded" + class="basic-user-card-expanded-content" + > + <UserCard + :user="user" + :rounded="true" + :bordered="true" + /> </div> - <div class="basic-user-card-collapsed-content" v-else> - <div :title="user.name" class="basic-user-card-user-name"> - <span v-if="user.name_html" class="basic-user-card-user-name-value" v-html="user.name_html"></span> - <span v-else class="basic-user-card-user-name-value">{{ user.name }}</span> + <div + v-else + class="basic-user-card-collapsed-content" + > + <div + :title="user.name" + class="basic-user-card-user-name" + > + <!-- eslint-disable vue/no-v-html --> + <span + v-if="user.name_html" + class="basic-user-card-user-name-value" + v-html="user.name_html" + /> + <!-- eslint-enable vue/no-v-html --> + <span + v-else + class="basic-user-card-user-name-value" + >{{ user.name }}</span> </div> <div> - <router-link class="basic-user-card-screen-name" :to="userProfileLink(user)"> - @{{user.screen_name}} + <router-link + class="basic-user-card-screen-name" + :to="userProfileLink(user)" + > + @{{ user.screen_name }} </router-link> </div> - <slot></slot> + <slot /> </div> </div> </template> @@ -62,6 +87,7 @@ &-expanded-content { flex: 1; margin-left: 0.7em; + min-width: 0; } } </style> diff --git a/src/components/block_card/block_card.vue b/src/components/block_card/block_card.vue index 8eb56e25..5b00b738 100644 --- a/src/components/block_card/block_card.vue +++ b/src/components/block_card/block_card.vue @@ -1,7 +1,12 @@ <template> <basic-user-card :user="user"> <div class="block-card-content-container"> - <button class="btn btn-default" @click="unblockUser" :disabled="progress" v-if="blocked"> + <button + v-if="blocked" + class="btn btn-default" + :disabled="progress" + @click="unblockUser" + > <template v-if="progress"> {{ $t('user_card.unblock_progress') }} </template> @@ -9,7 +14,12 @@ {{ $t('user_card.unblock') }} </template> </button> - <button class="btn btn-default" @click="blockUser" :disabled="progress" v-else> + <button + v-else + class="btn btn-default" + :disabled="progress" + @click="blockUser" + > <template v-if="progress"> {{ $t('user_card.block_progress') }} </template> diff --git a/src/components/chat_panel/chat_panel.js b/src/components/chat_panel/chat_panel.js index bbc9b49f..f2e3adf0 100644 --- a/src/components/chat_panel/chat_panel.js +++ b/src/components/chat_panel/chat_panel.js @@ -16,7 +16,7 @@ const chatPanel = { }, methods: { submit (message) { - this.$store.state.chat.channel.push('new_msg', {text: message}, 10000) + this.$store.state.chat.channel.push('new_msg', { text: message }, 10000) this.currentMessage = '' }, togglePanel () { diff --git a/src/components/chat_panel/chat_panel.vue b/src/components/chat_panel/chat_panel.vue index b37469ac..3677722f 100644 --- a/src/components/chat_panel/chat_panel.vue +++ b/src/components/chat_panel/chat_panel.vue @@ -1,41 +1,70 @@ <template> - <div class="chat-panel" v-if="!this.collapsed || !this.floating"> + <div + v-if="!collapsed || !floating" + class="chat-panel" + > <div class="panel panel-default"> - <div class="panel-heading timeline-heading" :class="{ 'chat-heading': floating }" @click.stop.prevent="togglePanel"> + <div + class="panel-heading timeline-heading" + :class="{ 'chat-heading': floating }" + @click.stop.prevent="togglePanel" + > <div class="title"> - <span>{{$t('chat.title')}}</span> - <i class="icon-cancel" v-if="floating"></i> + <span>{{ $t('chat.title') }}</span> + <i + v-if="floating" + class="icon-cancel" + /> </div> </div> - <div class="chat-window" v-chat-scroll> - <div class="chat-message" v-for="message in messages" :key="message.id"> + <div + v-chat-scroll + class="chat-window" + > + <div + v-for="message in messages" + :key="message.id" + class="chat-message" + > <span class="chat-avatar"> - <img :src="message.author.avatar" /> + <img :src="message.author.avatar"> </span> <div class="chat-content"> <router-link class="chat-name" - :to="userProfileLink(message.author)"> - {{message.author.username}} + :to="userProfileLink(message.author)" + > + {{ message.author.username }} </router-link> <br> <span class="chat-text"> - {{message.text}} + {{ message.text }} </span> </div> </div> </div> <div class="chat-input"> - <textarea @keyup.enter="submit(currentMessage)" v-model="currentMessage" class="chat-input-textarea" rows="1"></textarea> + <textarea + v-model="currentMessage" + class="chat-input-textarea" + rows="1" + @keyup.enter="submit(currentMessage)" + /> </div> </div> </div> - <div v-else class="chat-panel"> + <div + v-else + class="chat-panel" + > <div class="panel panel-default"> - <div class="panel-heading stub timeline-heading chat-heading" @click.stop.prevent="togglePanel"> + <div + class="panel-heading stub timeline-heading chat-heading" + @click.stop.prevent="togglePanel" + > <div class="title"> - <i class="icon-comment-empty"></i> - {{$t('chat.title')}} + <i class="icon-comment-empty" /> + {{ $t('chat.title') }} </div> </div> </div> diff --git a/src/components/checkbox/checkbox.vue b/src/components/checkbox/checkbox.vue index 4152b049..2b822ec3 100644 --- a/src/components/checkbox/checkbox.vue +++ b/src/components/checkbox/checkbox.vue @@ -1,8 +1,13 @@ <template> <label class="checkbox"> - <input type="checkbox" :checked="checked" @change="$emit('change', $event.target.checked)" :indeterminate.prop="indeterminate"> + <input + type="checkbox" + :checked="checked" + :indeterminate.prop="indeterminate" + @change="$emit('change', $event.target.checked)" + > <i class="checkbox-indicator" /> - <span v-if="!!$slots.default"><slot></slot></span> + <span v-if="!!$slots.default"><slot /></span> </label> </template> diff --git a/src/components/color_input/color_input.vue b/src/components/color_input/color_input.vue index 34eec248..9db62e81 100644 --- a/src/components/color_input/color_input.vue +++ b/src/components/color_input/color_input.vue @@ -1,33 +1,44 @@ <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)" + <div + class="color-control style-control" + :class="{ disabled: !present || disabled }" + > + <label + :for="name" + class="label" > - <input - :id="name + '-t'" - class="text-input" - type="text" - :value="value || fallback" - :disabled="!present || disabled" - @input="$emit('input', $event.target.value)" + {{ label }} + </label> + <input + v-if="typeof fallback !== 'undefined'" + :id="name + '-o'" + class="opt exlcude-disabled" + type="checkbox" + :checked="present" + @input="$emit('input', typeof value === 'undefined' ? fallback : undefined)" > -</div> + <label + v-if="typeof fallback !== 'undefined'" + class="opt-l" + :for="name + '-o'" + /> + <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> diff --git a/src/components/contrast_ratio/contrast_ratio.vue b/src/components/contrast_ratio/contrast_ratio.vue index bd971d00..15a450a2 100644 --- a/src/components/contrast_ratio/contrast_ratio.vue +++ b/src/components/contrast_ratio/contrast_ratio.vue @@ -1,28 +1,38 @@ <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 + 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 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 + v-if="contrast && large" + class="rating" + :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> -</span> </template> <script> diff --git a/src/components/conversation-page/conversation-page.vue b/src/components/conversation-page/conversation-page.vue index 9e322cf5..532f785c 100644 --- a/src/components/conversation-page/conversation-page.vue +++ b/src/components/conversation-page/conversation-page.vue @@ -1,9 +1,9 @@ <template> <conversation :collapsable="false" - isPage="true" + is-page="true" :statusoid="statusoid" - ></conversation> + /> </template> <script src="./conversation-page.js"></script> diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index b3074590..49fa8612 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -42,7 +42,7 @@ const conversation = { 'statusoid', 'collapsable', 'isPage', - 'showPinned' + 'pinnedStatusIdsObject' ], created () { if (this.isPage) { @@ -86,7 +86,8 @@ const conversation = { }, replies () { let i = 1 - return reduce(this.conversation, (result, {id, in_reply_to_status_id}) => { + // eslint-disable-next-line camelcase + return reduce(this.conversation, (result, { id, in_reply_to_status_id }) => { /* eslint-disable camelcase */ const irid = in_reply_to_status_id /* eslint-enable camelcase */ @@ -109,7 +110,7 @@ const conversation = { Status }, watch: { - '$route': 'fetchConversation', + status: 'fetchConversation', expanded (value) { if (value) { this.fetchConversation() @@ -119,15 +120,15 @@ const conversation = { methods: { fetchConversation () { if (this.status) { - this.$store.state.api.backendInteractor.fetchConversation({id: this.status.id}) - .then(({ancestors, descendants}) => { + this.$store.state.api.backendInteractor.fetchConversation({ id: this.status.id }) + .then(({ ancestors, descendants }) => { this.$store.dispatch('addNewStatuses', { statuses: ancestors }) this.$store.dispatch('addNewStatuses', { statuses: descendants }) }) .then(() => this.setHighlight(this.statusId)) } else { const id = this.$route.params.id - this.$store.state.api.backendInteractor.fetchStatus({id}) + this.$store.state.api.backendInteractor.fetchStatus({ id }) .then((status) => this.$store.dispatch('addNewStatuses', { statuses: [status] })) .then(() => this.fetchConversation()) } @@ -139,6 +140,7 @@ const conversation = { return (this.isExpanded) && id === this.status.id }, setHighlight (id) { + if (!id) return this.highlight = id this.$store.dispatch('fetchFavsAndRepeats', id) }, @@ -147,9 +149,6 @@ const conversation = { }, toggleExpanded () { this.expanded = !this.expanded - if (!this.expanded) { - this.setHighlight(null) - } } } } diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index 0b4998c3..f184c071 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -1,25 +1,34 @@ <template> - <div class="timeline panel-default" :class="[isExpanded ? 'panel' : 'panel-disabled']"> - <div v-if="isExpanded" class="panel-heading conversation-heading"> + <div + class="timeline panel-default" + :class="[isExpanded ? 'panel' : 'panel-disabled']" + > + <div + v-if="isExpanded" + class="panel-heading conversation-heading" + > <span class="title"> {{ $t('timeline.conversation') }} </span> <span v-if="collapsable"> - <a href="#" @click.prevent="toggleExpanded">{{ $t('timeline.collapse') }}</a> + <a + href="#" + @click.prevent="toggleExpanded" + >{{ $t('timeline.collapse') }}</a> </span> </div> <status v-for="status in conversation" - @goto="setHighlight" - @toggleExpanded="toggleExpanded" :key="status.id" - :inlineExpanded="collapsable && isExpanded" + :inline-expanded="collapsable && isExpanded" :statusoid="status" - :expandable='!isExpanded' - :showPinned="showPinned" + :expandable="!isExpanded" + :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]" :focused="focused(status.id)" - :inConversation="isExpanded" + :in-conversation="isExpanded" :highlight="getHighlight()" :replies="getReplies(status.id)" class="status-fadein panel-body" + @goto="setHighlight" + @toggleExpanded="toggleExpanded" /> </div> </template> diff --git a/src/components/dialog_modal/dialog_modal.vue b/src/components/dialog_modal/dialog_modal.vue index 3da543de..55d7a7d2 100644 --- a/src/components/dialog_modal/dialog_modal.vue +++ b/src/components/dialog_modal/dialog_modal.vue @@ -1,16 +1,22 @@ <template> - <span v-bind:class="{ 'dark-overlay': darkOverlay }" @click.self.stop='onCancel()'> - <div class="dialog-modal panel panel-default" @click.stop=''> + <span + :class="{ 'dark-overlay': darkOverlay }" + @click.self.stop="onCancel()" + > + <div + class="dialog-modal panel panel-default" + @click.stop="" + > <div class="panel-heading dialog-modal-heading"> <div class="title"> - <slot name="header"></slot> + <slot name="header" /> </div> </div> <div class="dialog-modal-content"> - <slot name="default"></slot> + <slot name="default" /> </div> <div class="dialog-modal-footer user-interactions panel-footer"> - <slot name="footer"></slot> + <slot name="footer" /> </div> </div> </span> diff --git a/src/components/dm_timeline/dm_timeline.vue b/src/components/dm_timeline/dm_timeline.vue index f03da4d3..c4e4d070 100644 --- a/src/components/dm_timeline/dm_timeline.vue +++ b/src/components/dm_timeline/dm_timeline.vue @@ -1,5 +1,9 @@ <template> - <Timeline :title="$t('nav.dms')" v-bind:timeline="timeline" v-bind:timeline-name="'dms'"/> + <Timeline + :title="$t('nav.dms')" + :timeline="timeline" + :timeline-name="'dms'" + /> </template> <script src="./dm_timeline.js"></script> diff --git a/src/components/emoji-input/emoji-input.js b/src/components/emoji-input/emoji-input.js index a5bb6eaf..fab64a69 100644 --- a/src/components/emoji-input/emoji-input.js +++ b/src/components/emoji-input/emoji-input.js @@ -1,51 +1,122 @@ import Completion from '../../services/completion/completion.js' -import { take, filter, map } from 'lodash' +import { take } from 'lodash' + +/** + * EmojiInput - augmented inputs for emoji and autocomplete support in inputs + * without having to give up the comfort of <input/> and <textarea/> elements + * + * Intended usage is: + * <EmojiInput v-model="something"> + * <input v-model="something"/> + * </EmojiInput> + * + * Works only with <input> and <textarea>. Intended to use with only one nested + * input. It will find first input or textarea and work with that, multiple + * nested children not tested. You HAVE TO duplicate v-model for both + * <emoji-input> and <input>/<textarea> otherwise it will not work. + * + * Be prepared for CSS troubles though because it still wraps component in a div + * while TRYING to make it look like nothing happened, but it could break stuff. + */ const EmojiInput = { - props: [ - 'value', - 'placeholder', - 'type', - 'classname' - ], + props: { + suggest: { + /** + * suggest: function (input: String) => Suggestion[] + * + * Function that takes input string which takes string (textAtCaret) + * and returns an array of Suggestions + * + * Suggestion is an object containing following properties: + * displayText: string. Main display text, what actual suggestion + * represents (user's screen name/emoji shortcode) + * replacement: string. Text that should replace the textAtCaret + * detailText: string, optional. Subtitle text, providing additional info + * if present (user's nickname) + * imageUrl: string, optional. Image to display alongside with suggestion, + * currently if no image is provided, replacement will be used (for + * unicode emojis) + * + * TODO: make it asynchronous when adding proper server-provided user + * suggestions + * + * For commonly used suggestors (emoji, users, both) use suggestor.js + */ + required: true, + type: Function + }, + value: { + /** + * Used for v-model + */ + required: true, + type: String + } + }, data () { return { + input: undefined, highlighted: 0, - caret: 0 + caret: 0, + focused: false, + blurTimeout: null } }, computed: { suggestions () { const firstchar = this.textAtCaret.charAt(0) - if (firstchar === ':') { - if (this.textAtCaret === ':') { return } - 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) => ({ - shortcode: `:${shortcode}:`, - utf: utf || '', + if (this.textAtCaret === firstchar) { return [] } + const matchedSuggestions = this.suggest(this.textAtCaret) + if (matchedSuggestions.length <= 0) { + return [] + } + return take(matchedSuggestions, 5) + .map(({ imageUrl, ...rest }, index) => ({ + ...rest, // eslint-disable-next-line camelcase - img: utf ? '' : this.$store.state.instance.server + image_url, + img: imageUrl || '', highlighted: index === this.highlighted })) - } else { - return false - } + }, + showPopup () { + return this.focused && this.suggestions && this.suggestions.length > 0 }, textAtCaret () { return (this.wordAtCaret || {}).word || '' }, wordAtCaret () { - const word = Completion.wordAtPosition(this.value, this.caret - 1) || {} - return word - }, - emoji () { - return this.$store.state.instance.emoji || [] - }, - customEmoji () { - return this.$store.state.instance.customEmoji || [] + if (this.value && this.caret) { + const word = Completion.wordAtPosition(this.value, this.caret - 1) || {} + return word + } + } + }, + mounted () { + const slots = this.$slots.default + if (!slots || slots.length === 0) return + const input = slots.find(slot => ['input', 'textarea'].includes(slot.tag)) + if (!input) return + this.input = input + this.resize() + input.elm.addEventListener('blur', this.onBlur) + input.elm.addEventListener('focus', this.onFocus) + input.elm.addEventListener('paste', this.onPaste) + input.elm.addEventListener('keyup', this.onKeyUp) + input.elm.addEventListener('keydown', this.onKeyDown) + input.elm.addEventListener('transitionend', this.onTransition) + input.elm.addEventListener('compositionupdate', this.onCompositionUpdate) + }, + unmounted () { + const { input } = this + if (input) { + input.elm.removeEventListener('blur', this.onBlur) + input.elm.removeEventListener('focus', this.onFocus) + input.elm.removeEventListener('paste', this.onPaste) + input.elm.removeEventListener('keyup', this.onKeyUp) + input.elm.removeEventListener('keydown', this.onKeyDown) + input.elm.removeEventListener('transitionend', this.onTransition) + input.elm.removeEventListener('compositionupdate', this.onCompositionUpdate) } }, methods: { @@ -54,27 +125,35 @@ const EmojiInput = { this.$emit('input', newValue) this.caret = 0 }, - replaceEmoji (e) { + replaceText (e, suggestion) { const len = this.suggestions.length || 0 - if (this.textAtCaret === ':' || e.ctrlKey) { return } - if (len > 0) { - e.preventDefault() - const emoji = this.suggestions[this.highlighted] - const replacement = emoji.utf || (emoji.shortcode + ' ') + if (this.textAtCaret.length === 1) { return } + if (len > 0 || suggestion) { + const chosenSuggestion = suggestion || this.suggestions[this.highlighted] + const replacement = chosenSuggestion.replacement const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement) this.$emit('input', newValue) - this.caret = 0 this.highlighted = 0 + const position = this.wordAtCaret.start + replacement.length + + this.$nextTick(function () { + // Re-focus inputbox after clicking suggestion + this.input.elm.focus() + // Set selection right after the replacement instead of the very end + this.input.elm.setSelectionRange(position, position) + this.caret = position + }) + e.preventDefault() } }, cycleBackward (e) { const len = this.suggestions.length || 0 if (len > 0) { - e.preventDefault() this.highlighted -= 1 if (this.highlighted < 0) { this.highlighted = this.suggestions.length - 1 } + e.preventDefault() } else { this.highlighted = 0 } @@ -82,24 +161,88 @@ const EmojiInput = { cycleForward (e) { const len = this.suggestions.length || 0 if (len > 0) { - if (e.shiftKey) { return } - e.preventDefault() this.highlighted += 1 if (this.highlighted >= len) { this.highlighted = 0 } + e.preventDefault() } else { this.highlighted = 0 } }, - onKeydown (e) { - e.stopPropagation() + onTransition (e) { + this.resize() + }, + onBlur (e) { + // Clicking on any suggestion removes focus from autocomplete, + // preventing click handler ever executing. + this.blurTimeout = setTimeout(() => { + this.focused = false + this.setCaret(e) + this.resize() + }, 200) + }, + onClick (e, suggestion) { + this.replaceText(e, suggestion) + }, + onFocus (e) { + if (this.blurTimeout) { + clearTimeout(this.blurTimeout) + this.blurTimeout = null + } + + this.focused = true + this.setCaret(e) + this.resize() + }, + onKeyUp (e) { + this.setCaret(e) + this.resize() + }, + onPaste (e) { + this.setCaret(e) + this.resize() + }, + onKeyDown (e) { + this.setCaret(e) + this.resize() + + const { ctrlKey, shiftKey, key } = e + if (key === 'Tab') { + if (shiftKey) { + this.cycleBackward(e) + } else { + this.cycleForward(e) + } + } + if (key === 'ArrowUp') { + this.cycleBackward(e) + } else if (key === 'ArrowDown') { + this.cycleForward(e) + } + if (key === 'Enter') { + if (!ctrlKey) { + this.replaceText(e) + } + } }, onInput (e) { + this.setCaret(e) this.$emit('input', e.target.value) }, - setCaret ({target: {selectionStart}}) { + onCompositionUpdate (e) { + this.setCaret(e) + this.resize() + this.$emit('input', e.target.value) + }, + setCaret ({ target: { selectionStart } }) { this.caret = selectionStart + }, + resize () { + const { panel } = this.$refs + if (!panel) return + const { offsetHeight, offsetTop } = this.input.elm + this.$refs.panel.style.top = (offsetTop + offsetHeight) + 'px' } } } diff --git a/src/components/emoji-input/emoji-input.vue b/src/components/emoji-input/emoji-input.vue index 338b77cd..48739ec8 100644 --- a/src/components/emoji-input/emoji-input.vue +++ b/src/components/emoji-input/emoji-input.vue @@ -1,50 +1,30 @@ <template> <div class="emoji-input"> - <input - v-if="type !== 'textarea'" - :class="classname" - :type="type" - :value="value" - :placeholder="placeholder" - @input="onInput" - @click="setCaret" - @keyup="setCaret" - @keydown="onKeydown" - @keydown.down="cycleForward" - @keydown.up="cycleBackward" - @keydown.shift.tab="cycleBackward" - @keydown.tab="cycleForward" - @keydown.enter="replaceEmoji" - /> - <textarea - v-else - :class="classname" - :value="value" - :placeholder="placeholder" - @input="onInput" - @click="setCaret" - @keyup="setCaret" - @keydown="onKeydown" - @keydown.down="cycleForward" - @keydown.up="cycleBackward" - @keydown.shift.tab="cycleBackward" - @keydown.tab="cycleForward" - @keydown.enter="replaceEmoji" - ></textarea> - <div class="autocomplete-panel" v-if="suggestions"> + <slot /> + <div + ref="panel" + class="autocomplete-panel" + :class="{ hide: !showPopup }" + > <div class="autocomplete-panel-body"> <div - v-for="(emoji, index) in suggestions" + v-for="(suggestion, index) in suggestions" :key="index" - @click="replace(emoji.utf || (emoji.shortcode + ' '))" class="autocomplete-item" - :class="{ highlighted: emoji.highlighted }" + :class="{ highlighted: suggestion.highlighted }" + @click.stop.prevent="onClick($event, suggestion)" > - <span v-if="emoji.img"> - <img :src="emoji.img" /> + <span class="image"> + <img + v-if="suggestion.img" + :src="suggestion.img" + > + <span v-else>{{ suggestion.replacement }}</span> </span> - <span v-else>{{emoji.utf}}</span> - <span>{{emoji.shortcode}}</span> + <div class="label"> + <span class="displayText">{{ suggestion.displayText }}</span> + <span class="detailText">{{ suggestion.detailText }}</span> + </div> </div> </div> </div> @@ -57,8 +37,81 @@ @import '../../_variables.scss'; .emoji-input { - .form-control { - width: 100%; + display: flex; + flex-direction: column; + + .autocomplete { + &-panel { + position: absolute; + z-index: 9; + margin-top: 2px; + + &.hide { + display: none + } + + &-body { + margin: 0 0.5em 0 0.5em; + border-radius: $fallback--tooltipRadius; + border-radius: var(--tooltipRadius, $fallback--tooltipRadius); + box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5); + box-shadow: var(--popupShadow); + min-width: 75%; + background: $fallback--bg; + background: var(--bg, $fallback--bg); + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + } + } + + &-item { + display: flex; + cursor: pointer; + padding: 0.2em 0.4em; + border-bottom: 1px solid rgba(0, 0, 0, 0.4); + height: 32px; + + .image { + width: 32px; + height: 32px; + line-height: 32px; + text-align: center; + font-size: 32px; + + margin-right: 4px; + + img { + width: 32px; + height: 32px; + object-fit: contain; + } + } + + .label { + display: flex; + flex-direction: column; + justify-content: center; + margin: 0 0.1em 0 0.2em; + + .displayText { + line-height: 1.5; + } + + .detailText { + font-size: 9px; + line-height: 9px; + } + } + + &.highlighted { + background-color: $fallback--fg; + background-color: var(--lightBg, $fallback--fg); + } + } + } + + input, textarea { + flex: 1 0 auto; } } </style> diff --git a/src/components/emoji-input/suggestor.js b/src/components/emoji-input/suggestor.js new file mode 100644 index 00000000..aec5c39d --- /dev/null +++ b/src/components/emoji-input/suggestor.js @@ -0,0 +1,94 @@ +import { debounce } from 'lodash' +/** + * suggest - generates a suggestor function to be used by emoji-input + * data: object providing source information for specific types of suggestions: + * data.emoji - optional, an array of all emoji available i.e. + * (state.instance.emoji + state.instance.customEmoji) + * data.users - optional, an array of all known users + * updateUsersList - optional, a function to search and append to users + * + * Depending on data present one or both (or none) can be present, so if field + * doesn't support user linking you can just provide only emoji. + */ + +const debounceUserSearch = debounce((data, input) => { + data.updateUsersList(input) +}, 500, { leading: true, trailing: false }) + +export default data => input => { + const firstChar = input[0] + if (firstChar === ':' && data.emoji) { + return suggestEmoji(data.emoji)(input) + } + if (firstChar === '@' && data.users) { + return suggestUsers(data)(input) + } + return [] +} + +export const suggestEmoji = emojis => input => { + const noPrefix = input.toLowerCase().substr(1) + return emojis + .filter(({ displayText }) => displayText.toLowerCase().startsWith(noPrefix)) + .sort((a, b) => { + let aScore = 0 + let bScore = 0 + + // Make custom emojis a priority + aScore += a.imageUrl ? 10 : 0 + bScore += b.imageUrl ? 10 : 0 + + // Sort alphabetically + const alphabetically = a.displayText > b.displayText ? 1 : -1 + + return bScore - aScore + alphabetically + }) +} + +export const suggestUsers = data => input => { + const noPrefix = input.toLowerCase().substr(1) + const users = data.users + + const newUsers = users.filter( + user => + user.screen_name.toLowerCase().startsWith(noPrefix) || + user.name.toLowerCase().startsWith(noPrefix) + + /* taking only 20 results so that sorting is a bit cheaper, we display + * only 5 anyway. could be inaccurate, but we ideally we should query + * backend anyway + */ + ).slice(0, 20).sort((a, b) => { + let aScore = 0 + let bScore = 0 + + // Matches on screen name (i.e. user@instance) makes a priority + aScore += a.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0 + bScore += b.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0 + + // Matches on name takes second priority + aScore += a.name.toLowerCase().startsWith(noPrefix) ? 1 : 0 + bScore += b.name.toLowerCase().startsWith(noPrefix) ? 1 : 0 + + const diff = (bScore - aScore) * 10 + + // Then sort alphabetically + const nameAlphabetically = a.name > b.name ? 1 : -1 + const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1 + + return diff + nameAlphabetically + screenNameAlphabetically + /* eslint-disable camelcase */ + }).map(({ screen_name, name, profile_image_url_original }) => ({ + displayText: screen_name, + detailText: name, + imageUrl: profile_image_url_original, + replacement: '@' + screen_name + ' ' + })) + + // BE search users if there are no matches + if (newUsers.length === 0 && data.updateUsersList) { + debounceUserSearch(data, noPrefix) + } + return newUsers + /* eslint-enable camelcase */ +} diff --git a/src/components/export_import/export_import.vue b/src/components/export_import/export_import.vue index 451a2668..20c6f569 100644 --- a/src/components/export_import/export_import.vue +++ b/src/components/export_import/export_import.vue @@ -1,12 +1,27 @@ <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> + <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> @@ -49,7 +64,7 @@ export default { if (event.target.files[0]) { // eslint-disable-next-line no-undef const reader = new FileReader() - reader.onload = ({target}) => { + reader.onload = ({ target }) => { try { const parsed = JSON.parse(target.result) const valid = this.validator(parsed) diff --git a/src/components/exporter/exporter.vue b/src/components/exporter/exporter.vue index f22e579e..f5126dc1 100644 --- a/src/components/exporter/exporter.vue +++ b/src/components/exporter/exporter.vue @@ -1,10 +1,16 @@ <template> <div class="exporter"> <div v-if="processing"> - <i class="icon-spin4 animate-spin exporter-processing"></i> - <span>{{processingMessage}}</span> + <i class="icon-spin4 animate-spin exporter-processing" /> + <span>{{ processingMessage }}</span> </div> - <button class="btn btn-default" @click="process" v-else>{{exportButtonLabel}}</button> + <button + v-else + class="btn btn-default" + @click="process" + > + {{ exportButtonLabel }} + </button> </div> </template> diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js index 528da301..5ac73e97 100644 --- a/src/components/extra_buttons/extra_buttons.js +++ b/src/components/extra_buttons/extra_buttons.js @@ -1,45 +1,31 @@ -import Popper from 'vue-popperjs/src/component/popper.js.vue' - const ExtraButtons = { props: [ 'status' ], - components: { - Popper - }, - data () { - return { - showDropDown: false, - showPopper: true - } - }, methods: { deleteStatus () { - this.refreshPopper() const confirmed = window.confirm(this.$t('status.delete_confirm')) if (confirmed) { this.$store.dispatch('deleteStatus', { id: this.status.id }) } }, - toggleMenu () { - this.showDropDown = !this.showDropDown - }, pinStatus () { - this.refreshPopper() this.$store.dispatch('pinStatus', this.status.id) .then(() => this.$emit('onSuccess')) .catch(err => this.$emit('onError', err.error.error)) }, unpinStatus () { - this.refreshPopper() this.$store.dispatch('unpinStatus', this.status.id) .then(() => this.$emit('onSuccess')) .catch(err => this.$emit('onError', err.error.error)) }, - refreshPopper () { - this.showPopper = false - this.showDropDown = false - setTimeout(() => { - this.showPopper = true - }) + muteConversation () { + this.$store.dispatch('muteConversation', this.status.id) + .then(() => this.$emit('onSuccess')) + .catch(err => this.$emit('onError', err.error.error)) + }, + unmuteConversation () { + this.$store.dispatch('unmuteConversation', this.status.id) + .then(() => this.$emit('onSuccess')) + .catch(err => this.$emit('onError', err.error.error)) } }, computed: { @@ -55,8 +41,8 @@ const ExtraButtons = { canPin () { return this.ownStatus && (this.status.visibility === 'public' || this.status.visibility === 'unlisted') }, - enabled () { - return this.canPin || this.canDelete + canMute () { + return !!this.currentUser } } } diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue index a761d313..ed0f3aa4 100644 --- a/src/components/extra_buttons/extra_buttons.vue +++ b/src/components/extra_buttons/extra_buttons.vue @@ -1,34 +1,58 @@ <template> - <Popper + <v-popover + v-if="canDelete || canMute || canPin" trigger="click" - @hide='showDropDown = false' - append-to-body - v-if="enabled && showPopper" - :options="{ - placement: 'top', - modifiers: { - arrow: { enabled: true }, - offset: { offset: '0, 5px' }, - } - }" + placement="top" + class="extra-button-popover" + :offset="5" + :container="false" > - <div class="popper-wrapper"> + <div slot="popover"> <div class="dropdown-menu"> - <button class="dropdown-item dropdown-item-icon" @click.prevent="pinStatus" v-if="!status.pinned && canPin"> - <i class="icon-pin"></i><span>{{$t("status.pin")}}</span> + <button + v-if="canMute && !status.muted" + class="dropdown-item dropdown-item-icon" + @click.prevent="muteConversation" + > + <i class="icon-eye-off" /><span>{{ $t("status.mute_conversation") }}</span> </button> - <button class="dropdown-item dropdown-item-icon" @click.prevent="unpinStatus" v-if="status.pinned && canPin"> - <i class="icon-pin"></i><span>{{$t("status.unpin")}}</span> + <button + v-if="canMute && status.muted" + class="dropdown-item dropdown-item-icon" + @click.prevent="unmuteConversation" + > + <i class="icon-eye-off" /><span>{{ $t("status.unmute_conversation") }}</span> </button> - <button class="dropdown-item dropdown-item-icon" @click.prevent="deleteStatus" v-if="canDelete"> - <i class="icon-cancel"></i><span>{{$t("status.delete")}}</span> + <button + v-if="!status.pinned && canPin" + v-close-popover + class="dropdown-item dropdown-item-icon" + @click.prevent="pinStatus" + > + <i class="icon-pin" /><span>{{ $t("status.pin") }}</span> + </button> + <button + v-if="status.pinned && canPin" + v-close-popover + class="dropdown-item dropdown-item-icon" + @click.prevent="unpinStatus" + > + <i class="icon-pin" /><span>{{ $t("status.unpin") }}</span> + </button> + <button + v-if="canDelete" + v-close-popover + class="dropdown-item dropdown-item-icon" + @click.prevent="deleteStatus" + > + <i class="icon-cancel" /><span>{{ $t("status.delete") }}</span> </button> </div> </div> - <div class="button-icon" slot="reference" @click="toggleMenu"> - <i class='icon-ellipsis' :class="{'icon-clicked': showDropDown}"></i> + <div class="button-icon"> + <i class="icon-ellipsis" /> </div> - </Popper> + </v-popover> </template> <script src="./extra_buttons.js" ></script> @@ -40,7 +64,8 @@ .icon-ellipsis { cursor: pointer; - &:hover, &.icon-clicked { + &:hover, + .extra-button-popover.open & { color: $fallback--text; color: var(--text, $fallback--text); } diff --git a/src/components/favorite_button/favorite_button.js b/src/components/favorite_button/favorite_button.js index a2b4cb65..a24eacbf 100644 --- a/src/components/favorite_button/favorite_button.js +++ b/src/components/favorite_button/favorite_button.js @@ -11,9 +11,9 @@ const FavoriteButton = { methods: { favorite () { if (!this.status.favorited) { - this.$store.dispatch('favorite', {id: this.status.id}) + this.$store.dispatch('favorite', { id: this.status.id }) } else { - this.$store.dispatch('unfavorite', {id: this.status.id}) + this.$store.dispatch('unfavorite', { id: this.status.id }) } this.animated = true setTimeout(() => { diff --git a/src/components/favorite_button/favorite_button.vue b/src/components/favorite_button/favorite_button.vue index 05ce6bd0..06ce9983 100644 --- a/src/components/favorite_button/favorite_button.vue +++ b/src/components/favorite_button/favorite_button.vue @@ -1,11 +1,20 @@ <template> <div v-if="loggedIn"> - <i :class='classes' class='button-icon favorite-button fav-active' @click.prevent='favorite()' :title="$t('tool_tip.favorite')"/> - <span v-if='!hidePostStatsLocal && status.fave_num > 0'>{{status.fave_num}}</span> + <i + :class="classes" + class="button-icon favorite-button fav-active" + :title="$t('tool_tip.favorite')" + @click.prevent="favorite()" + /> + <span v-if="!hidePostStatsLocal && status.fave_num > 0">{{ status.fave_num }}</span> </div> <div v-else> - <i :class='classes' class='button-icon favorite-button' :title="$t('tool_tip.favorite')"/> - <span v-if='!hidePostStatsLocal && status.fave_num > 0'>{{status.fave_num}}</span> + <i + :class="classes" + class="button-icon 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 index 5f0b7b25..5f80a079 100644 --- a/src/components/features_panel/features_panel.js +++ b/src/components/features_panel/features_panel.js @@ -1,8 +1,6 @@ const FeaturesPanel = { computed: { - chat: function () { - return this.$store.state.instance.chatAvailable && (!this.$store.state.chatDisabled) - }, + chat: function () { return this.$store.state.instance.chatAvailable }, gopher: function () { return this.$store.state.instance.gopherAvailable }, whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled }, mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable }, diff --git a/src/components/features_panel/features_panel.vue b/src/components/features_panel/features_panel.vue index 7a263e01..3e5939a6 100644 --- a/src/components/features_panel/features_panel.vue +++ b/src/components/features_panel/features_panel.vue @@ -3,17 +3,25 @@ <div class="panel panel-default base01-background"> <div class="panel-heading timeline-heading base02-background base04"> <div class="title"> - {{$t('features_panel.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>{{$t('features_panel.scope_options')}}</li> - <li>{{$t('features_panel.text_limit')}} = {{textlimit}}</li> + <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>{{ $t('features_panel.scope_options') }}</li> + <li>{{ $t('features_panel.text_limit') }} = {{ textlimit }}</li> </ul> </div> </div> diff --git a/src/components/follow_card/follow_card.vue b/src/components/follow_card/follow_card.vue index 94e2836f..310fe843 100644 --- a/src/components/follow_card/follow_card.vue +++ b/src/components/follow_card/follow_card.vue @@ -1,11 +1,17 @@ <template> <basic-user-card :user="user"> <div class="follow-card-content-container"> - <span class="faint" v-if="!noFollowsYou && user.follows_you"> + <span + v-if="!noFollowsYou && user.follows_you" + class="faint" + > {{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }} </span> <template v-if="!loggedIn"> - <div class="follow-card-follow-button" v-if="!user.following"> + <div + v-if="!user.following" + class="follow-card-follow-button" + > <RemoteFollow :user="user" /> </div> </template> @@ -13,9 +19,9 @@ <button v-if="!user.following" class="btn btn-default follow-card-follow-button" - @click="followUser" :disabled="inProgress" :title="requestSent ? $t('user_card.follow_again') : ''" + @click="followUser" > <template v-if="inProgress"> {{ $t('user_card.follow_progress') }} @@ -27,7 +33,12 @@ {{ $t('user_card.follow') }} </template> </button> - <button v-else class="btn btn-default follow-card-follow-button pressed" @click="unfollowUser" :disabled="inProgress"> + <button + v-else + class="btn btn-default follow-card-follow-button pressed" + :disabled="inProgress" + @click="unfollowUser" + > <template v-if="inProgress"> {{ $t('user_card.follow_progress') }} </template> diff --git a/src/components/follow_request_card/follow_request_card.vue b/src/components/follow_request_card/follow_request_card.vue index 4a3bbba4..b217b8ed 100644 --- a/src/components/follow_request_card/follow_request_card.vue +++ b/src/components/follow_request_card/follow_request_card.vue @@ -1,8 +1,18 @@ <template> <basic-user-card :user="user"> <div class="follow-request-card-content-container"> - <button class="btn btn-default" @click="approveUser">{{ $t('user_card.approve') }}</button> - <button class="btn btn-default" @click="denyUser">{{ $t('user_card.deny') }}</button> + <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> </basic-user-card> </template> diff --git a/src/components/follow_requests/follow_requests.vue b/src/components/follow_requests/follow_requests.vue index 36901fb4..5fa4cf39 100644 --- a/src/components/follow_requests/follow_requests.vue +++ b/src/components/follow_requests/follow_requests.vue @@ -1,10 +1,15 @@ <template> <div class="settings panel panel-default"> <div class="panel-heading"> - {{$t('nav.friend_requests')}} + {{ $t('nav.friend_requests') }} </div> <div class="panel-body"> - <FollowRequestCard v-for="request in requests" :key="request.id" :user="request" class="list-item"/> + <FollowRequestCard + v-for="request in requests" + :key="request.id" + :user="request" + class="list-item" + /> </div> </div> </template> diff --git a/src/components/font_control/font_control.vue b/src/components/font_control/font_control.vue index ed36b280..61f0384b 100644 --- a/src/components/font_control/font_control.vue +++ b/src/components/font_control/font_control.vue @@ -1,35 +1,56 @@ <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 + <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'" + :id="name + '-o'" + class="opt exlcude-disabled" + 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 + :for="name + '-font-switcher'" + class="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> + > + <select + :id="name + '-font-switcher'" + v-model="preset" + :disabled="!present" + class="font-switcher" + > + <option + v-for="option in availableOptions" + :key="option" + :value="option" + > + {{ option === 'custom' ? $t('settings.style.fonts.custom') : option }} + </option> + </select> + <i class="icon-down-open" /> + </label> + <input + v-if="isCustom" + :id="name" + v-model="family" + class="custom-font" + type="text" + > + </div> </template> <script src="./font_control.js" ></script> diff --git a/src/components/friends_timeline/friends_timeline.vue b/src/components/friends_timeline/friends_timeline.vue index 66c0c058..01a56812 100644 --- a/src/components/friends_timeline/friends_timeline.vue +++ b/src/components/friends_timeline/friends_timeline.vue @@ -1,5 +1,9 @@ <template> - <Timeline :title="$t('nav.timeline')" v-bind:timeline="timeline" v-bind:timeline-name="'friends'"/> + <Timeline + :title="$t('nav.timeline')" + :timeline="timeline" + :timeline-name="'friends'" + /> </template> <script src="./friends_timeline.js"></script> diff --git a/src/components/gallery/gallery.vue b/src/components/gallery/gallery.vue index ea525c95..6169d294 100644 --- a/src/components/gallery/gallery.vue +++ b/src/components/gallery/gallery.vue @@ -1,13 +1,22 @@ <template> - <div ref="galleryContainer" style="width: 100%;"> - <div class="gallery-row" v-for="row in rows" :style="rowHeight(row.length)" :class="{ 'contain-fit': useContainFit, 'cover-fit': !useContainFit }"> + <div + ref="galleryContainer" + style="width: 100%;" + > + <div + v-for="(row, index) in rows" + :key="index" + class="gallery-row" + :style="rowHeight(row.length)" + :class="{ 'contain-fit': useContainFit, 'cover-fit': !useContainFit }" + > <attachment v-for="attachment in row" - :setMedia="setMedia" + :key="attachment.id" + :set-media="setMedia" :nsfw="nsfw" :attachment="attachment" - :allowPlay="false" - :key="attachment.id" + :allow-play="false" /> </div> </div> @@ -28,7 +37,9 @@ flex-grow: 1; margin-top: 0.5em; - .attachments, .attachment { + // FIXME: specificity problem with this and .attachments.attachment + // we shouldn't have the need for .image here + .attachment.image { margin: 0 0.5em 0 0; flex-grow: 1; height: 100%; @@ -50,13 +61,17 @@ } &.contain-fit { - img, video { + img, + video, + canvas { object-fit: contain; } } &.cover-fit { - img, video { + img, + video, + canvas { object-fit: cover; } } diff --git a/src/components/image_cropper/image_cropper.vue b/src/components/image_cropper/image_cropper.vue index d2b86e9e..4e1b5927 100644 --- a/src/components/image_cropper/image_cropper.vue +++ b/src/components/image_cropper/image_cropper.vue @@ -2,20 +2,57 @@ <div class="image-cropper"> <div v-if="dataUrl"> <div class="image-cropper-image-container"> - <img ref="img" :src="dataUrl" alt="" @load.stop="createCropper" /> + <img + ref="img" + :src="dataUrl" + alt="" + @load.stop="createCropper" + > </div> <div class="image-cropper-buttons-wrapper"> - <button class="btn" type="button" :disabled="submitting" @click="submit()" v-text="saveText"></button> - <button class="btn" type="button" :disabled="submitting" @click="destroy" v-text="cancelText"></button> - <button class="btn" type="button" :disabled="submitting" @click="submit(false)" v-text="saveWithoutCroppingText"></button> - <i class="icon-spin4 animate-spin" v-if="submitting"></i> + <button + class="btn" + type="button" + :disabled="submitting" + @click="submit()" + v-text="saveText" + /> + <button + class="btn" + type="button" + :disabled="submitting" + @click="destroy" + v-text="cancelText" + /> + <button + class="btn" + type="button" + :disabled="submitting" + @click="submit(false)" + v-text="saveWithoutCroppingText" + /> + <i + v-if="submitting" + class="icon-spin4 animate-spin" + /> </div> - <div class="alert error" v-if="submitError"> - {{submitErrorMsg}} - <i class="button-icon icon-cancel" @click="clearError"></i> + <div + v-if="submitError" + class="alert error" + > + {{ submitErrorMsg }} + <i + class="button-icon icon-cancel" + @click="clearError" + /> </div> </div> - <input ref="input" type="file" class="image-cropper-img-input" :accept="mimes"> + <input + ref="input" + type="file" + class="image-cropper-img-input" + :accept="mimes" + > </div> </template> diff --git a/src/components/importer/importer.vue b/src/components/importer/importer.vue index 0c5aa93d..ed923d59 100644 --- a/src/components/importer/importer.vue +++ b/src/components/importer/importer.vue @@ -1,17 +1,36 @@ <template> <div class="importer"> <form> - <input type="file" ref="input" v-on:change="change" /> + <input + ref="input" + type="file" + @change="change" + > </form> - <i class="icon-spin4 animate-spin importer-uploading" v-if="submitting"></i> - <button class="btn btn-default" v-else @click="submit">{{submitButtonLabel}}</button> + <i + v-if="submitting" + class="icon-spin4 animate-spin importer-uploading" + /> + <button + v-else + class="btn btn-default" + @click="submit" + > + {{ submitButtonLabel }} + </button> <div v-if="success"> - <i class="icon-cross" @click="dismiss"></i> - <p>{{successMessage}}</p> + <i + class="icon-cross" + @click="dismiss" + /> + <p>{{ successMessage }}</p> </div> <div v-else-if="error"> - <i class="icon-cross" @click="dismiss"></i> - <p>{{errorMessage}}</p> + <i + class="icon-cross" + @click="dismiss" + /> + <p>{{ errorMessage }}</p> </div> </div> </template> diff --git a/src/components/instance_specific_panel/instance_specific_panel.js b/src/components/instance_specific_panel/instance_specific_panel.js index 9bb5e945..09e3d055 100644 --- a/src/components/instance_specific_panel/instance_specific_panel.js +++ b/src/components/instance_specific_panel/instance_specific_panel.js @@ -2,9 +2,6 @@ const InstanceSpecificPanel = { computed: { instanceSpecificPanelContent () { return this.$store.state.instance.instanceSpecificPanelContent - }, - show () { - return !this.$store.state.config.hideISP } } } diff --git a/src/components/instance_specific_panel/instance_specific_panel.vue b/src/components/instance_specific_panel/instance_specific_panel.vue index a7b74667..7448ca06 100644 --- a/src/components/instance_specific_panel/instance_specific_panel.vue +++ b/src/components/instance_specific_panel/instance_specific_panel.vue @@ -1,15 +1,13 @@ <template> - <div v-if="show" class="instance-specific-panel"> + <div class="instance-specific-panel"> <div class="panel panel-default"> <div class="panel-body"> - <div v-html="instanceSpecificPanelContent"> - </div> + <!-- eslint-disable vue/no-v-html --> + <div v-html="instanceSpecificPanelContent" /> + <!-- eslint-enable vue/no-v-html --> </div> </div> </div> </template> <script src="./instance_specific_panel.js" ></script> - -<style lang="scss"> -</style> diff --git a/src/components/interactions/interactions.js b/src/components/interactions/interactions.js index d4e3cc17..1f8a9de9 100644 --- a/src/components/interactions/interactions.js +++ b/src/components/interactions/interactions.js @@ -13,8 +13,8 @@ const Interactions = { } }, methods: { - onModeSwitch (index, dataset) { - this.filterMode = tabModeDict[dataset.filter] + onModeSwitch (key) { + this.filterMode = tabModeDict[key] } }, components: { diff --git a/src/components/interactions/interactions.vue b/src/components/interactions/interactions.vue index 38b2670d..08cee343 100644 --- a/src/components/interactions/interactions.vue +++ b/src/components/interactions/interactions.vue @@ -7,18 +7,27 @@ </div> <tab-switcher ref="tabSwitcher" - :onSwitch="onModeSwitch" - > - <span data-tab-dummy data-filter="mentions" :label="$t('nav.mentions')"/> - <span data-tab-dummy data-filter="likes+repeats" :label="$t('interactions.favs_repeats')"/> - <span data-tab-dummy data-filter="follows" :label="$t('interactions.follows')"/> + :on-switch="onModeSwitch" + > + <span + key="mentions" + :label="$t('nav.mentions')" + /> + <span + key="likes+repeats" + :label="$t('interactions.favs_repeats')" + /> + <span + key="follows" + :label="$t('interactions.follows')" + /> </tab-switcher> <Notifications ref="notifications" - :noHeading="true" - :minimalMode="true" - :filterMode="filterMode" - /> + :no-heading="true" + :minimal-mode="true" + :filter-mode="filterMode" + /> </div> </template> diff --git a/src/components/interface_language_switcher/interface_language_switcher.vue b/src/components/interface_language_switcher/interface_language_switcher.vue index 9f7877c6..83df9a0b 100644 --- a/src/components/interface_language_switcher/interface_language_switcher.vue +++ b/src/components/interface_language_switcher/interface_language_switcher.vue @@ -3,50 +3,60 @@ <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"> + <label + for="interface-language-switcher" + class="select" + > + <select + id="interface-language-switcher" + v-model="language" + > + <option + v-for="(langCode, i) in languageCodes" + :key="langCode" + :value="langCode" + > {{ languageNames[i] }} </option> </select> - <i class="icon-down-open"/> + <i class="icon-down-open" /> </label> </div> </template> <script> - import languagesObject from '../../i18n/messages' - import ISO6391 from 'iso-639-1' - import _ from 'lodash' +import languagesObject from '../../i18n/messages' +import ISO6391 from 'iso-639-1' +import _ from 'lodash' - export default { - computed: { - languageCodes () { - return Object.keys(languagesObject) - }, +export default { + computed: { + languageCodes () { + return Object.keys(languagesObject) + }, - languageNames () { - return _.map(this.languageCodes, this.getLanguageName) - }, + languageNames () { + return _.map(this.languageCodes, this.getLanguageName) + }, - language: { - get: function () { return this.$store.state.config.interfaceLanguage }, - set: function (val) { - this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val }) - this.$i18n.locale = val - } + language: { + get: function () { return this.$store.state.config.interfaceLanguage }, + set: function (val) { + this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val }) + this.$i18n.locale = val } - }, + } + }, - methods: { - getLanguageName (code) { - const specialLanguageNames = { - 'ja': 'Japanese (やさしいにほんご)', - 'ja_pedantic': 'Japanese (日本語)', - 'zh': 'Chinese (简体中文)' - } - return specialLanguageNames[code] || ISO6391.getName(code) + methods: { + getLanguageName (code) { + const specialLanguageNames = { + 'ja': 'Japanese (やさしいにほんご)', + 'ja_pedantic': 'Japanese (日本語)', + 'zh': 'Chinese (简体中文)' } + return specialLanguageNames[code] || ISO6391.getName(code) } } +} </script> diff --git a/src/components/link-preview/link-preview.js b/src/components/link-preview/link-preview.js index 2f6da55e..444aafbe 100644 --- a/src/components/link-preview/link-preview.js +++ b/src/components/link-preview/link-preview.js @@ -5,6 +5,11 @@ const LinkPreview = { 'size', 'nsfw' ], + data () { + return { + imageLoaded: false + } + }, computed: { useImage () { // Currently BE shoudn't give cards if tagged NSFW, this is a bit paranoid @@ -15,6 +20,15 @@ const LinkPreview = { useDescription () { return this.card.description && /\S/.test(this.card.description) } + }, + created () { + if (this.useImage) { + const newImg = new Image() + newImg.onload = () => { + this.imageLoaded = true + } + newImg.src = this.card.image + } } } diff --git a/src/components/link-preview/link-preview.vue b/src/components/link-preview/link-preview.vue index 64b1a58b..69171977 100644 --- a/src/components/link-preview/link-preview.vue +++ b/src/components/link-preview/link-preview.vue @@ -1,13 +1,25 @@ <template> <div> - <a class="link-preview-card" :href="card.url" target="_blank" rel="noopener"> - <div class="card-image" :class="{ 'small-image': size === 'small' }" v-if="useImage"> - <img :src="card.image"></img> + <a + class="link-preview-card" + :href="card.url" + target="_blank" + rel="noopener" + > + <div + v-if="useImage && imageLoaded" + class="card-image" + :class="{ 'small-image': size === 'small' }" + > + <img :src="card.image"> </div> <div class="card-content"> <span class="card-host faint">{{ card.provider_name }}</span> <h4 class="card-title">{{ card.title }}</h4> - <p class="card-description" v-if="useDescription">{{ card.description }}</p> + <p + v-if="useDescription" + class="card-description" + >{{ card.description }}</p> </div> </a> </div> diff --git a/src/components/list/list.vue b/src/components/list/list.vue index 7136915b..a6223cce 100644 --- a/src/components/list/list.vue +++ b/src/components/list/list.vue @@ -1,9 +1,19 @@ <template> <div class="list"> - <div v-for="item in items" class="list-item" :key="getKey(item)"> - <slot name="item" :item="item" /> + <div + v-for="item in items" + :key="getKey(item)" + class="list-item" + > + <slot + name="item" + :item="item" + /> </div> - <div class="list-empty-content faint" v-if="items.length === 0 && !!$slots.empty"> + <div + v-if="items.length === 0 && !!$slots.empty" + class="list-empty-content faint" + > <slot name="empty" /> </div> </div> diff --git a/src/components/login_form/login_form.js b/src/components/login_form/login_form.js index 93214646..10f52fe2 100644 --- a/src/components/login_form/login_form.js +++ b/src/components/login_form/login_form.js @@ -26,9 +26,10 @@ const LoginForm = { this.isTokenAuth ? this.submitToken() : this.submitPassword() }, submitToken () { - const { clientId } = this.oauth + const { clientId, clientSecret } = this.oauth const data = { clientId, + clientSecret, instance: this.instance.server, commit: this.$store.commit } @@ -57,7 +58,7 @@ const LoginForm = { ).then((result) => { if (result.error) { if (result.error === 'mfa_required') { - this.requireMFA({app: app, settings: result}) + this.requireMFA({ app: app, settings: result }) } else { this.error = result.error this.focusOnPasswordInput() @@ -65,7 +66,7 @@ const LoginForm = { return } this.login(result).then(() => { - this.$router.push({name: 'friends'}) + this.$router.push({ name: 'friends' }) }) }) }) diff --git a/src/components/login_form/login_form.vue b/src/components/login_form/login_form.vue index a2c5cf8f..3ec7fe0c 100644 --- a/src/components/login_form/login_form.vue +++ b/src/components/login_form/login_form.vue @@ -1,53 +1,83 @@ <template> -<div class="login panel panel-default"> - <!-- Default panel contents --> - - <div class="panel-heading">{{$t('login.login')}}</div> - - <div class="panel-body"> - <form class='login-form' @submit.prevent='submit'> - <template v-if="isPasswordAuth"> - <div class='form-group'> - <label for='username'>{{$t('login.username')}}</label> - <input :disabled="loggingIn" v-model='user.username' - class='form-control' id='username' - :placeholder="$t('login.placeholder')"> - </div> - <div class='form-group'> - <label for='password'>{{$t('login.password')}}</label> - <input :disabled="loggingIn" v-model='user.password' - ref='passwordInput' class='form-control' id='password' type='password'> - </div> - </template> + <div class="login panel panel-default"> + <!-- Default panel contents --> - <div class="form-group" v-if="isTokenAuth"> - <p>{{$t('login.description')}}</p> - </div> + <div class="panel-heading"> + {{ $t('login.login') }} + </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 class="panel-body"> + <form + class="login-form" + @submit.prevent="submit" + > + <template v-if="isPasswordAuth"> + <div class="form-group"> + <label for="username">{{ $t('login.username') }}</label> + <input + id="username" + v-model="user.username" + :disabled="loggingIn" + class="form-control" + :placeholder="$t('login.placeholder')" + > </div> - <button :disabled="loggingIn" type='submit' class='btn btn-default'> - {{$t('login.login')}} - </button> + <div class="form-group"> + <label for="password">{{ $t('login.password') }}</label> + <input + id="password" + ref="passwordInput" + v-model="user.password" + :disabled="loggingIn" + class="form-control" + type="password" + > + </div> + </template> + + <div + v-if="isTokenAuth" + class="form-group" + > + <p>{{ $t('login.description') }}</p> </div> - </div> - </form> - </div> - <div v-if="error" class='form-group'> - <div class='alert error'> - {{error}} - <i class="button-icon icon-cancel" @click="clearError"></i> + <div class="form-group"> + <div class="login-bottom"> + <div> + <router-link + v-if="registrationOpen" + :to="{name: 'registration'}" + 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> + + <div + v-if="error" + class="form-group" + > + <div class="alert error"> + {{ error }} + <i + class="button-icon icon-cancel" + @click="clearError" + /> + </div> </div> </div> -</div> </template> <script src="./login_form.js" ></script> diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue index a4c12d74..0543e677 100644 --- a/src/components/media_modal/media_modal.vue +++ b/src/components/media_modal/media_modal.vue @@ -1,25 +1,33 @@ <template> - <div class="modal-view media-modal-view" v-if="showing" @click.prevent="hide"> - <img class="modal-image" v-if="type === 'image'" :src="currentMedia.url"></img> - <VideoAttachment + <div + v-if="showing" + class="modal-view media-modal-view" + @click.prevent="hide" + > + <img + v-if="type === 'image'" class="modal-image" + :src="currentMedia.url" + > + <VideoAttachment v-if="type === 'video'" + class="modal-image" :attachment="currentMedia" :controls="true" - @click.stop.native=""> - </VideoAttachment> + @click.stop.native="" + /> <button + v-if="canNavigate" :title="$t('media_modal.previous')" class="modal-view-button-arrow modal-view-button-arrow--prev" - v-if="canNavigate" @click.stop.prevent="goPrev" > <i class="icon-left-open arrow-icon" /> </button> <button + v-if="canNavigate" :title="$t('media_modal.next')" class="modal-view-button-arrow modal-view-button-arrow--next" - v-if="canNavigate" @click.stop.prevent="goNext" > <i class="icon-right-open arrow-icon" /> diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js index e4b3d460..f457d022 100644 --- a/src/components/media_upload/media_upload.js +++ b/src/components/media_upload/media_upload.js @@ -16,7 +16,7 @@ const mediaUpload = { if (file.size > store.state.instance.uploadlimit) { const filesize = fileSizeFormatService.fileSizeFormat(file.size) const allowedsize = fileSizeFormatService.fileSizeFormat(store.state.instance.uploadlimit) - self.$emit('upload-failed', 'file_too_big', {filesize: filesize.num, filesizeunit: filesize.unit, allowedsize: allowedsize.num, allowedsizeunit: allowedsize.unit}) + self.$emit('upload-failed', 'file_too_big', { filesize: filesize.num, filesizeunit: filesize.unit, allowedsize: allowedsize.num, allowedsizeunit: allowedsize.unit }) return } const formData = new FormData() @@ -36,7 +36,7 @@ const mediaUpload = { }, fileDrop (e) { if (e.dataTransfer.files.length > 0) { - e.preventDefault() // allow dropping text like before + e.preventDefault() // allow dropping text like before this.uploadFile(e.dataTransfer.files[0]) } }, @@ -54,7 +54,7 @@ const mediaUpload = { this.uploadReady = true }) }, - change ({target}) { + change ({ target }) { for (var i = 0; i < target.files.length; i++) { let file = target.files[i] this.uploadFile(file) diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue index fcdc3471..ac32ae83 100644 --- a/src/components/media_upload/media_upload.vue +++ b/src/components/media_upload/media_upload.vue @@ -1,9 +1,29 @@ <template> - <div class="media-upload" @drop.prevent @dragover.prevent="fileDrag" @drop="fileDrop"> - <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" v-if="uploadReady" @change="change" style="position: fixed; top: -100em" multiple="true"></input> + <div + class="media-upload" + @drop.prevent + @dragover.prevent="fileDrag" + @drop="fileDrop" + > + <label + class="btn btn-default" + :title="$t('tool_tip.media_upload')" + > + <i + v-if="uploading" + class="icon-spin4 animate-spin" + /> + <i + v-if="!uploading" + class="icon-upload" + /> + <input + v-if="uploadReady" + type="file" + style="position: fixed; top: -100em" + multiple="true" + @change="change" + > </label> </div> </template> @@ -13,7 +33,7 @@ <style> .media-upload { font-size: 26px; - flex: 1; + min-width: 50px; } .icon-upload { diff --git a/src/components/mentions/mentions.vue b/src/components/mentions/mentions.vue index 6b4e96e0..70f60baf 100644 --- a/src/components/mentions/mentions.vue +++ b/src/components/mentions/mentions.vue @@ -1,5 +1,9 @@ <template> - <Timeline :title="$t('nav.interactions')" v-bind:timeline="timeline" v-bind:timeline-name="'mentions'"/> + <Timeline + :title="$t('nav.interactions')" + :timeline="timeline" + :timeline-name="'mentions'" + /> </template> <script src="./mentions.js"></script> diff --git a/src/components/mfa_form/recovery_form.js b/src/components/mfa_form/recovery_form.js index fbe9b437..7a3cc22d 100644 --- a/src/components/mfa_form/recovery_form.js +++ b/src/components/mfa_form/recovery_form.js @@ -33,7 +33,7 @@ export default { } this.login(result).then(() => { - this.$router.push({name: 'friends'}) + this.$router.push({ name: 'friends' }) }) }) } diff --git a/src/components/mfa_form/recovery_form.vue b/src/components/mfa_form/recovery_form.vue index e0e2d65b..57294630 100644 --- a/src/components/mfa_form/recovery_form.vue +++ b/src/components/mfa_form/recovery_form.vue @@ -1,42 +1,65 @@ <template> -<div class="login panel panel-default"> - <!-- Default panel contents --> + <div class="login panel panel-default"> + <!-- Default panel contents --> - <div class="panel-heading">{{$t('login.heading.recovery')}}</div> + <div class="panel-heading"> + {{ $t('login.heading.recovery') }} + </div> - <div class="panel-body"> - <form class='login-form' @submit.prevent='submit'> - <div class='form-group'> - <label for='code'>{{$t('login.recovery_code')}}</label> - <input v-model='code' class='form-control' id='code'> - </div> + <div class="panel-body"> + <form + class="login-form" + @submit.prevent="submit" + > + <div class="form-group"> + <label for="code">{{ $t('login.recovery_code') }}</label> + <input + id="code" + v-model="code" + class="form-control" + > + </div> - <div class='form-group'> - <div class='login-bottom'> - <div> - <a href="#" @click.prevent="requireTOTP"> - {{$t('login.enter_two_factor_code')}} - </a> - <br /> - <a href="#" @click.prevent="abortMFA"> - {{$t('general.cancel')}} - </a> + <div class="form-group"> + <div class="login-bottom"> + <div> + <a + href="#" + @click.prevent="requireTOTP" + > + {{ $t('login.enter_two_factor_code') }} + </a> + <br> + <a + href="#" + @click.prevent="abortMFA" + > + {{ $t('general.cancel') }} + </a> + </div> + <button + type="submit" + class="btn btn-default" + > + {{ $t('general.verify') }} + </button> </div> - <button type='submit' class='btn btn-default'> - {{$t('general.verify')}} - </button> </div> - </div> - - </form> - </div> + </form> + </div> - <div v-if="error" class='form-group'> - <div class='alert error'> - {{error}} - <i class="button-icon icon-cancel" @click="clearError"></i> + <div + v-if="error" + class="form-group" + > + <div class="alert error"> + {{ error }} + <i + class="button-icon icon-cancel" + @click="clearError" + /> + </div> </div> </div> -</div> </template> <script src="./recovery_form.js" ></script> diff --git a/src/components/mfa_form/totp_form.js b/src/components/mfa_form/totp_form.js index 6c94fe52..778bf8dc 100644 --- a/src/components/mfa_form/totp_form.js +++ b/src/components/mfa_form/totp_form.js @@ -32,7 +32,7 @@ export default { } this.login(result).then(() => { - this.$router.push({name: 'friends'}) + this.$router.push({ name: 'friends' }) }) }) } diff --git a/src/components/mfa_form/totp_form.vue b/src/components/mfa_form/totp_form.vue index c547785e..a344b395 100644 --- a/src/components/mfa_form/totp_form.vue +++ b/src/components/mfa_form/totp_form.vue @@ -1,45 +1,67 @@ <template> -<div class="login panel panel-default"> - <!-- Default panel contents --> + <div class="login panel panel-default"> + <!-- Default panel contents --> - <div class="panel-heading"> - {{$t('login.heading.totp')}} - </div> + <div class="panel-heading"> + {{ $t('login.heading.totp') }} + </div> - <div class="panel-body"> - <form class='login-form' @submit.prevent='submit'> - <div class='form-group'> - <label for='code'> - {{$t('login.authentication_code')}} - </label> - <input v-model='code' class='form-control' id='code'> - </div> + <div class="panel-body"> + <form + class="login-form" + @submit.prevent="submit" + > + <div class="form-group"> + <label for="code"> + {{ $t('login.authentication_code') }} + </label> + <input + id="code" + v-model="code" + class="form-control" + > + </div> - <div class='form-group'> - <div class='login-bottom'> - <div> - <a href="#" @click.prevent="requireRecovery"> - {{$t('login.enter_recovery_code')}} - </a> - <br /> - <a href="#" @click.prevent="abortMFA"> - {{$t('general.cancel')}} - </a> + <div class="form-group"> + <div class="login-bottom"> + <div> + <a + href="#" + @click.prevent="requireRecovery" + > + {{ $t('login.enter_recovery_code') }} + </a> + <br> + <a + href="#" + @click.prevent="abortMFA" + > + {{ $t('general.cancel') }} + </a> + </div> + <button + type="submit" + class="btn btn-default" + > + {{ $t('general.verify') }} + </button> </div> - <button type='submit' class='btn btn-default'> - {{$t('general.verify')}} - </button> </div> - </div> - </form> - </div> + </form> + </div> - <div v-if="error" class='form-group'> - <div class='alert error'> - {{error}} - <i class="button-icon icon-cancel" @click="clearError"></i> + <div + v-if="error" + class="form-group" + > + <div class="alert error"> + {{ error }} + <i + class="button-icon icon-cancel" + @click="clearError" + /> + </div> </div> </div> -</div> </template> <script src="./totp_form.js"></script> diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js index 9b341a3b..c2bb76ee 100644 --- a/src/components/mobile_nav/mobile_nav.js +++ b/src/components/mobile_nav/mobile_nav.js @@ -1,14 +1,12 @@ import SideDrawer from '../side_drawer/side_drawer.vue' import Notifications from '../notifications/notifications.vue' -import MobilePostStatusModal from '../mobile_post_status_modal/mobile_post_status_modal.vue' import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils' import GestureService from '../../services/gesture_service/gesture_service' const MobileNav = { components: { SideDrawer, - Notifications, - MobilePostStatusModal + Notifications }, data: () => ({ notificationsCloseGesture: undefined, diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue index dcac440a..d1c24e56 100644 --- a/src/components/mobile_nav/mobile_nav.vue +++ b/src/components/mobile_nav/mobile_nav.vue @@ -1,39 +1,75 @@ <template> <div> - <nav class='nav-bar container' id="nav"> - <div class='mobile-inner-nav' @click="scrollToTop()"> - <div class='item'> - <a href="#" class="mobile-nav-button" @click.stop.prevent="toggleMobileSidebar()"> - <i class="button-icon icon-menu"></i> + <nav + id="nav" + class="nav-bar container" + > + <div + class="mobile-inner-nav" + @click="scrollToTop()" + > + <div class="item"> + <a + href="#" + class="mobile-nav-button" + @click.stop.prevent="toggleMobileSidebar()" + > + <i class="button-icon icon-menu" /> </a> - <router-link class="site-name" :to="{ name: 'root' }" active-class="home">{{sitename}}</router-link> + <router-link + class="site-name" + :to="{ name: 'root' }" + active-class="home" + > + {{ sitename }} + </router-link> </div> - <div class='item right'> - <a class="mobile-nav-button" v-if="currentUser" href="#" @click.stop.prevent="openMobileNotifications()"> - <i class="button-icon icon-bell-alt"></i> - <div class="alert-dot" v-if="unseenNotificationsCount"></div> + <div class="item right"> + <a + v-if="currentUser" + class="mobile-nav-button" + href="#" + @click.stop.prevent="openMobileNotifications()" + > + <i class="button-icon icon-bell-alt" /> + <div + v-if="unseenNotificationsCount" + class="alert-dot" + /> </a> </div> </div> </nav> - <div v-if="currentUser" + <div + v-if="currentUser" class="mobile-notifications-drawer" :class="{ 'closed': !notificationsOpen }" @touchstart.stop="notificationsTouchStart" @touchmove.stop="notificationsTouchMove" > <div class="mobile-notifications-header"> - <span class="title">{{$t('notifications.notifications')}}</span> - <a class="mobile-nav-button" @click.stop.prevent="closeMobileNotifications()"> - <i class="button-icon icon-cancel"/> + <span class="title">{{ $t('notifications.notifications') }}</span> + <a + class="mobile-nav-button" + @click.stop.prevent="closeMobileNotifications()" + > + <i class="button-icon icon-cancel" /> </a> </div> - <div class="mobile-notifications" @scroll="onScroll"> - <Notifications ref="notifications" :noHeading="true"/> + <div + class="mobile-notifications" + @scroll="onScroll" + > + <Notifications + ref="notifications" + :no-heading="true" + /> </div> </div> - <SideDrawer ref="sideDrawer" :logout="logout"/> - <MobilePostStatusModal /> + <SideDrawer + ref="sideDrawer" + :logout="logout" + /> </div> </template> diff --git a/src/components/mobile_post_status_modal/mobile_post_status_modal.js b/src/components/mobile_post_status_modal/mobile_post_status_modal.js index 91b730e7..3cec23c6 100644 --- a/src/components/mobile_post_status_modal/mobile_post_status_modal.js +++ b/src/components/mobile_post_status_modal/mobile_post_status_modal.js @@ -96,12 +96,12 @@ const MobilePostStatusModal = { this.hidden = false } this.oldScrollPos = window.scrollY - }, 100, {leading: true, trailing: false}), + }, 100, { leading: true, trailing: false }), handleScrollEnd: debounce(function () { this.hidden = false this.oldScrollPos = window.scrollY - }, 100, {leading: false, trailing: true}) + }, 100, { leading: false, trailing: true }) } } diff --git a/src/components/mobile_post_status_modal/mobile_post_status_modal.vue b/src/components/mobile_post_status_modal/mobile_post_status_modal.vue index c762705b..b6d7d3ba 100644 --- a/src/components/mobile_post_status_modal/mobile_post_status_modal.vue +++ b/src/components/mobile_post_status_modal/mobile_post_status_modal.vue @@ -1,23 +1,31 @@ <template> -<div v-if="currentUser"> - <div - class="post-form-modal-view modal-view" - v-show="postFormOpen" - @click="closePostForm" - > - <div class="post-form-modal-panel panel" @click.stop=""> - <div class="panel-heading">{{$t('post_status.new_status')}}</div> - <PostStatusForm class="panel-body" @posted="closePostForm" /> + <div v-if="currentUser"> + <div + v-show="postFormOpen" + class="post-form-modal-view modal-view" + @click="closePostForm" + > + <div + class="post-form-modal-panel panel" + @click.stop="" + > + <div class="panel-heading"> + {{ $t('post_status.new_status') }} + </div> + <PostStatusForm + class="panel-body" + @posted="closePostForm" + /> + </div> </div> + <button + class="new-status-button" + :class="{ 'hidden': isHidden }" + @click="openPostForm" + > + <i class="icon-edit" /> + </button> </div> - <button - class="new-status-button" - :class="{ 'hidden': isHidden }" - @click="openPostForm" - > - <i class="icon-edit" /> - </button> -</div> </template> <script src="./mobile_post_status_modal.js"></script> @@ -26,14 +34,19 @@ @import '../../_variables.scss'; .post-form-modal-view { - max-height: 100%; - display: block; + align-items: flex-start; } .post-form-modal-panel { flex-shrink: 0; - margin: 25% 0 4em 0; + margin-top: 25%; + margin-bottom: 2em; width: 100%; + max-width: 700px; + + @media (orientation: landscape) { + margin-top: 8%; + } } .new-status-button { diff --git a/src/components/moderation_tools/moderation_tools.js b/src/components/moderation_tools/moderation_tools.js index 3eedeaa1..8aadc8c5 100644 --- a/src/components/moderation_tools/moderation_tools.js +++ b/src/components/moderation_tools/moderation_tools.js @@ -1,5 +1,4 @@ import DialogModal from '../dialog_modal/dialog_modal.vue' -import Popper from 'vue-popperjs/src/component/popper.js.vue' const FORCE_NSFW = 'mrf_tag:media-force-nsfw' const STRIP_MEDIA = 'mrf_tag:media-strip' @@ -29,8 +28,7 @@ const ModerationTools = { } }, components: { - DialogModal, - Popper + DialogModal }, computed: { tagsSet () { @@ -41,9 +39,6 @@ const ModerationTools = { } }, methods: { - toggleMenu () { - this.showDropDown = !this.showDropDown - }, hasTag (tagName) { return this.tagsSet.has(tagName) }, @@ -52,12 +47,12 @@ const ModerationTools = { if (this.tagsSet.has(tag)) { store.state.api.backendInteractor.untagUser(this.user, tag).then(response => { if (!response.ok) { return } - store.commit('untagUser', {user: this.user, tag}) + store.commit('untagUser', { user: this.user, tag }) }) } else { store.state.api.backendInteractor.tagUser(this.user, tag).then(response => { if (!response.ok) { return } - store.commit('tagUser', {user: this.user, tag}) + store.commit('tagUser', { user: this.user, tag }) }) } }, @@ -66,12 +61,12 @@ const ModerationTools = { if (this.user.rights[right]) { store.state.api.backendInteractor.deleteRight(this.user, right).then(response => { if (!response.ok) { return } - store.commit('updateRight', {user: this.user, right: right, value: false}) + store.commit('updateRight', { user: this.user, right: right, value: false }) }) } else { store.state.api.backendInteractor.addRight(this.user, right).then(response => { if (!response.ok) { return } - store.commit('updateRight', {user: this.user, right: right, value: true}) + store.commit('updateRight', { user: this.user, right: right, value: true }) }) } }, @@ -80,7 +75,7 @@ const ModerationTools = { const status = !!this.user.deactivated store.state.api.backendInteractor.setActivationStatus(this.user, status).then(response => { if (!response.ok) { return } - store.commit('updateActivationStatus', {user: this.user, status: status}) + store.commit('updateActivationStatus', { user: this.user, status: status }) }) }, deleteUserDialog (show) { @@ -89,7 +84,7 @@ const ModerationTools = { deleteUser () { const store = this.$store const user = this.user - const {id, name} = user + const { id, name } = user store.state.api.backendInteractor.deleteUser(user) .then(e => { this.$store.dispatch('markStatusesAsDeleted', status => user.id === status.user.id) diff --git a/src/components/moderation_tools/moderation_tools.vue b/src/components/moderation_tools/moderation_tools.vue index 6a5e8cc0..d97ca3aa 100644 --- a/src/components/moderation_tools/moderation_tools.vue +++ b/src/components/moderation_tools/moderation_tools.vue @@ -1,85 +1,161 @@ <template> -<div class='block' style='position: relative'> - <Popper - trigger="click" - @hide='showDropDown = false' - append-to-body - :options="{ - placement: 'bottom-end', - modifiers: { - arrow: { enabled: true }, - offset: { offset: '0, 5px' }, - } - }"> - <div class="popper-wrapper"> - <div class="dropdown-menu"> - <span v-if='user.is_local'> - <button class="dropdown-item" @click='toggleRight("admin")'> - {{ $t(!!user.rights.admin ? 'user_card.admin_menu.revoke_admin' : 'user_card.admin_menu.grant_admin') }} + <div> + <v-popover + trigger="click" + class="moderation-tools-popover" + :container="false" + placement="bottom-end" + :offset="5" + @show="showDropDown = true" + @hide="showDropDown = false" + > + <div slot="popover"> + <div class="dropdown-menu"> + <span v-if="user.is_local"> + <button + class="dropdown-item" + @click="toggleRight("admin")" + > + {{ $t(!!user.rights.admin ? 'user_card.admin_menu.revoke_admin' : 'user_card.admin_menu.grant_admin') }} + </button> + <button + class="dropdown-item" + @click="toggleRight("moderator")" + > + {{ $t(!!user.rights.moderator ? 'user_card.admin_menu.revoke_moderator' : 'user_card.admin_menu.grant_moderator') }} + </button> + <div + role="separator" + class="dropdown-divider" + /> + </span> + <button + class="dropdown-item" + @click="toggleActivationStatus()" + > + {{ $t(!!user.deactivated ? 'user_card.admin_menu.activate_account' : 'user_card.admin_menu.deactivate_account') }} </button> - <button class="dropdown-item" @click='toggleRight("moderator")'> - {{ $t(!!user.rights.moderator ? 'user_card.admin_menu.revoke_moderator' : 'user_card.admin_menu.grant_moderator') }} + <button + class="dropdown-item" + @click="deleteUserDialog(true)" + > + {{ $t('user_card.admin_menu.delete_account') }} </button> - <div role="separator" class="dropdown-divider"></div> - </span> - <button class="dropdown-item" @click='toggleActivationStatus()'> - {{ $t(!!user.deactivated ? 'user_card.admin_menu.activate_account' : 'user_card.admin_menu.deactivate_account') }} - </button> - <button class="dropdown-item" @click='deleteUserDialog(true)'> - {{ $t('user_card.admin_menu.delete_account') }} - </button> - <div role="separator" class="dropdown-divider" v-if='hasTagPolicy'></div> - <span v-if='hasTagPolicy'> - <button class="dropdown-item" @click='toggleTag(tags.FORCE_NSFW)'> - {{ $t('user_card.admin_menu.force_nsfw') }} - <span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_NSFW) }"></span> - </button> - <button class="dropdown-item" @click='toggleTag(tags.STRIP_MEDIA)'> - {{ $t('user_card.admin_menu.strip_media') }} - <span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.STRIP_MEDIA) }"></span> - </button> - <button class="dropdown-item" @click='toggleTag(tags.FORCE_UNLISTED)'> - {{ $t('user_card.admin_menu.force_unlisted') }} - <span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_UNLISTED) }"></span> - </button> - <button class="dropdown-item" @click='toggleTag(tags.SANDBOX)'> - {{ $t('user_card.admin_menu.sandbox') }} - <span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.SANDBOX) }"></span> - </button> - <button class="dropdown-item" v-if='user.is_local' @click='toggleTag(tags.DISABLE_REMOTE_SUBSCRIPTION)'> - {{ $t('user_card.admin_menu.disable_remote_subscription') }} - <span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_REMOTE_SUBSCRIPTION) }"></span> - </button> - <button class="dropdown-item" v-if='user.is_local' @click='toggleTag(tags.DISABLE_ANY_SUBSCRIPTION)'> - {{ $t('user_card.admin_menu.disable_any_subscription') }} - <span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_ANY_SUBSCRIPTION) }"></span> - </button> - <button class="dropdown-item" v-if='user.is_local' @click='toggleTag(tags.QUARANTINE)'> - {{ $t('user_card.admin_menu.quarantine') }} - <span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.QUARANTINE) }"></span> - </button> - </span> + <div + v-if="hasTagPolicy" + role="separator" + class="dropdown-divider" + /> + <span v-if="hasTagPolicy"> + <button + class="dropdown-item" + @click="toggleTag(tags.FORCE_NSFW)" + > + {{ $t('user_card.admin_menu.force_nsfw') }} + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_NSFW) }" + /> + </button> + <button + class="dropdown-item" + @click="toggleTag(tags.STRIP_MEDIA)" + > + {{ $t('user_card.admin_menu.strip_media') }} + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': hasTag(tags.STRIP_MEDIA) }" + /> + </button> + <button + class="dropdown-item" + @click="toggleTag(tags.FORCE_UNLISTED)" + > + {{ $t('user_card.admin_menu.force_unlisted') }} + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_UNLISTED) }" + /> + </button> + <button + class="dropdown-item" + @click="toggleTag(tags.SANDBOX)" + > + {{ $t('user_card.admin_menu.sandbox') }} + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': hasTag(tags.SANDBOX) }" + /> + </button> + <button + v-if="user.is_local" + class="dropdown-item" + @click="toggleTag(tags.DISABLE_REMOTE_SUBSCRIPTION)" + > + {{ $t('user_card.admin_menu.disable_remote_subscription') }} + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_REMOTE_SUBSCRIPTION) }" + /> + </button> + <button + v-if="user.is_local" + class="dropdown-item" + @click="toggleTag(tags.DISABLE_ANY_SUBSCRIPTION)" + > + {{ $t('user_card.admin_menu.disable_any_subscription') }} + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_ANY_SUBSCRIPTION) }" + /> + </button> + <button + v-if="user.is_local" + class="dropdown-item" + @click="toggleTag(tags.QUARANTINE)" + > + {{ $t('user_card.admin_menu.quarantine') }} + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': hasTag(tags.QUARANTINE) }" + /> + </button> + </span> + </div> </div> - </div> - <button slot="reference" v-bind:class="{ pressed: showDropDown }" @click='toggleMenu'> - {{ $t('user_card.admin_menu.moderation') }} - </button> - </Popper> - <portal to="modal"> - <DialogModal v-if="showDeleteUserDialog" :onCancel='deleteUserDialog.bind(this, false)'> - <template slot="header">{{ $t('user_card.admin_menu.delete_user') }}</template> - <p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p> - <template slot="footer"> - <button class="btn btn-default" @click='deleteUserDialog(false)'> - {{ $t('general.cancel') }} - </button> - <button class="btn btn-default danger" @click='deleteUser()'> + <button + class="btn btn-default btn-block" + :class="{ pressed: showDropDown }" + > + {{ $t('user_card.admin_menu.moderation') }} + </button> + </v-popover> + <portal to="modal"> + <DialogModal + v-if="showDeleteUserDialog" + :on-cancel="deleteUserDialog.bind(this, false)" + > + <template slot="header"> {{ $t('user_card.admin_menu.delete_user') }} - </button> - </template> - </DialogModal> - </portal> -</div> + </template> + <p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p> + <template slot="footer"> + <button + class="btn btn-default" + @click="deleteUserDialog(false)" + > + {{ $t('general.cancel') }} + </button> + <button + class="btn btn-default danger" + @click="deleteUser()" + > + {{ $t('user_card.admin_menu.delete_user') }} + </button> + </template> + </DialogModal> + </portal> + </div> </template> <script src="./moderation_tools.js"></script> @@ -107,4 +183,11 @@ } } +.moderation-tools-popover { + height: 100%; + .trigger { + display: flex !important; + height: 100%; + } +} </style> diff --git a/src/components/mute_card/mute_card.vue b/src/components/mute_card/mute_card.vue index a4edff03..9611fb82 100644 --- a/src/components/mute_card/mute_card.vue +++ b/src/components/mute_card/mute_card.vue @@ -1,7 +1,12 @@ <template> <basic-user-card :user="user"> <div class="mute-card-content-container"> - <button class="btn btn-default" @click="unmuteUser" :disabled="progress" v-if="muted"> + <button + v-if="muted" + class="btn btn-default" + :disabled="progress" + @click="unmuteUser" + > <template v-if="progress"> {{ $t('user_card.unmute_progress') }} </template> @@ -9,7 +14,12 @@ {{ $t('user_card.unmute') }} </template> </button> - <button class="btn btn-default" @click="muteUser" :disabled="progress" v-else> + <button + v-else + class="btn btn-default" + :disabled="progress" + @click="muteUser" + > <template v-if="progress"> {{ $t('user_card.mute_progress') }} </template> diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue index e6e0f074..614fadf4 100644 --- a/src/components/nav_panel/nav_panel.vue +++ b/src/components/nav_panel/nav_panel.vue @@ -2,26 +2,29 @@ <div class="nav-panel"> <div class="panel panel-default"> <ul> - <li v-if='currentUser'> + <li v-if="currentUser"> <router-link :to="{ name: 'friends' }"> {{ $t("nav.timeline") }} </router-link> </li> - <li v-if='currentUser'> + <li v-if="currentUser"> <router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }"> {{ $t("nav.interactions") }} </router-link> </li> - <li v-if='currentUser'> + <li v-if="currentUser"> <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }"> {{ $t("nav.dms") }} </router-link> </li> - <li v-if='currentUser && currentUser.locked'> + <li v-if="currentUser && currentUser.locked"> <router-link :to="{ name: 'friend-requests' }"> - {{ $t("nav.friend_requests")}} - <span v-if='followRequestCount > 0' class="badge follow-request-count"> - {{followRequestCount}} + {{ $t("nav.friend_requests") }} + <span + v-if="followRequestCount > 0" + class="badge follow-request-count" + > + {{ followRequestCount }} </span> </router-link> </li> diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js index e59e7497..896c6d52 100644 --- a/src/components/notification/notification.js +++ b/src/components/notification/notification.js @@ -1,6 +1,7 @@ import Status from '../status/status.vue' import UserAvatar from '../user_avatar/user_avatar.vue' import UserCard from '../user_card/user_card.vue' +import Timeago from '../timeago/timeago.vue' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' @@ -13,7 +14,10 @@ const Notification = { }, props: [ 'notification' ], components: { - Status, UserAvatar, UserCard + Status, + UserAvatar, + UserCard, + Timeago }, methods: { toggleUserExpanded () { diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index 3427b9c5..bafcd026 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -3,49 +3,104 @@ v-if="notification.type === 'mention'" :compact="true" :statusoid="notification.status" + /> + <div + v-else + class="non-mention" + :class="[userClass, { highlighted: userStyle }]" + :style="[ userStyle ]" > - </status> - <div class="non-mention" :class="[userClass, { highlighted: userStyle }]" :style="[ userStyle ]" v-else> - <a class='avatar-container' :href="notification.from_profile.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded"> - <UserAvatar :compact="true" :betterShadow="betterShadow" :user="notification.from_profile"/> + <a + class="avatar-container" + :href="notification.from_profile.statusnet_profile_url" + @click.stop.prevent.capture="toggleUserExpanded" + > + <UserAvatar + :compact="true" + :better-shadow="betterShadow" + :user="notification.from_profile" + /> </a> - <div class='notification-right'> - <UserCard :user="getUser(notification)" :rounded="true" :bordered="true" v-if="userExpanded" /> + <div class="notification-right"> + <UserCard + v-if="userExpanded" + :user="getUser(notification)" + :rounded="true" + :bordered="true" + /> <span class="notification-details"> <div class="name-and-action"> - <span class="username" v-if="!!notification.from_profile.name_html" :title="'@'+notification.from_profile.screen_name" v-html="notification.from_profile.name_html"></span> - <span class="username" v-else :title="'@'+notification.from_profile.screen_name">{{ notification.from_profile.name }}</span> + <!-- eslint-disable vue/no-v-html --> + <span + v-if="!!notification.from_profile.name_html" + class="username" + :title="'@'+notification.from_profile.screen_name" + v-html="notification.from_profile.name_html" + /> + <!-- eslint-enable vue/no-v-html --> + <span + v-else + class="username" + :title="'@'+notification.from_profile.screen_name" + >{{ notification.from_profile.name }}</span> <span v-if="notification.type === 'like'"> - <i class="fa icon-star lit"></i> - <small>{{$t('notifications.favorited_you')}}</small> + <i class="fa icon-star lit" /> + <small>{{ $t('notifications.favorited_you') }}</small> </span> <span v-if="notification.type === 'repeat'"> - <i class="fa icon-retweet lit" :title="$t('tool_tip.repeat')"></i> - <small>{{$t('notifications.repeated_you')}}</small> + <i + class="fa icon-retweet lit" + :title="$t('tool_tip.repeat')" + /> + <small>{{ $t('notifications.repeated_you') }}</small> </span> <span v-if="notification.type === 'follow'"> - <i class="fa icon-user-plus lit"></i> - <small>{{$t('notifications.followed_you')}}</small> + <i class="fa icon-user-plus lit" /> + <small>{{ $t('notifications.followed_you') }}</small> </span> </div> - <div class="timeago" v-if="notification.type === 'follow'"> + <div + v-if="notification.type === 'follow'" + class="timeago" + > <span class="faint"> - <timeago :since="notification.created_at" :auto-update="240"></timeago> + <Timeago + :time="notification.created_at" + :auto-update="240" + /> </span> </div> - <div class="timeago" v-else> - <router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }" class="faint-link"> - <timeago :since="notification.created_at" :auto-update="240"></timeago> + <div + v-else + class="timeago" + > + <router-link + v-if="notification.status" + :to="{ name: 'conversation', params: { id: notification.status.id } }" + class="faint-link" + > + <Timeago + :time="notification.created_at" + :auto-update="240" + /> </router-link> </div> </span> - <div class="follow-text" v-if="notification.type === 'follow'"> + <div + v-if="notification.type === 'follow'" + class="follow-text" + > <router-link :to="userProfileLink(notification.from_profile)"> - @{{notification.from_profile.screen_name}} + @{{ notification.from_profile.screen_name }} </router-link> </div> <template v-else> - <status class="faint" :compact="true" :statusoid="notification.action" :noHeading="true"></status> + <status + class="faint" + :compact="true" + :statusoid="notification.action" + :no-heading="true" + /> </template> </div> </div> diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue index c71499b2..c42c35e6 100644 --- a/src/components/notifications/notifications.vue +++ b/src/components/notifications/notifications.vue @@ -1,33 +1,67 @@ <template> - <div :class="{ minimal: minimalMode }" class="notifications"> + <div + :class="{ minimal: minimalMode }" + class="notifications" + > <div :class="mainClass"> - <div v-if="!noHeading" class="panel-heading"> + <div + v-if="!noHeading" + class="panel-heading" + > <div class="title"> - {{$t('notifications.notifications')}} - <span class="badge badge-notification unseen-count" v-if="unseenCount">{{unseenCount}}</span> + {{ $t('notifications.notifications') }} + <span + v-if="unseenCount" + class="badge badge-notification unseen-count" + >{{ unseenCount }}</span> </div> - <div @click.prevent class="loadmore-error alert error" v-if="error"> - {{$t('timeline.error_fetching')}} + <div + v-if="error" + class="loadmore-error alert error" + @click.prevent + > + {{ $t('timeline.error_fetching') }} </div> - <button v-if="unseenCount" @click.prevent="markAsSeen" class="read-button">{{$t('notifications.read')}}</button> + <button + v-if="unseenCount" + class="read-button" + @click.prevent="markAsSeen" + > + {{ $t('notifications.read') }} + </button> </div> <div class="panel-body"> - <div v-for="notification in visibleNotifications" :key="notification.id" class="notification" :class='{"unseen": !minimalMode && !notification.seen}'> - <div class="notification-overlay"></div> - <notification :notification="notification"></notification> + <div + v-for="notification in visibleNotifications" + :key="notification.id" + class="notification" + :class="{"unseen": !minimalMode && !notification.seen}" + > + <div class="notification-overlay" /> + <notification :notification="notification" /> </div> </div> <div class="panel-footer"> - <div v-if="bottomedOut" class="new-status-notification text-center panel-footer faint"> - {{$t('notifications.no_more_notifications')}} + <div + v-if="bottomedOut" + class="new-status-notification text-center panel-footer faint" + > + {{ $t('notifications.no_more_notifications') }} </div> - <a v-else-if="!loading" href="#" v-on:click.prevent="fetchOlderNotifications()"> + <a + v-else-if="!loading" + href="#" + @click.prevent="fetchOlderNotifications()" + > <div class="new-status-notification text-center panel-footer"> - {{ minimalMode ? $t('interactions.load_older') : $t('notifications.load_older')}} + {{ minimalMode ? $t('interactions.load_older') : $t('notifications.load_older') }} </div> </a> - <div v-else class="new-status-notification text-center panel-footer"> - <i class="icon-spin3 animate-spin"/> + <div + v-else + class="new-status-notification text-center panel-footer" + > + <i class="icon-spin3 animate-spin" /> </div> </div> </div> diff --git a/src/components/oauth_callback/oauth_callback.js b/src/components/oauth_callback/oauth_callback.js index 2c6ca235..a3c7b7f9 100644 --- a/src/components/oauth_callback/oauth_callback.js +++ b/src/components/oauth_callback/oauth_callback.js @@ -4,10 +4,11 @@ const oac = { props: ['code'], mounted () { if (this.code) { - const { clientId } = this.$store.state.oauth + const { clientId, clientSecret } = this.$store.state.oauth oauth.getToken({ clientId, + clientSecret, instance: this.$store.state.instance.server, code: this.code }).then((result) => { diff --git a/src/components/opacity_input/opacity_input.vue b/src/components/opacity_input/opacity_input.vue index 3926915b..c677f18c 100644 --- a/src/components/opacity_input/opacity_input.vue +++ b/src/components/opacity_input/opacity_input.vue @@ -1,27 +1,39 @@ <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> + <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'" + :id="name + '-o'" + class="opt exclude-disabled" + type="checkbox" + :checked="present" + @input="$emit('input', !present ? fallback : undefined)" + > + <label + v-if="typeof fallback !== 'undefined'" + class="opt-l" + :for="name + '-o'" + /> + <input + :id="name" + class="input-number" + type="number" + :value="value || fallback" + :disabled="!present || disabled" + max="1" + min="0" + step=".05" + @input="$emit('input', $event.target.value)" + > + </div> </template> <script> diff --git a/src/components/poll/poll.js b/src/components/poll/poll.js new file mode 100644 index 00000000..98db5582 --- /dev/null +++ b/src/components/poll/poll.js @@ -0,0 +1,112 @@ +import Timeago from '../timeago/timeago.vue' +import { forEach, map } from 'lodash' + +export default { + name: 'Poll', + props: ['basePoll'], + components: { Timeago }, + data () { + return { + loading: false, + choices: [] + } + }, + created () { + if (!this.$store.state.polls.pollsObject[this.pollId]) { + this.$store.dispatch('mergeOrAddPoll', this.basePoll) + } + this.$store.dispatch('trackPoll', this.pollId) + }, + destroyed () { + this.$store.dispatch('untrackPoll', this.pollId) + }, + computed: { + pollId () { + return this.basePoll.id + }, + poll () { + const storePoll = this.$store.state.polls.pollsObject[this.pollId] + return storePoll || {} + }, + options () { + return (this.poll && this.poll.options) || [] + }, + expiresAt () { + return (this.poll && this.poll.expires_at) || 0 + }, + expired () { + return (this.poll && this.poll.expired) || false + }, + loggedIn () { + return this.$store.state.users.currentUser + }, + showResults () { + return this.poll.voted || this.expired || !this.loggedIn + }, + totalVotesCount () { + return this.poll.votes_count + }, + containerClass () { + return { + loading: this.loading + } + }, + choiceIndices () { + // Convert array of booleans into an array of indices of the + // items that were 'true', so [true, false, false, true] becomes + // [0, 3]. + return this.choices + .map((entry, index) => entry && index) + .filter(value => typeof value === 'number') + }, + isDisabled () { + const noChoice = this.choiceIndices.length === 0 + return this.loading || noChoice + } + }, + methods: { + percentageForOption (count) { + return this.totalVotesCount === 0 ? 0 : Math.round(count / this.totalVotesCount * 100) + }, + resultTitle (option) { + return `${option.votes_count}/${this.totalVotesCount} ${this.$t('polls.votes')}` + }, + fetchPoll () { + this.$store.dispatch('refreshPoll', { id: this.statusId, pollId: this.poll.id }) + }, + activateOption (index) { + // forgive me father: doing checking the radio/checkboxes + // in code because of customized input elements need either + // a) an extra element for the actual graphic, or b) use a + // pseudo element for the label. We use b) which mandates + // using "for" and "id" matching which isn't nice when the + // same poll appears multiple times on the site (notifs and + // timeline for example). With code we can make sure it just + // works without altering the pseudo element implementation. + const allElements = this.$el.querySelectorAll('input') + const clickedElement = this.$el.querySelector(`input[value="${index}"]`) + if (this.poll.multiple) { + // Checkboxes, toggle only the clicked one + clickedElement.checked = !clickedElement.checked + } else { + // Radio button, uncheck everything and check the clicked one + forEach(allElements, element => { element.checked = false }) + clickedElement.checked = true + } + this.choices = map(allElements, e => e.checked) + }, + optionId (index) { + return `poll${this.poll.id}-${index}` + }, + vote () { + if (this.choiceIndices.length === 0) return + this.loading = true + this.$store.dispatch( + 'votePoll', + { id: this.statusId, pollId: this.poll.id, choices: this.choiceIndices } + ).then(poll => { + this.loading = false + }) + } + } +} diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue new file mode 100644 index 00000000..db8e33b3 --- /dev/null +++ b/src/components/poll/poll.vue @@ -0,0 +1,134 @@ +<template> + <div + class="poll" + :class="containerClass" + > + <div + v-for="(option, index) in options" + :key="index" + class="poll-option" + > + <div + v-if="showResults" + :title="resultTitle(option)" + class="option-result" + > + <div class="option-result-label"> + <span class="result-percentage"> + {{ percentageForOption(option.votes_count) }}% + </span> + <span>{{ option.title }}</span> + </div> + <div + class="result-fill" + :style="{ 'width': `${percentageForOption(option.votes_count)}%` }" + /> + </div> + <div + v-else + @click="activateOption(index)" + > + <input + v-if="poll.multiple" + type="checkbox" + :disabled="loading" + :value="index" + > + <input + v-else + type="radio" + :disabled="loading" + :value="index" + > + <label class="option-vote"> + <div>{{ option.title }}</div> + </label> + </div> + </div> + <div class="footer faint"> + <button + v-if="!showResults" + class="btn btn-default poll-vote-button" + type="button" + :disabled="isDisabled" + @click="vote" + > + {{ $t('polls.vote') }} + </button> + <div class="total"> + {{ totalVotesCount }} {{ $t("polls.votes") }} · + </div> + <i18n :path="expired ? 'polls.expired' : 'polls.expires_in'"> + <Timeago + :time="expiresAt" + :auto-update="60" + :now-threshold="0" + /> + </i18n> + </div> + </div> +</template> + +<script src="./poll.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.poll { + .votes { + display: flex; + flex-direction: column; + margin: 0 0 0.5em; + } + .poll-option { + margin: 0.75em 0.5em; + } + .option-result { + height: 100%; + display: flex; + flex-direction: row; + position: relative; + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + } + .option-result-label { + display: flex; + align-items: center; + padding: 0.1em 0.25em; + z-index: 1; + } + .result-percentage { + width: 3.5em; + flex-shrink: 0; + } + .result-fill { + height: 100%; + position: absolute; + background-color: $fallback--lightBg; + background-color: var(--linkBg, $fallback--lightBg); + border-radius: $fallback--panelRadius; + border-radius: var(--panelRadius, $fallback--panelRadius); + top: 0; + left: 0; + transition: width 0.5s; + } + .option-vote { + display: flex; + align-items: center; + } + input { + width: 3.5em; + } + .footer { + display: flex; + align-items: center; + } + &.loading * { + cursor: progress; + } + .poll-vote-button { + padding: 0 0.5em; + margin-right: 0.5em; + } +} +</style> diff --git a/src/components/poll/poll_form.js b/src/components/poll/poll_form.js new file mode 100644 index 00000000..c0c1ccf7 --- /dev/null +++ b/src/components/poll/poll_form.js @@ -0,0 +1,121 @@ +import * as DateUtils from 'src/services/date_utils/date_utils.js' +import { uniq } from 'lodash' + +export default { + name: 'PollForm', + props: ['visible'], + data: () => ({ + pollType: 'single', + options: ['', ''], + expiryAmount: 10, + expiryUnit: 'minutes' + }), + computed: { + pollLimits () { + return this.$store.state.instance.pollLimits + }, + maxOptions () { + return this.pollLimits.max_options + }, + maxLength () { + return this.pollLimits.max_option_chars + }, + expiryUnits () { + const allUnits = ['minutes', 'hours', 'days'] + const expiry = this.convertExpiryFromUnit + return allUnits.filter( + unit => this.pollLimits.max_expiration >= expiry(unit, 1) + ) + }, + minExpirationInCurrentUnit () { + return Math.ceil( + this.convertExpiryToUnit( + this.expiryUnit, + this.pollLimits.min_expiration + ) + ) + }, + maxExpirationInCurrentUnit () { + return Math.floor( + this.convertExpiryToUnit( + this.expiryUnit, + this.pollLimits.max_expiration + ) + ) + } + }, + methods: { + clear () { + this.pollType = 'single' + this.options = ['', ''] + this.expiryAmount = 10 + this.expiryUnit = 'minutes' + }, + nextOption (index) { + const element = this.$el.querySelector(`#poll-${index + 1}`) + if (element) { + element.focus() + } else { + // Try adding an option and try focusing on it + const addedOption = this.addOption() + if (addedOption) { + this.$nextTick(function () { + this.nextOption(index) + }) + } + } + }, + addOption () { + if (this.options.length < this.maxOptions) { + this.options.push('') + return true + } + return false + }, + deleteOption (index, event) { + if (this.options.length > 2) { + this.options.splice(index, 1) + } + }, + convertExpiryToUnit (unit, amount) { + // Note: we want seconds and not milliseconds + switch (unit) { + case 'minutes': return (1000 * amount) / DateUtils.MINUTE + case 'hours': return (1000 * amount) / DateUtils.HOUR + case 'days': return (1000 * amount) / DateUtils.DAY + } + }, + convertExpiryFromUnit (unit, amount) { + // Note: we want seconds and not milliseconds + switch (unit) { + case 'minutes': return 0.001 * amount * DateUtils.MINUTE + case 'hours': return 0.001 * amount * DateUtils.HOUR + case 'days': return 0.001 * amount * DateUtils.DAY + } + }, + expiryAmountChange () { + this.expiryAmount = + Math.max(this.minExpirationInCurrentUnit, this.expiryAmount) + this.expiryAmount = + Math.min(this.maxExpirationInCurrentUnit, this.expiryAmount) + this.updatePollToParent() + }, + updatePollToParent () { + const expiresIn = this.convertExpiryFromUnit( + this.expiryUnit, + this.expiryAmount + ) + + const options = uniq(this.options.filter(option => option !== '')) + if (options.length < 2) { + this.$emit('update-poll', { error: this.$t('polls.not_enough_options') }) + return + } + this.$emit('update-poll', { + options, + multiple: this.pollType === 'multiple', + expiresIn + }) + } + } +} diff --git a/src/components/poll/poll_form.vue b/src/components/poll/poll_form.vue new file mode 100644 index 00000000..d53f3837 --- /dev/null +++ b/src/components/poll/poll_form.vue @@ -0,0 +1,163 @@ +<template> + <div + v-if="visible" + class="poll-form" + > + <div + v-for="(option, index) in options" + :key="index" + class="poll-option" + > + <div class="input-container"> + <input + :id="`poll-${index}`" + v-model="options[index]" + class="poll-option-input" + type="text" + :placeholder="$t('polls.option')" + :maxlength="maxLength" + @change="updatePollToParent" + @keydown.enter.stop.prevent="nextOption(index)" + > + </div> + <div + v-if="options.length > 2" + class="icon-container" + > + <i + class="icon-cancel" + @click="deleteOption(index)" + /> + </div> + </div> + <a + v-if="options.length < maxOptions" + class="add-option faint" + @click="addOption" + > + <i class="icon-plus" /> + {{ $t("polls.add_option") }} + </a> + <div class="poll-type-expiry"> + <div + class="poll-type" + :title="$t('polls.type')" + > + <label + for="poll-type-selector" + class="select" + > + <select + v-model="pollType" + class="select" + @change="updatePollToParent" + > + <option value="single">{{ $t('polls.single_choice') }}</option> + <option value="multiple">{{ $t('polls.multiple_choices') }}</option> + </select> + <i class="icon-down-open" /> + </label> + </div> + <div + class="poll-expiry" + :title="$t('polls.expiry')" + > + <input + v-model="expiryAmount" + type="number" + class="expiry-amount hide-number-spinner" + :min="minExpirationInCurrentUnit" + :max="maxExpirationInCurrentUnit" + @change="expiryAmountChange" + > + <label class="expiry-unit select"> + <select + v-model="expiryUnit" + @change="expiryAmountChange" + > + <option + v-for="unit in expiryUnits" + :key="unit" + :value="unit" + > + {{ $t(`time.${unit}_short`, ['']) }} + </option> + </select> + <i class="icon-down-open" /> + </label> + </div> + </div> + </div> +</template> + +<script src="./poll_form.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.poll-form { + display: flex; + flex-direction: column; + padding: 0 0.5em 0.5em; + + .add-option { + align-self: flex-start; + padding-top: 0.25em; + cursor: pointer; + } + + .poll-option { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 0.25em; + } + + .input-container { + width: 100%; + input { + // Hack: dodge the floating X icon + padding-right: 2.5em; + width: 100%; + } + } + + .icon-container { + // Hack: Move the icon over the input box + width: 2em; + margin-left: -2em; + z-index: 1; + } + + .poll-type-expiry { + margin-top: 0.5em; + display: flex; + width: 100%; + } + + .poll-type { + margin-right: 0.75em; + flex: 1 1 60%; + .select { + border: none; + box-shadow: none; + background-color: transparent; + } + } + + .poll-expiry { + display: flex; + + .expiry-amount { + width: 3em; + text-align: right; + } + + .expiry-unit { + border: none; + box-shadow: none; + background-color: transparent; + } + } +} +</style> diff --git a/src/components/popper/popper.scss b/src/components/popper/popper.scss index cfc5c8e7..279b01be 100644 --- a/src/components/popper/popper.scss +++ b/src/components/popper/popper.scss @@ -1,71 +1,99 @@ @import '../../_variables.scss'; -.popper-wrapper { +.tooltip.popover { z-index: 8; -} -.popper-wrapper .popper__arrow { - width: 0; - height: 0; - border-style: solid; - position: absolute; - margin: 5px; -} + .popover-inner { + box-shadow: 1px 1px 4px rgba(0,0,0,.6); + box-shadow: var(--panelShadow); + border-radius: $fallback--btnRadius; + border-radius: var(--btnRadius, $fallback--btnRadius); + background-color: $fallback--bg; + background-color: var(--bg, $fallback--bg); + } -.popper-wrapper[x-placement^="top"] { - margin-bottom: 5px; -} + .popover-arrow { + width: 0; + height: 0; + border-style: solid; + position: absolute; + margin: 5px; + border-color: $fallback--bg; + border-color: var(--bg, $fallback--bg); + z-index: 1; + } -.popper-wrapper[x-placement^="top"] .popper__arrow { - border-width: 5px 5px 0 5px; - border-color: $fallback--bg transparent transparent transparent; - border-color: var(--bg, $fallback--bg) transparent transparent transparent; - bottom: -5px; - left: calc(50% - 5px); - margin-top: 0; - margin-bottom: 0; -} + &[x-placement^="top"] { + margin-bottom: 5px; + + .popover-arrow { + border-width: 5px 5px 0 5px; + border-left-color: transparent !important; + border-right-color: transparent !important; + border-bottom-color: transparent !important; + bottom: -5px; + left: calc(50% - 5px); + margin-top: 0; + margin-bottom: 0; + } + } -.popper-wrapper[x-placement^="bottom"] { - margin-top: 5px; -} + &[x-placement^="bottom"] { + margin-top: 5px; + + .popover-arrow { + border-width: 0 5px 5px 5px; + border-left-color: transparent !important; + border-right-color: transparent !important; + border-top-color: transparent !important; + top: -5px; + left: calc(50% - 5px); + margin-top: 0; + margin-bottom: 0; + } + } -.popper-wrapper[x-placement^="bottom"] .popper__arrow { - border-width: 0 5px 5px 5px; - border-color: transparent transparent $fallback--bg transparent; - border-color: transparent transparent var(--bg, $fallback--bg) transparent; - top: -5px; - left: calc(50% - 5px); - margin-top: 0; - margin-bottom: 0; -} + &[x-placement^="right"] { + margin-left: 5px; + + .popover-arrow { + border-width: 5px 5px 5px 0; + border-left-color: transparent !important; + border-top-color: transparent !important; + border-bottom-color: transparent !important; + left: -5px; + top: calc(50% - 5px); + margin-left: 0; + margin-right: 0; + } + } -.popper-wrapper[x-placement^="right"] { - margin-left: 5px; -} + &[x-placement^="left"] { + margin-right: 5px; -.popper-wrapper[x-placement^="right"] .popper__arrow { - border-width: 5px 5px 5px 0; - border-color: transparent $fallback--bg transparent transparent; - border-color: transparent var(--bg, $fallback--bg) transparent transparent; - left: -5px; - top: calc(50% - 5px); - margin-left: 0; - margin-right: 0; -} + .popover-arrow { + border-width: 5px 0 5px 5px; + border-top-color: transparent !important; + border-right-color: transparent !important; + border-bottom-color: transparent !important; + right: -5px; + top: calc(50% - 5px); + margin-left: 0; + margin-right: 0; + } + } -.popper-wrapper[x-placement^="left"] { - margin-right: 5px; -} + &[aria-hidden='true'] { + visibility: hidden; + opacity: 0; + transition: opacity .15s, visibility .15s; + } -.popper-wrapper[x-placement^="left"] .popper__arrow { - border-width: 5px 0 5px 5px; - border-color: transparent transparent transparent $fallback--bg; - border-color: transparent transparent transparent var(--bg, $fallback--bg); - right: -5px; - top: calc(50% - 5px); - margin-left: 0; - margin-right: 0; + &[aria-hidden='false'] { + visibility: visible; + opacity: 1; + transition: opacity .15s; + } } .dropdown-menu { @@ -76,13 +104,6 @@ list-style: none; max-width: 100vw; z-index: 10; - box-shadow: 1px 1px 4px rgba(0,0,0,.6); - box-shadow: var(--panelShadow); - border: none; - border-radius: $fallback--btnRadius; - border-radius: var(--btnRadius, $fallback--btnRadius); - background-color: $fallback--bg; - background-color: var(--bg, $fallback--bg); .dropdown-divider { height: 0; diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index cbd2024a..40bbf6d4 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -2,17 +2,19 @@ import statusPoster from '../../services/status_poster/status_poster.service.js' import MediaUpload from '../media_upload/media_upload.vue' import ScopeSelector from '../scope_selector/scope_selector.vue' import EmojiInput from '../emoji-input/emoji-input.vue' +import PollForm from '../poll/poll_form.vue' +import StickerPicker from '../sticker_picker/sticker_picker.vue' import fileTypeService from '../../services/file_type/file_type.service.js' -import Completion from '../../services/completion/completion.js' -import { take, filter, reject, map, uniqBy } from 'lodash' +import { reject, map, uniqBy } from 'lodash' +import suggestor from '../emoji-input/suggestor.js' -const buildMentionsString = ({user, attentions}, currentUser) => { +const buildMentionsString = ({ user, attentions }, currentUser) => { let allAttentions = [...attentions] allAttentions.unshift(user) allAttentions = uniqBy(allAttentions, 'id') - allAttentions = reject(allAttentions, {id: currentUser.id}) + allAttentions = reject(allAttentions, { id: currentUser.id }) let mentions = map(allAttentions, (attention) => { return `@${attention.screen_name}` @@ -31,8 +33,10 @@ const PostStatusForm = { ], components: { MediaUpload, - ScopeSelector, - EmojiInput + EmojiInput, + PollForm, + StickerPicker, + ScopeSelector }, mounted () { this.resize(this.$refs.textarea) @@ -48,17 +52,17 @@ const PostStatusForm = { let statusText = preset || '' const scopeCopy = typeof this.$store.state.config.scopeCopy === 'undefined' - ? this.$store.state.instance.scopeCopy - : this.$store.state.config.scopeCopy + ? this.$store.state.instance.scopeCopy + : this.$store.state.config.scopeCopy if (this.replyTo) { const currentUser = this.$store.state.users.currentUser statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser) } - const scope = (this.copyMessageScope && scopeCopy || this.copyMessageScope === 'direct') - ? this.copyMessageScope - : this.$store.state.users.currentUser.default_scope + const scope = ((this.copyMessageScope && scopeCopy) || this.copyMessageScope === 'direct') + ? this.copyMessageScope + : this.$store.state.users.currentUser.default_scope const contentType = typeof this.$store.state.config.postContentType === 'undefined' ? this.$store.state.instance.postContentType @@ -75,57 +79,16 @@ const PostStatusForm = { status: statusText, nsfw: false, files: [], + poll: {}, visibility: scope, contentType }, - caret: 0 + caret: 0, + pollFormVisible: false, + stickerPickerVisible: false } }, computed: { - candidates () { - const firstchar = this.textAtCaret.charAt(0) - if (firstchar === '@') { - 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 - } - // eslint-disable-next-line camelcase - return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}, index) => ({ - // eslint-disable-next-line camelcase - screen_name: `@${screen_name}`, - name: name, - img: profile_image_url_original, - highlighted: index === this.highlighted - })) - } else if (firstchar === ':') { - if (this.textAtCaret === ':') { return } - 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) => ({ - screen_name: `:${shortcode}:`, - name: '', - utf: utf || '', - // eslint-disable-next-line camelcase - img: utf ? '' : this.$store.state.instance.server + image_url, - highlighted: index === this.highlighted - })) - } else { - return false - } - }, - textAtCaret () { - return (this.wordAtCaret || {}).word || '' - }, - wordAtCaret () { - const word = Completion.wordAtPosition(this.newStatus.status, this.caret - 1) || {} - return word - }, users () { return this.$store.state.users.users }, @@ -134,10 +97,28 @@ const PostStatusForm = { }, showAllScopes () { const minimalScopesMode = typeof this.$store.state.config.minimalScopesMode === 'undefined' - ? this.$store.state.instance.minimalScopesMode - : this.$store.state.config.minimalScopesMode + ? this.$store.state.instance.minimalScopesMode + : this.$store.state.config.minimalScopesMode return !minimalScopesMode }, + emojiUserSuggestor () { + return suggestor({ + emoji: [ + ...this.$store.state.instance.emoji, + ...this.$store.state.instance.customEmoji + ], + users: this.$store.state.users.users, + updateUsersList: (input) => this.$store.dispatch('searchUsers', input) + }) + }, + emojiSuggestor () { + return suggestor({ + emoji: [ + ...this.$store.state.instance.emoji, + ...this.$store.state.instance.customEmoji + ] + }) + }, emoji () { return this.$store.state.instance.emoji || [] }, @@ -174,71 +155,32 @@ const PostStatusForm = { return true } }, - formattingOptionsEnabled () { - return this.$store.state.instance.formattingOptionsEnabled - }, postFormats () { return this.$store.state.instance.postFormats || [] }, safeDMEnabled () { return this.$store.state.instance.safeDM }, + stickersAvailable () { + if (this.$store.state.instance.stickers) { + return this.$store.state.instance.stickers.length > 0 + } + return 0 + }, + pollsAvailable () { + return this.$store.state.instance.pollsAvailable && + this.$store.state.instance.pollLimits.max_options >= 2 + }, hideScopeNotice () { return this.$store.state.config.hideScopeNotice + }, + pollContentError () { + return this.pollFormVisible && + this.newStatus.poll && + this.newStatus.poll.error } }, methods: { - replace (replacement) { - this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement) - const el = this.$el.querySelector('textarea') - el.focus() - this.caret = 0 - }, - replaceCandidate (e) { - const len = this.candidates.length || 0 - if (this.textAtCaret === ':' || e.ctrlKey) { return } - if (len > 0) { - e.preventDefault() - const candidate = this.candidates[this.highlighted] - const replacement = candidate.utf || (candidate.screen_name + ' ') - this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement) - const el = this.$el.querySelector('textarea') - el.focus() - this.caret = 0 - this.highlighted = 0 - } - }, - cycleBackward (e) { - const len = this.candidates.length || 0 - if (len > 0) { - e.preventDefault() - this.highlighted -= 1 - if (this.highlighted < 0) { - this.highlighted = this.candidates.length - 1 - } - } else { - this.highlighted = 0 - } - }, - cycleForward (e) { - const len = this.candidates.length || 0 - if (len > 0) { - if (e.shiftKey) { return } - e.preventDefault() - this.highlighted += 1 - if (this.highlighted >= len) { - this.highlighted = 0 - } - } else { - this.highlighted = 0 - } - }, - onKeydown (e) { - e.stopPropagation() - }, - setCaret ({target: {selectionStart}}) { - this.caret = selectionStart - }, postStatus (newStatus) { if (this.posting) { return } if (this.submitDisabled) { return } @@ -252,6 +194,12 @@ const PostStatusForm = { } } + const poll = this.pollFormVisible ? this.newStatus.poll : {} + if (this.pollContentError) { + this.error = this.pollContentError + return + } + this.posting = true statusPoster.postStatus({ status: newStatus.status, @@ -261,7 +209,8 @@ const PostStatusForm = { media: newStatus.files, store: this.$store, inReplyToStatusId: this.replyTo, - contentType: newStatus.contentType + contentType: newStatus.contentType, + poll }).then((data) => { if (!data.error) { this.newStatus = { @@ -269,9 +218,13 @@ const PostStatusForm = { spoilerText: '', files: [], visibility: newStatus.visibility, - contentType: newStatus.contentType + contentType: newStatus.contentType, + poll: {} } + this.pollFormVisible = false + this.stickerPickerVisible = false this.$refs.mediaUpload.clearFile() + this.clearPollForm() this.$emit('posted') let el = this.$el.querySelector('textarea') el.style.height = 'auto' @@ -286,6 +239,7 @@ const PostStatusForm = { addMediaFile (fileInfo) { this.newStatus.files.push(fileInfo) this.enableSubmit() + this.stickerPickerVisible = false }, removeMediaFile (fileInfo) { let index = this.newStatus.files.indexOf(fileInfo) @@ -317,7 +271,7 @@ const PostStatusForm = { }, fileDrop (e) { if (e.dataTransfer.files.length > 0) { - e.preventDefault() // allow dropping text like before + e.preventDefault() // allow dropping text like before this.dropFiles = e.dataTransfer.files } }, @@ -327,8 +281,11 @@ const PostStatusForm = { resize (e) { const target = e.target || e if (!(target instanceof window.Element)) { return } - const vertPadding = Number(window.getComputedStyle(target)['padding-top'].substr(0, 1)) + - Number(window.getComputedStyle(target)['padding-bottom'].substr(0, 1)) + const topPaddingStr = window.getComputedStyle(target)['padding-top'] + const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom'] + // Remove "px" at the end of the values + const vertPadding = Number(topPaddingStr.substr(0, topPaddingStr.length - 2)) + + Number(bottomPaddingStr.substr(0, bottomPaddingStr.length - 2)) // Auto is needed to make textbox shrink when removing lines target.style.height = 'auto' target.style.height = `${target.scrollHeight - vertPadding}px` @@ -342,6 +299,25 @@ const PostStatusForm = { changeVis (visibility) { this.newStatus.visibility = visibility }, + toggleStickerPicker () { + this.stickerPickerVisible = !this.stickerPickerVisible + }, + clearStickerPicker () { + if (this.$refs.stickerPicker) { + this.$refs.stickerPicker.clear() + } + }, + togglePollForm () { + this.pollFormVisible = !this.pollFormVisible + }, + setPoll (poll) { + this.newStatus.poll = poll + }, + clearPollForm () { + if (this.$refs.pollForm) { + this.$refs.pollForm.clear() + } + }, dismissScopeNotice () { this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true }) } diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 25c5284f..d29d47e4 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -1,127 +1,268 @@ <template> -<div class="post-status-form"> - <form @submit.prevent="postStatus(newStatus)"> - <div class="form-group" > - <i18n - v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'" - path="post_status.account_not_locked_warning" - tag="p" - class="visibility-notice"> - <router-link :to="{ name: 'user-settings' }">{{ $t('post_status.account_not_locked_warning_link') }}</router-link> - </i18n> - <p v-if="!hideScopeNotice && newStatus.visibility === 'public'" class="visibility-notice notice-dismissible"> - <span>{{ $t('post_status.scope_notice.public') }}</span> - <a v-on:click.prevent="dismissScopeNotice()" class="button-icon dismiss"> - <i class='icon-cancel'></i> - </a> - </p> - <p v-else-if="!hideScopeNotice && newStatus.visibility === 'unlisted'" class="visibility-notice notice-dismissible"> - <span>{{ $t('post_status.scope_notice.unlisted') }}</span> - <a v-on:click.prevent="dismissScopeNotice()" class="button-icon dismiss"> - <i class='icon-cancel'></i> - </a> - </p> - <p v-else-if="!hideScopeNotice && newStatus.visibility === 'private' && $store.state.users.currentUser.locked" class="visibility-notice notice-dismissible"> - <span>{{ $t('post_status.scope_notice.private') }}</span> - <a v-on:click.prevent="dismissScopeNotice()" class="button-icon dismiss"> - <i class='icon-cancel'></i> - </a> - </p> - <p v-else-if="newStatus.visibility === 'direct'" class="visibility-notice"> - <span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span> - <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span> - </p> - <EmojiInput - v-if="newStatus.spoilerText || alwaysShowSubject" - type="text" - :placeholder="$t('post_status.content_warning')" - v-model="newStatus.spoilerText" - classname="form-control" - /> - <textarea - ref="textarea" - @click="setCaret" - @keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control" - @keydown="onKeydown" - @keydown.down="cycleForward" - @keydown.up="cycleBackward" - @keydown.shift.tab="cycleBackward" - @keydown.tab="cycleForward" - @keydown.enter="replaceCandidate" - @keydown.meta.enter="postStatus(newStatus)" - @keyup.ctrl.enter="postStatus(newStatus)" - @drop="fileDrop" - @dragover.prevent="fileDrag" - @input="resize" - @paste="paste" - :disabled="posting" - > - </textarea> - <div class="visibility-tray"> - <div 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 v-for="postFormat in postFormats" :key="postFormat" :value="postFormat"> - {{$t(`post_status.content_type["${postFormat}"]`)}} - </option> - </select> - <i class="icon-down-open"></i> - </label> - </div> + <div class="post-status-form"> + <form + autocomplete="off" + @submit.prevent="postStatus(newStatus)" + > + <div class="form-group"> + <i18n + v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'" + path="post_status.account_not_locked_warning" + tag="p" + class="visibility-notice" + > + <router-link :to="{ name: 'user-settings' }"> + {{ $t('post_status.account_not_locked_warning_link') }} + </router-link> + </i18n> + <p + v-if="!hideScopeNotice && newStatus.visibility === 'public'" + class="visibility-notice notice-dismissible" + > + <span>{{ $t('post_status.scope_notice.public') }}</span> + <a + class="button-icon dismiss" + @click.prevent="dismissScopeNotice()" + > + <i class="icon-cancel" /> + </a> + </p> + <p + v-else-if="!hideScopeNotice && newStatus.visibility === 'unlisted'" + class="visibility-notice notice-dismissible" + > + <span>{{ $t('post_status.scope_notice.unlisted') }}</span> + <a + class="button-icon dismiss" + @click.prevent="dismissScopeNotice()" + > + <i class="icon-cancel" /> + </a> + </p> + <p + v-else-if="!hideScopeNotice && newStatus.visibility === 'private' && $store.state.users.currentUser.locked" + class="visibility-notice notice-dismissible" + > + <span>{{ $t('post_status.scope_notice.private') }}</span> + <a + class="button-icon dismiss" + @click.prevent="dismissScopeNotice()" + > + <i class="icon-cancel" /> + </a> + </p> + <p + v-else-if="newStatus.visibility === 'direct'" + class="visibility-notice" + > + <span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span> + <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span> + </p> + <EmojiInput + v-if="newStatus.spoilerText || alwaysShowSubject" + v-model="newStatus.spoilerText" + :suggest="emojiSuggestor" + class="form-control" + > + <input - <scope-selector - :showAll="showAllScopes" - :userDefault="userDefaultScope" - :originalScope="copyMessageScope" - :initialScope="newStatus.visibility" - :onScopeChange="changeVis"/> - </div> - </div> - <div class="autocomplete-panel" v-if="candidates"> - <div class="autocomplete-panel-body"> + v-model="newStatus.spoilerText" + type="text" + :placeholder="$t('post_status.content_warning')" + class="form-post-subject" + > + </EmojiInput> + <EmojiInput + v-model="newStatus.status" + :suggest="emojiUserSuggestor" + class="form-control main-input" + > + <textarea + ref="textarea" + v-model="newStatus.status" + :placeholder="$t('post_status.default')" + rows="1" + :disabled="posting" + class="form-post-body" + @keydown.meta.enter="postStatus(newStatus)" + @keyup.ctrl.enter="postStatus(newStatus)" + @drop="fileDrop" + @dragover.prevent="fileDrag" + @input="resize" + @paste="paste" + /> + <p + v-if="hasStatusLengthLimit" + class="character-counter faint" + :class="{ error: isOverLengthLimit }" + > + {{ charactersLeft }} + </p> + </EmojiInput> + <div class="visibility-tray"> + <scope-selector + :show-all="showAllScopes" + :user-default="userDefaultScope" + :original-scope="copyMessageScope" + :initial-scope="newStatus.visibility" + :on-scope-change="changeVis" + /> + + <div + v-if="postFormats.length > 1" + class="text-format" + > + <label + for="post-content-type" + class="select" + > + <select + id="post-content-type" + v-model="newStatus.contentType" + class="form-control" + > + <option + v-for="postFormat in postFormats" + :key="postFormat" + :value="postFormat" + > + {{ $t(`post_status.content_type["${postFormat}"]`) }} + </option> + </select> + <i class="icon-down-open" /> + </label> + </div> <div - v-for="(candidate, index) in candidates" - :key="index" - @click="replace(candidate.utf || (candidate.screen_name + ' '))" - class="autocomplete-item" - :class="{ highlighted: candidate.highlighted }" + v-if="postFormats.length === 1 && postFormats[0] !== 'text/plain'" + class="text-format" > - <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> + <span class="only-format"> + {{ $t(`post_status.content_type["${postFormats[0]}"]`) }} + </span> </div> </div> </div> - <div class='form-bottom'> - <media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload> - - <p v-if="isOverLengthLimit" class="error">{{ charactersLeft }}</p> - <p class="faint" v-else-if="hasStatusLengthLimit">{{ charactersLeft }}</p> - - <button v-if="posting" disabled class="btn btn-default">{{$t('post_status.posting')}}</button> - <button v-else-if="isOverLengthLimit" disabled class="btn btn-default">{{$t('general.submit')}}</button> - <button v-else :disabled="submitDisabled" type="submit" class="btn btn-default">{{$t('general.submit')}}</button> + <poll-form + v-if="pollsAvailable" + ref="pollForm" + :visible="pollFormVisible" + @update-poll="setPoll" + /> + <div class="form-bottom"> + <div class="form-bottom-left"> + <media-upload + ref="mediaUpload" + :drop-files="dropFiles" + @uploading="disableSubmit" + @uploaded="addMediaFile" + @upload-failed="uploadFailed" + /> + <div + v-if="stickersAvailable" + class="sticker-icon" + > + <i + :title="$t('stickers.add_sticker')" + class="icon-picture btn btn-default" + :class="{ selected: stickerPickerVisible }" + @click="toggleStickerPicker" + /> + </div> + <div + v-if="pollsAvailable" + class="poll-icon" + > + <i + :title="$t('polls.add_poll')" + class="icon-chart-bar btn btn-default" + :class="pollFormVisible && 'selected'" + @click="togglePollForm" + /> + </div> + </div> + <button + v-if="posting" + disabled + class="btn btn-default" + > + {{ $t('post_status.posting') }} + </button> + <button + v-else-if="isOverLengthLimit" + disabled + class="btn btn-default" + > + {{ $t('general.submit') }} + </button> + <button + v-else + :disabled="submitDisabled" + type="submit" + class="btn btn-default" + > + {{ $t('general.submit') }} + </button> </div> - <div class='alert error' v-if="error"> + <div + v-if="error" + class="alert error" + > Error: {{ error }} - <i class="button-icon icon-cancel" @click="clearError"></i> + <i + class="button-icon icon-cancel" + @click="clearError" + /> </div> <div class="attachments"> - <div class="media-upload-wrapper" v-for="file in newStatus.files"> - <i class="fa button-icon icon-cancel" @click="removeMediaFile(file)"></i> + <div + v-for="file in newStatus.files" + :key="file.url" + class="media-upload-wrapper" + > + <i + class="fa button-icon icon-cancel" + @click="removeMediaFile(file)" + /> <div class="media-upload-container attachment"> - <img class="thumbnail media-upload" :src="file.url" v-if="type(file) === 'image'"></img> - <video v-if="type(file) === 'video'" :src="file.url" controls></video> - <audio v-if="type(file) === 'audio'" :src="file.url" controls></audio> - <a v-if="type(file) === 'unknown'" :href="file.url">{{file.url}}</a> + <img + v-if="type(file) === 'image'" + class="thumbnail media-upload" + :src="file.url" + > + <video + v-if="type(file) === 'video'" + :src="file.url" + controls + /> + <audio + v-if="type(file) === 'audio'" + :src="file.url" + controls + /> + <a + v-if="type(file) === 'unknown'" + :href="file.url" + >{{ 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 + v-if="newStatus.files.length > 0" + class="upload_settings" + > + <input + id="filesSensitive" + v-model="newStatus.nsfw" + type="checkbox" + > + <label for="filesSensitive">{{ $t('post_status.attachments_sensitive') }}</label> </div> </form> + <sticker-picker + v-if="stickerPickerVisible" + ref="stickerPicker" + @uploaded="addMediaFile" + /> </div> </template> @@ -151,7 +292,6 @@ .visibility-tray { display: flex; justify-content: space-between; - flex-direction: row-reverse; padding-top: 5px; } } @@ -173,6 +313,37 @@ } } + .form-bottom-left { + display: flex; + flex: 1; + } + + .text-format { + .only-format { + color: $fallback--faint; + color: var(--faint, $fallback--faint); + } + } + + .poll-icon, .sticker-icon { + font-size: 26px; + flex: 1; + + .selected { + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + } + } + + .sticker-icon { + flex: 0; + min-width: 50px; + } + + .icon-chart-bar { + cursor: pointer; + } + .error { text-align: center; } @@ -233,7 +404,6 @@ } } - .btn { cursor: pointer; } @@ -263,19 +433,38 @@ min-height: 1px; } - form textarea.form-control { - line-height:16px; + .form-post-body { + height: 16px; // Only affects the empty-height + line-height: 16px; resize: none; overflow: hidden; transition: min-height 200ms 100ms; + padding-bottom: 1.75em; min-height: 1px; box-sizing: content-box; } - form textarea.form-control:focus { + .form-post-body:focus { min-height: 48px; } + .main-input { + position: relative; + } + + .character-counter { + position: absolute; + bottom: 0; + right: 0; + padding: 0; + margin: 0 0.5em; + + &.error { + color: $fallback--cRed; + color: var(--cRed, $fallback--cRed); + } + } + .btn { cursor: pointer; } diff --git a/src/components/progress_button/progress_button.vue b/src/components/progress_button/progress_button.vue index 737360bb..283a51af 100644 --- a/src/components/progress_button/progress_button.vue +++ b/src/components/progress_button/progress_button.vue @@ -1,6 +1,9 @@ <template> - <button :disabled="progress || disabled" @click="onClick"> - <template v-if="progress"> + <button + :disabled="progress || disabled" + @click="onClick" + > + <template v-if="progress && $slots.progress"> <slot name="progress" /> </template> <template v-else> diff --git a/src/components/public_and_external_timeline/public_and_external_timeline.vue b/src/components/public_and_external_timeline/public_and_external_timeline.vue index 6be9f955..fcd915ac 100644 --- a/src/components/public_and_external_timeline/public_and_external_timeline.vue +++ b/src/components/public_and_external_timeline/public_and_external_timeline.vue @@ -1,5 +1,9 @@ <template> - <Timeline :title="$t('nav.twkn')" v-bind:timeline="timeline" v-bind:timeline-name="'publicAndExternal'"/> + <Timeline + :title="$t('nav.twkn')" + :timeline="timeline" + :timeline-name="'publicAndExternal'" + /> </template> <script src="./public_and_external_timeline.js"></script> diff --git a/src/components/public_timeline/public_timeline.vue b/src/components/public_timeline/public_timeline.vue index 85d42cca..5720068d 100644 --- a/src/components/public_timeline/public_timeline.vue +++ b/src/components/public_timeline/public_timeline.vue @@ -1,5 +1,9 @@ <template> - <Timeline :title="$t('nav.public_tl')" v-bind:timeline="timeline" v-bind:timeline-name="'public'"/> + <Timeline + :title="$t('nav.public_tl')" + :timeline="timeline" + :timeline-name="'public'" + /> </template> <script src="./public_timeline.js"></script> diff --git a/src/components/range_input/range_input.vue b/src/components/range_input/range_input.vue index 3e50664b..aaa2ed26 100644 --- a/src/components/range_input/range_input.vue +++ b/src/components/range_input/range_input.vue @@ -1,37 +1,50 @@ <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> + <div + class="range-control style-control" + :class="{ disabled: !present || disabled }" + > + <label + :for="name" + class="label" + > + {{ label }} + </label> + <input + v-if="typeof fallback !== 'undefined'" + :id="name + '-o'" + class="opt exclude-disabled" + type="checkbox" + :checked="present" + @input="$emit('input', !present ? fallback : undefined)" + > + <label + v-if="typeof fallback !== 'undefined'" + class="opt-l" + :for="name + '-o'" + /> + <input + :id="name" + class="input-number" + type="range" + :value="value || fallback" + :disabled="!present || disabled" + :max="max || hardMax || 100" + :min="min || hardMin || 0" + :step="step || 1" + @input="$emit('input', $event.target.value)" + > + <input + :id="name" + class="input-number" + type="number" + :value="value || fallback" + :disabled="!present || disabled" + :max="hardMax" + :min="hardMin" + :step="step || 1" + @input="$emit('input', $event.target.value)" + > + </div> </template> <script> diff --git a/src/components/registration/registration.js b/src/components/registration/registration.js index 8dc00420..57f3caf0 100644 --- a/src/components/registration/registration.js +++ b/src/components/registration/registration.js @@ -28,7 +28,7 @@ const registration = { }, created () { if ((!this.registrationOpen && !this.token) || this.signedIn) { - this.$router.push({name: 'root'}) + this.$router.push({ name: 'root' }) } this.setCaptcha() @@ -61,7 +61,7 @@ const registration = { if (!this.$v.$invalid) { try { await this.signUp(this.user) - this.$router.push({name: 'friends'}) + this.$router.push({ name: 'friends' }) } catch (error) { console.warn('Registration failed: ' + error) } diff --git a/src/components/registration/registration.vue b/src/components/registration/registration.vue index 110b27bf..5bb06a4f 100644 --- a/src/components/registration/registration.vue +++ b/src/components/registration/registration.vue @@ -1,109 +1,236 @@ <template> <div class="settings panel panel-default"> <div class="panel-heading"> - {{$t('registration.registration')}} + {{ $t('registration.registration') }} </div> <div class="panel-body"> - <form v-on:submit.prevent='submit(user)' class='registration-form'> - <div class='container'> - <div class='text-fields'> - <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="$t('registration.username_placeholder')"> + <form + class="registration-form" + @submit.prevent="submit(user)" + > + <div class="container"> + <div class="text-fields"> + <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 + id="sign-up-username" + v-model.trim="$v.user.username.$model" + :disabled="isPending" + class="form-control" + :placeholder="$t('registration.username_placeholder')" + > </div> - <div class="form-error" v-if="$v.user.username.$dirty"> + <div + v-if="$v.user.username.$dirty" + class="form-error" + > <ul> <li v-if="!$v.user.username.required"> - <span>{{$t('registration.validations.username_required')}}</span> + <span>{{ $t('registration.validations.username_required') }}</span> </li> </ul> </div> - <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="$t('registration.fullname_placeholder')"> + <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 + id="sign-up-fullname" + v-model.trim="$v.user.fullname.$model" + :disabled="isPending" + class="form-control" + :placeholder="$t('registration.fullname_placeholder')" + > </div> - <div class="form-error" v-if="$v.user.fullname.$dirty"> + <div + v-if="$v.user.fullname.$dirty" + class="form-error" + > <ul> <li v-if="!$v.user.fullname.required"> - <span>{{$t('registration.validations.fullname_required')}}</span> + <span>{{ $t('registration.validations.fullname_required') }}</span> </li> </ul> </div> - <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 + class="form-group" + :class="{ 'form-group--error': $v.user.email.$error }" + > + <label + class="form--label" + for="email" + >{{ $t('registration.email') }}</label> + <input + id="email" + v-model="$v.user.email.$model" + :disabled="isPending" + class="form-control" + type="email" + > </div> - <div class="form-error" v-if="$v.user.email.$dirty"> + <div + v-if="$v.user.email.$dirty" + class="form-error" + > <ul> <li v-if="!$v.user.email.required"> - <span>{{$t('registration.validations.email_required')}}</span> + <span>{{ $t('registration.validations.email_required') }}</span> </li> </ul> </div> - <div class='form-group'> - <label class='form--label' for='bio'>{{$t('registration.bio')}} ({{$t('general.optional')}})</label> - <textarea :disabled="isPending" v-model='user.bio' class='form-control' id='bio' :placeholder="bioPlaceholder"></textarea> + <div class="form-group"> + <label + class="form--label" + for="bio" + >{{ $t('registration.bio') }} ({{ $t('general.optional') }})</label> + <textarea + id="bio" + v-model="user.bio" + :disabled="isPending" + class="form-control" + :placeholder="bioPlaceholder" + /> </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 + class="form-group" + :class="{ 'form-group--error': $v.user.password.$error }" + > + <label + class="form--label" + for="sign-up-password" + >{{ $t('login.password') }}</label> + <input + id="sign-up-password" + v-model="user.password" + :disabled="isPending" + class="form-control" + type="password" + > </div> - <div class="form-error" v-if="$v.user.password.$dirty"> + <div + v-if="$v.user.password.$dirty" + class="form-error" + > <ul> <li v-if="!$v.user.password.required"> - <span>{{$t('registration.validations.password_required')}}</span> + <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 + 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 + id="sign-up-password-confirmation" + v-model="user.confirm" + :disabled="isPending" + class="form-control" + type="password" + > </div> - <div class="form-error" v-if="$v.user.confirm.$dirty"> + <div + v-if="$v.user.confirm.$dirty" + class="form-error" + > <ul> <li v-if="!$v.user.confirm.required"> - <span>{{$t('registration.validations.password_confirmation_required')}}</span> + <span>{{ $t('registration.validations.password_confirmation_required') }}</span> </li> <li v-if="!$v.user.confirm.sameAsPassword"> - <span>{{$t('registration.validations.password_confirmation_match')}}</span> + <span>{{ $t('registration.validations.password_confirmation_match') }}</span> </li> </ul> </div> - <div class="form-group" id="captcha-group" v-if="captcha.type != 'none'"> - <label class='form--label' for='captcha-label'>{{$t('captcha')}}</label> + <div + v-if="captcha.type != 'none'" + id="captcha-group" + class="form-group" + > + <label + class="form--label" + for="captcha-label" + >{{ $t('captcha') }}</label> <template v-if="captcha.type == 'kocaptcha'"> - <img v-bind:src="captcha.url" v-on:click="setCaptcha"> + <img + :src="captcha.url" + @click="setCaptcha" + > - <sub>{{$t('registration.new_captcha')}}</sub> + <sub>{{ $t('registration.new_captcha') }}</sub> - <input :disabled="isPending" - v-model='captcha.solution' - class='form-control' id='captcha-answer' type='text' autocomplete="off"> + <input + id="captcha-answer" + v-model="captcha.solution" + :disabled="isPending" + class="form-control" + type="text" + autocomplete="off" + > </template> </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 + v-if="token" + class="form-group" + > + <label for="token">{{ $t('registration.token') }}</label> + <input + id="token" + v-model="token" + disabled="true" + class="form-control" + type="text" + > </div> - <div class='form-group'> - <button :disabled="isPending" type='submit' class='btn btn-default'>{{$t('general.submit')}}</button> + <div class="form-group"> + <button + :disabled="isPending" + type="submit" + class="btn btn-default" + > + {{ $t('general.submit') }} + </button> </div> </div> - <div class='terms-of-service' v-html="termsOfService"> - </div> + <!-- eslint-disable vue/no-v-html --> + <div + class="terms-of-service" + v-html="termsOfService" + /> + <!-- eslint-enable vue/no-v-html --> </div> - <div v-if="serverValidationErrors.length" class='form-group'> - <div class='alert error'> - <span v-for="error in serverValidationErrors">{{error}}</span> + <div + v-if="serverValidationErrors.length" + class="form-group" + > + <div class="alert error"> + <span + v-for="error in serverValidationErrors" + :key="error" + >{{ error }}</span> </div> </div> </form> @@ -141,6 +268,7 @@ $validations-cRed: #f04124; textarea { min-height: 100px; + resize: vertical; } .form-group { diff --git a/src/components/remote_follow/remote_follow.vue b/src/components/remote_follow/remote_follow.vue index fb2147bd..cb1c2a1b 100644 --- a/src/components/remote_follow/remote_follow.vue +++ b/src/components/remote_follow/remote_follow.vue @@ -1,9 +1,23 @@ <template> <div class="remote-follow"> - <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"> + <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> diff --git a/src/components/retweet_button/retweet_button.js b/src/components/retweet_button/retweet_button.js index eb4e4b41..fb543a9c 100644 --- a/src/components/retweet_button/retweet_button.js +++ b/src/components/retweet_button/retweet_button.js @@ -11,9 +11,9 @@ const RetweetButton = { methods: { retweet () { if (!this.status.repeated) { - this.$store.dispatch('retweet', {id: this.status.id}) + this.$store.dispatch('retweet', { id: this.status.id }) } else { - this.$store.dispatch('unretweet', {id: this.status.id}) + this.$store.dispatch('unretweet', { id: this.status.id }) } this.animated = true setTimeout(() => { diff --git a/src/components/retweet_button/retweet_button.vue b/src/components/retweet_button/retweet_button.vue index 6370f9dc..d58a7f8c 100644 --- a/src/components/retweet_button/retweet_button.vue +++ b/src/components/retweet_button/retweet_button.vue @@ -1,16 +1,29 @@ <template> <div v-if="loggedIn"> <template v-if="visibility !== 'private' && visibility !== 'direct'"> - <i :class='classes' class='button-icon 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> + <i + :class="classes" + class="button-icon retweet-button icon-retweet rt-active" + :title="$t('tool_tip.repeat')" + @click.prevent="retweet()" + /> + <span v-if="!hidePostStatsLocal && status.repeat_num > 0">{{ status.repeat_num }}</span> </template> <template v-else> - <i :class='classes' class='button-icon icon-lock' :title="$t('timeline.no_retweet_hint')"></i> + <i + :class="classes" + class="button-icon icon-lock" + :title="$t('timeline.no_retweet_hint')" + /> </template> </div> <div v-else-if="!loggedIn"> - <i :class='classes' class='button-icon icon-retweet' :title="$t('tool_tip.repeat')"></i> - <span v-if='!hidePostStatsLocal && status.repeat_num > 0'>{{status.repeat_num}}</span> + <i + :class="classes" + class="button-icon icon-retweet" + :title="$t('tool_tip.repeat')" + /> + <span v-if="!hidePostStatsLocal && status.repeat_num > 0">{{ status.repeat_num }}</span> </div> </template> diff --git a/src/components/scope_selector/scope_selector.js b/src/components/scope_selector/scope_selector.js index 8a42ee7b..e9ccdefc 100644 --- a/src/components/scope_selector/scope_selector.js +++ b/src/components/scope_selector/scope_selector.js @@ -29,10 +29,10 @@ const ScopeSelector = { }, css () { return { - public: {selected: this.currentScope === 'public'}, - unlisted: {selected: this.currentScope === 'unlisted'}, - private: {selected: this.currentScope === 'private'}, - direct: {selected: this.currentScope === 'direct'} + public: { selected: this.currentScope === 'public' }, + unlisted: { selected: this.currentScope === 'unlisted' }, + private: { selected: this.currentScope === 'private' }, + direct: { selected: this.currentScope === 'direct' } } } }, diff --git a/src/components/scope_selector/scope_selector.vue b/src/components/scope_selector/scope_selector.vue index 5ebb5d56..291236f2 100644 --- a/src/components/scope_selector/scope_selector.vue +++ b/src/components/scope_selector/scope_selector.vue @@ -1,30 +1,37 @@ <template> -<div v-if="!showNothing" class="scope-selector"> - <i class="icon-mail-alt" - :class="css.direct" - :title="$t('post_status.scope.direct')" - v-if="showDirect" - @click="changeVis('direct')"> - </i> - <i class="icon-lock" - :class="css.private" - :title="$t('post_status.scope.private')" - v-if="showPrivate" - v-on:click="changeVis('private')"> - </i> - <i class="icon-lock-open-alt" - :class="css.unlisted" - :title="$t('post_status.scope.unlisted')" - v-if="showUnlisted" - @click="changeVis('unlisted')"> - </i> - <i class="icon-globe" - :class="css.public" - :title="$t('post_status.scope.public')" - v-if="showPublic" - @click="changeVis('public')"> - </i> -</div> + <div + v-if="!showNothing" + class="scope-selector" + > + <i + v-if="showDirect" + class="icon-mail-alt" + :class="css.direct" + :title="$t('post_status.scope.direct')" + @click="changeVis('direct')" + /> + <i + v-if="showPrivate" + class="icon-lock" + :class="css.private" + :title="$t('post_status.scope.private')" + @click="changeVis('private')" + /> + <i + v-if="showUnlisted" + class="icon-lock-open-alt" + :class="css.unlisted" + :title="$t('post_status.scope.unlisted')" + @click="changeVis('unlisted')" + /> + <i + v-if="showPublic" + class="icon-globe" + :class="css.public" + :title="$t('post_status.scope.public')" + @click="changeVis('public')" + /> + </div> </template> <script src="./scope_selector.js"></script> diff --git a/src/components/search/search.js b/src/components/search/search.js new file mode 100644 index 00000000..8e903052 --- /dev/null +++ b/src/components/search/search.js @@ -0,0 +1,98 @@ +import FollowCard from '../follow_card/follow_card.vue' +import Conversation from '../conversation/conversation.vue' +import Status from '../status/status.vue' +import map from 'lodash/map' + +const Search = { + components: { + FollowCard, + Conversation, + Status + }, + props: [ + 'query' + ], + data () { + return { + loaded: false, + loading: false, + searchTerm: this.query || '', + userIds: [], + statuses: [], + hashtags: [], + currenResultTab: 'statuses' + } + }, + computed: { + users () { + return this.userIds.map(userId => this.$store.getters.findUser(userId)) + }, + visibleStatuses () { + const allStatusesObject = this.$store.state.statuses.allStatusesObject + + return this.statuses.filter(status => + allStatusesObject[status.id] && !allStatusesObject[status.id].deleted + ) + } + }, + mounted () { + this.search(this.query) + }, + watch: { + query (newValue) { + this.searchTerm = newValue + this.search(newValue) + } + }, + methods: { + newQuery (query) { + this.$router.push({ name: 'search', query: { query } }) + this.$refs.searchInput.focus() + }, + search (query) { + if (!query) { + this.loading = false + return + } + + this.loading = true + this.userIds = [] + this.statuses = [] + this.hashtags = [] + this.$refs.searchInput.blur() + + this.$store.dispatch('search', { q: query, resolve: true }) + .then(data => { + this.loading = false + this.userIds = map(data.accounts, 'id') + this.statuses = data.statuses + this.hashtags = data.hashtags + this.currenResultTab = this.getActiveTab() + this.loaded = true + }) + }, + resultCount (tabName) { + const length = this[tabName].length + return length === 0 ? '' : ` (${length})` + }, + onResultTabSwitch (key) { + this.currenResultTab = key + }, + getActiveTab () { + if (this.visibleStatuses.length > 0) { + return 'statuses' + } else if (this.users.length > 0) { + return 'people' + } else if (this.hashtags.length > 0) { + return 'hashtags' + } + + return 'statuses' + }, + lastHistoryRecord (hashtag) { + return hashtag.history && hashtag.history[0] + } + } +} + +export default Search diff --git a/src/components/search/search.vue b/src/components/search/search.vue new file mode 100644 index 00000000..746bbaa2 --- /dev/null +++ b/src/components/search/search.vue @@ -0,0 +1,208 @@ +<template> + <div class="panel panel-default"> + <div class="panel-heading"> + <div class="title"> + {{ $t('nav.search') }} + </div> + </div> + <div class="search-input-container"> + <input + ref="searchInput" + v-model="searchTerm" + class="search-input" + :placeholder="$t('nav.search')" + @keyup.enter="newQuery(searchTerm)" + > + <button + class="btn search-button" + @click="newQuery(searchTerm)" + > + <i class="icon-search" /> + </button> + </div> + <div + v-if="loading" + class="text-center loading-icon" + > + <i class="icon-spin3 animate-spin" /> + </div> + <div v-else-if="loaded"> + <div class="search-nav-heading"> + <tab-switcher + ref="tabSwitcher" + :on-switch="onResultTabSwitch" + :active-tab="currenResultTab" + > + <span + key="statuses" + :label="$t('user_card.statuses') + resultCount('visibleStatuses')" + /> + <span + key="people" + :label="$t('search.people') + resultCount('users')" + /> + <span + key="hashtags" + :label="$t('search.hashtags') + resultCount('hashtags')" + /> + </tab-switcher> + </div> + </div> + <div class="panel-body"> + <div v-if="currenResultTab === 'statuses'"> + <div + v-if="visibleStatuses.length === 0 && !loading && loaded" + class="search-result-heading" + > + <h4>{{ $t('search.no_results') }}</h4> + </div> + <Status + v-for="status in visibleStatuses" + :key="status.id" + :collapsable="false" + :expandable="false" + :compact="false" + class="search-result" + :statusoid="status" + :no-heading="false" + /> + </div> + <div v-else-if="currenResultTab === 'people'"> + <div + v-if="users.length === 0 && !loading && loaded" + class="search-result-heading" + > + <h4>{{ $t('search.no_results') }}</h4> + </div> + <FollowCard + v-for="user in users" + :key="user.id" + :user="user" + class="list-item search-result" + /> + </div> + <div v-else-if="currenResultTab === 'hashtags'"> + <div + v-if="hashtags.length === 0 && !loading && loaded" + class="search-result-heading" + > + <h4>{{ $t('search.no_results') }}</h4> + </div> + <div + v-for="hashtag in hashtags" + :key="hashtag.url" + class="status trend search-result" + > + <div class="hashtag"> + <router-link :to="{ name: 'tag-timeline', params: { tag: hashtag.name } }"> + #{{ hashtag.name }} + </router-link> + <div v-if="lastHistoryRecord(hashtag)"> + <span v-if="lastHistoryRecord(hashtag).accounts == 1"> + {{ $t('search.person_talking', { count: lastHistoryRecord(hashtag).accounts }) }} + </span> + <span v-else> + {{ $t('search.people_talking', { count: lastHistoryRecord(hashtag).accounts }) }} + </span> + </div> + </div> + <div + v-if="lastHistoryRecord(hashtag)" + class="count" + > + {{ lastHistoryRecord(hashtag).uses }} + </div> + </div> + </div> + </div> + <div class="search-result-footer text-center panel-footer faint" /> + </div> +</template> + +<script src="./search.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.search-result-heading { + color: $fallback--faint; + color: var(--faint, $fallback--faint); + padding: 0.75rem; + text-align: center; +} + +@media all and (max-width: 800px) { + .search-nav-heading { + .tab-switcher .tabs .tab-wrapper { + display: block; + justify-content: center; + flex: 1 1 auto; + text-align: center; + } + } +} + +.search-result { + box-sizing: border-box; + border-bottom: 1px solid; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); +} + +.search-result-footer { + border-width: 1px 0 0 0; + border-style: solid; + border-color: var(--border, $fallback--border); + padding: 10px; + background-color: $fallback--fg; + background-color: var(--panel, $fallback--fg); +} + +.search-input-container { + padding: 0.8rem; + display: flex; + justify-content: center; + + .search-input { + width: 100%; + line-height: 1.125rem; + font-size: 1rem; + padding: 0.5rem; + box-sizing: border-box; + } + + .search-button { + margin-left: 0.5em; + } +} + +.loading-icon { + padding: 1em; +} + +.trend { + display: flex; + align-items: center; + + .hashtag { + flex: 1 1 auto; + color: $fallback--text; + color: var(--text, $fallback--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .count { + flex: 0 0 auto; + width: 2rem; + font-size: 1.5rem; + line-height: 2.25rem; + font-weight: 500; + text-align: center; + color: $fallback--text; + color: var(--text, $fallback--text); + } +} + +</style> diff --git a/src/components/search_bar/search_bar.js b/src/components/search_bar/search_bar.js new file mode 100644 index 00000000..d7d85676 --- /dev/null +++ b/src/components/search_bar/search_bar.js @@ -0,0 +1,32 @@ +const SearchBar = { + data: () => ({ + searchTerm: undefined, + hidden: true, + error: false, + loading: false + }), + watch: { + '$route': function (route) { + if (route.name === 'search') { + this.searchTerm = route.query.query + } + } + }, + methods: { + find (searchTerm) { + this.$router.push({ name: 'search', query: { query: searchTerm } }) + this.$refs.searchInput.focus() + }, + toggleHidden () { + this.hidden = !this.hidden + this.$emit('toggled', this.hidden) + this.$nextTick(() => { + if (!this.hidden) { + this.$refs.searchInput.focus() + } + }) + } + } +} + +export default SearchBar diff --git a/src/components/search_bar/search_bar.vue b/src/components/search_bar/search_bar.vue new file mode 100644 index 00000000..4d5a1aec --- /dev/null +++ b/src/components/search_bar/search_bar.vue @@ -0,0 +1,73 @@ +<template> + <div> + <div class="search-bar-container"> + <i + v-if="loading" + class="icon-spin4 finder-icon animate-spin-slow" + /> + <a + v-if="hidden" + href="#" + :title="$t('nav.search')" + ><i + class="button-icon icon-search" + @click.prevent.stop="toggleHidden" + /></a> + <template v-else> + <input + id="search-bar-input" + ref="searchInput" + v-model="searchTerm" + class="search-bar-input" + :placeholder="$t('nav.search')" + type="text" + @keyup.enter="find(searchTerm)" + > + <button + class="btn search-button" + @click="find(searchTerm)" + > + <i class="icon-search" /> + </button> + <i + class="button-icon icon-cancel" + @click.prevent.stop="toggleHidden" + /> + </template> + </div> + </div> +</template> + +<script src="./search_bar.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.search-bar-container { + max-width: 100%; + display: inline-flex; + align-items: baseline; + vertical-align: baseline; + justify-content: flex-end; + + .search-bar-input, + .search-button { + height: 29px; + } + + .search-bar-input { + // TODO: do this properly without a rough guesstimate of 2 icons + paddings + max-width: calc(100% - 30px - 30px - 20px); + } + + .search-button { + margin-left: .5em; + margin-right: .5em; + } + + .icon-cancel { + cursor: pointer; + } +} + +</style> diff --git a/src/components/selectable_list/selectable_list.vue b/src/components/selectable_list/selectable_list.vue index 3f16c921..d9ec7ece 100644 --- a/src/components/selectable_list/selectable_list.vue +++ b/src/components/selectable_list/selectable_list.vue @@ -1,23 +1,52 @@ <template> <div class="selectable-list"> - <div class="selectable-list-header" v-if="items.length > 0"> + <div + v-if="items.length > 0" + class="selectable-list-header" + > <div class="selectable-list-checkbox-wrapper"> - <Checkbox :checked="allSelected" @change="toggleAll" :indeterminate="someSelected">{{ $t('selectable_list.select_all') }}</Checkbox> + <Checkbox + :checked="allSelected" + :indeterminate="someSelected" + @change="toggleAll" + > + {{ $t('selectable_list.select_all') }} + </Checkbox> </div> <div class="selectable-list-header-actions"> - <slot name="header" :selected="filteredSelected" /> + <slot + name="header" + :selected="filteredSelected" + /> </div> </div> - <List :items="items" :getKey="getKey"> - <template slot="item" slot-scope="{item}"> - <div class="selectable-list-item-inner" :class="{ 'selectable-list-item-selected-inner': isSelected(item) }"> + <List + :items="items" + :get-key="getKey" + > + <template + slot="item" + slot-scope="{item}" + > + <div + class="selectable-list-item-inner" + :class="{ 'selectable-list-item-selected-inner': isSelected(item) }" + > <div class="selectable-list-checkbox-wrapper"> - <Checkbox :checked="isSelected(item)" @change="checked => toggle(checked, item)" /> + <Checkbox + :checked="isSelected(item)" + @change="checked => toggle(checked, item)" + /> </div> - <slot name="item" :item="item" /> + <slot + name="item" + :item="item" + /> </div> </template> - <template slot="empty"><slot name="empty" /></template> + <template slot="empty"> + <slot name="empty" /> + </template> </List> </div> </template> diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue index 4cf6fae2..744ec566 100644 --- a/src/components/settings/settings.vue +++ b/src/components/settings/settings.vue @@ -1,304 +1,482 @@ <template> -<div class="settings panel panel-default"> - <div class="panel-heading"> - <div class="title"> - {{$t('settings.settings')}} - </div> + <div class="settings panel panel-default"> + <div class="panel-heading"> + <div class="title"> + {{ $t('settings.settings') }} + </div> - <transition name="fade"> - <template v-if="currentSaveStateNotice"> - <div @click.prevent class="alert error" v-if="currentSaveStateNotice.error"> - {{ $t('settings.saving_err') }} - </div> + <transition name="fade"> + <template v-if="currentSaveStateNotice"> + <div + v-if="currentSaveStateNotice.error" + class="alert error" + @click.prevent + > + {{ $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 v-if="instanceSpecificPanelPresent"> - <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="hideMutedPosts" v-model="hideMutedPostsLocal"> - <label for="hideMutedPosts">{{$t('settings.hide_muted_posts')}} {{$t('settings.instance_default', { value: hideMutedPostsDefault })}}</label> - </li> - <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}]"> + <div + v-if="!currentSaveStateNotice.error" + class="alert transparent" + @click.prevent + > + {{ $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> - <input :disabled="!streamingLocal" type="checkbox" id="pauseOnUnfocused" v-model="pauseOnUnfocusedLocal"> - <label for="pauseOnUnfocused">{{$t('settings.pause_on_unfocused')}}</label> + <interface-language-switcher /> + </li> + <li v-if="instanceSpecificPanelPresent"> + <input + id="hideISP" + v-model="hideISPLocal" + type="checkbox" + > + <label for="hideISP">{{ $t('settings.hide_isp') }}</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> - <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> - <li> - <div> - {{$t('settings.post_status_content_type')}} - <label for="postContentType" class="select"> - <select id="postContentType" v-model="postContentTypeLocal"> - <option v-for="postFormat in postFormats" :key="postFormat" :value="postFormat"> - {{$t(`post_status.content_type["${postFormat}"]`)}} - {{postContentTypeDefault === postFormat ? $t('settings.instance_default_simple') : ''}} - </option> - </select> - <i class="icon-down-open"/> - </label> - </div> - </li> - <li> - <input type="checkbox" id="minimalScopesMode" v-model="minimalScopesModeLocal"> - <label for="minimalScopesMode"> - {{$t('settings.minimal_scopes_mode')}} {{$t('settings.instance_default', { value: minimalScopesModeDefault })}} - </label> - </li> - <li> - <input type="checkbox" id="autohideFloatingPostButton" v-model="autohideFloatingPostButtonLocal"> - <label for="autohideFloatingPostButton">{{$t('settings.autohide_floating_post_button')}}</label> - </li> - </ul> - </div> + </div> + <div class="setting-item"> + <h2>{{ $t('nav.timeline') }}</h2> + <ul class="setting-list"> + <li> + <input + id="hideMutedPosts" + v-model="hideMutedPostsLocal" + type="checkbox" + > + <label for="hideMutedPosts">{{ $t('settings.hide_muted_posts') }} {{ $t('settings.instance_default', { value: hideMutedPostsDefault }) }}</label> + </li> + <li> + <input + id="collapseMessageWithSubject" + v-model="collapseMessageWithSubjectLocal" + type="checkbox" + > + <label for="collapseMessageWithSubject">{{ $t('settings.collapse_subject') }} {{ $t('settings.instance_default', { value: collapseMessageWithSubjectDefault }) }}</label> + </li> + <li> + <input + id="streaming" + v-model="streamingLocal" + type="checkbox" + > + <label for="streaming">{{ $t('settings.streaming') }}</label> + <ul + class="setting-list suboptions" + :class="[{disabled: !streamingLocal}]" + > + <li> + <input + id="pauseOnUnfocused" + v-model="pauseOnUnfocusedLocal" + :disabled="!streamingLocal" + type="checkbox" + > + <label for="pauseOnUnfocused">{{ $t('settings.pause_on_unfocused') }}</label> + </li> + </ul> + </li> + <li> + <input + id="autoload" + v-model="autoLoadLocal" + type="checkbox" + > + <label for="autoload">{{ $t('settings.autoload') }}</label> + </li> + <li> + <input + id="hoverPreview" + v-model="hoverPreviewLocal" + type="checkbox" + > + <label for="hoverPreview">{{ $t('settings.reply_link_preview') }}</label> + </li> + </ul> + </div> - <div class="setting-item"> - <h2>{{$t('settings.attachments')}}</h2> - <ul class="setting-list"> - <li> - <input type="checkbox" id="hideAttachments" v-model="hideAttachmentsLocal"> - <label for="hideAttachments">{{$t('settings.hide_attachments_in_tl')}}</label> - </li> - <li> - <input type="checkbox" id="hideAttachmentsInConv" v-model="hideAttachmentsInConvLocal"> - <label for="hideAttachmentsInConv">{{$t('settings.hide_attachments_in_convo')}}</label> - </li> - <li> - <label for="maxThumbnails">{{$t('settings.max_thumbnails')}}</label> - <input class="number-input" type="number" id="maxThumbnails" v-model.number="maxThumbnails" min="0" step="1"> - </li> - <li> - <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="!hideNsfwLocal" type="checkbox" id="preloadImage" v-model="preloadImage"> - <label for="preloadImage">{{$t('settings.preload_images')}}</label> - </li> - <li> - <input :disabled="!hideNsfwLocal" type="checkbox" id="useOneClickNsfw" v-model="useOneClickNsfw"> - <label for="useOneClickNsfw">{{$t('settings.use_one_click_nsfw')}}</label> - </li> - </ul> - <li> - <input type="checkbox" id="stopGifs" v-model="stopGifs"> - <label for="stopGifs">{{$t('settings.stop_gifs')}}</label> - </li> - <li> - <input type="checkbox" id="loopVideo" v-model="loopVideoLocal"> - <label for="loopVideo">{{$t('settings.loop_video')}}</label> - <ul class="setting-list suboptions" :class="[{disabled: !streamingLocal}]"> + <div class="setting-item"> + <h2>{{ $t('settings.composing') }}</h2> + <ul class="setting-list"> <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')}} + <input + id="scopeCopy" + v-model="scopeCopyLocal" + type="checkbox" + > + <label for="scopeCopy"> + {{ $t('settings.scope_copy') }} {{ $t('settings.instance_default', { value: scopeCopyDefault }) }} + </label> + </li> + <li> + <input + id="subjectHide" + v-model="alwaysShowSubjectInputLocal" + type="checkbox" + > + <label for="subjectHide"> + {{ $t('settings.subject_input_always_show') }} {{ $t('settings.instance_default', { value: alwaysShowSubjectInputDefault }) }} + </label> + </li> + <li> + <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> + <li v-if="postFormats.length > 0"> + <div> + {{ $t('settings.post_status_content_type') }} + <label + for="postContentType" + class="select" + > + <select + id="postContentType" + v-model="postContentTypeLocal" + > + <option + v-for="postFormat in postFormats" + :key="postFormat" + :value="postFormat" + > + {{ $t(`post_status.content_type["${postFormat}"]`) }} + {{ postContentTypeDefault === postFormat ? $t('settings.instance_default_simple') : '' }} + </option> + </select> + <i class="icon-down-open" /> + </label> + </div> + </li> + <li> + <input + id="minimalScopesMode" + v-model="minimalScopesModeLocal" + type="checkbox" + > + <label for="minimalScopesMode"> + {{ $t('settings.minimal_scopes_mode') }} {{ $t('settings.instance_default', { value: minimalScopesModeDefault }) }} + </label> + </li> + <li> + <input + id="autohideFloatingPostButton" + v-model="autohideFloatingPostButtonLocal" + type="checkbox" + > + <label for="autohideFloatingPostButton">{{ $t('settings.autohide_floating_post_button') }}</label> + </li> </ul> - </li> - <li> - <input type="checkbox" id="playVideosInModal" v-model="playVideosInModal"> - <label for="playVideosInModal">{{$t('settings.play_videos_in_modal')}}</label> - </li> - <li> - <input type="checkbox" id="useContainFit" v-model="useContainFit"> - <label for="useContainFit">{{$t('settings.use_contain_fit')}}</label> - </li> - </ul> - </div> + </div> - <div class="setting-item"> - <h2>{{$t('settings.notifications')}}</h2> - <ul class="setting-list"> - <li> - <input type="checkbox" id="webPushNotifications" v-model="webPushNotificationsLocal"> - <label for="webPushNotifications"> - {{$t('settings.enable_web_push_notifications')}} - </label> - </li> - </ul> - </div> - </div> + <div class="setting-item"> + <h2>{{ $t('settings.attachments') }}</h2> + <ul class="setting-list"> + <li> + <input + id="hideAttachments" + v-model="hideAttachmentsLocal" + type="checkbox" + > + <label for="hideAttachments">{{ $t('settings.hide_attachments_in_tl') }}</label> + </li> + <li> + <input + id="hideAttachmentsInConv" + v-model="hideAttachmentsInConvLocal" + type="checkbox" + > + <label for="hideAttachmentsInConv">{{ $t('settings.hide_attachments_in_convo') }}</label> + </li> + <li> + <label for="maxThumbnails">{{ $t('settings.max_thumbnails') }}</label> + <input + id="maxThumbnails" + v-model.number="maxThumbnails" + class="number-input" + type="number" + min="0" + step="1" + > + </li> + <li> + <input + id="hideNsfw" + v-model="hideNsfwLocal" + type="checkbox" + > + <label for="hideNsfw">{{ $t('settings.nsfw_clickthrough') }}</label> + </li> + <ul class="setting-list suboptions"> + <li> + <input + id="preloadImage" + v-model="preloadImage" + :disabled="!hideNsfwLocal" + type="checkbox" + > + <label for="preloadImage">{{ $t('settings.preload_images') }}</label> + </li> + <li> + <input + id="useOneClickNsfw" + v-model="useOneClickNsfw" + :disabled="!hideNsfwLocal" + type="checkbox" + > + <label for="useOneClickNsfw">{{ $t('settings.use_one_click_nsfw') }}</label> + </li> + </ul> + <li> + <input + id="stopGifs" + v-model="stopGifs" + type="checkbox" + > + <label for="stopGifs">{{ $t('settings.stop_gifs') }}</label> + </li> + <li> + <input + id="loopVideo" + v-model="loopVideoLocal" + type="checkbox" + > + <label for="loopVideo">{{ $t('settings.loop_video') }}</label> + <ul + class="setting-list suboptions" + :class="[{disabled: !streamingLocal}]" + > + <li> + <input + id="loopVideoSilentOnly" + v-model="loopVideoSilentOnlyLocal" + :disabled="!loopVideoLocal || !loopSilentAvailable" + type="checkbox" + > + <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> + <li> + <input + id="playVideosInModal" + v-model="playVideosInModal" + type="checkbox" + > + <label for="playVideosInModal">{{ $t('settings.play_videos_in_modal') }}</label> + </li> + <li> + <input + id="useContainFit" + v-model="useContainFit" + type="checkbox" + > + <label for="useContainFit">{{ $t('settings.use_contain_fit') }}</label> + </li> + </ul> + </div> - <div :label="$t('settings.theme')" > - <div class="setting-item"> - <style-switcher></style-switcher> - </div> - </div> + <div class="setting-item"> + <h2>{{ $t('settings.notifications') }}</h2> + <ul class="setting-list"> + <li> + <input + id="webPushNotifications" + v-model="webPushNotificationsLocal" + type="checkbox" + > + <label for="webPushNotifications"> + {{ $t('settings.enable_web_push_notifications') }} + </label> + </li> + </ul> + </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')}} + <div :label="$t('settings.theme')"> + <div class="setting-item"> + <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 + id="notification-visibility-likes" + v-model="notificationVisibilityLocal.likes" + type="checkbox" + > + <label for="notification-visibility-likes"> + {{ $t('settings.notification_visibility_likes') }} + </label> + </li> + <li> + <input + id="notification-visibility-repeats" + v-model="notificationVisibilityLocal.repeats" + type="checkbox" + > + <label for="notification-visibility-repeats"> + {{ $t('settings.notification_visibility_repeats') }} + </label> + </li> + <li> + <input + id="notification-visibility-follows" + v-model="notificationVisibilityLocal.follows" + type="checkbox" + > + <label for="notification-visibility-follows"> + {{ $t('settings.notification_visibility_follows') }} + </label> + </li> + <li> + <input + id="notification-visibility-mentions" + v-model="notificationVisibilityLocal.mentions" + type="checkbox" + > + <label for="notification-visibility-mentions"> + {{ $t('settings.notification_visibility_mentions') }} + </label> + </li> + </ul> + </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> - </li> - <li> - <input type="checkbox" id="notification-visibility-repeats" v-model="notificationVisibilityLocal.repeats"> - <label for="notification-visibility-repeats"> - {{$t('settings.notification_visibility_repeats')}} + </div> + <div> + <input + id="hidePostStats" + v-model="hidePostStatsLocal" + type="checkbox" + > + <label for="hidePostStats"> + {{ $t('settings.hide_post_stats') }} {{ $t('settings.instance_default', { value: hidePostStatsDefault }) }} </label> - </li> - <li> - <input type="checkbox" id="notification-visibility-follows" v-model="notificationVisibilityLocal.follows"> - <label for="notification-visibility-follows"> - {{$t('settings.notification_visibility_follows')}} + </div> + <div> + <input + id="hideUserStats" + v-model="hideUserStatsLocal" + type="checkbox" + > + <label for="hideUserStats"> + {{ $t('settings.hide_user_stats') }} {{ $t('settings.instance_default', { value: hideUserStatsDefault }) }} </label> - </li> - <li> - <input type="checkbox" id="notification-visibility-mentions" v-model="notificationVisibilityLocal.mentions"> - <label for="notification-visibility-mentions"> - {{$t('settings.notification_visibility_mentions')}} + </div> + </div> + <div class="setting-item"> + <div> + <p>{{ $t('settings.filtering_explanation') }}</p> + <textarea + id="muteWords" + v-model="muteWordsString" + /> + </div> + <div> + <input + id="hideFilteredStatuses" + v-model="hideFilteredStatusesLocal" + type="checkbox" + > + <label for="hideFilteredStatuses"> + {{ $t('settings.hide_filtered_statuses') }} {{ $t('settings.instance_default', { value: hideFilteredStatusesDefault }) }} </label> - </li> - </ul> - </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"> - <div> - <p>{{$t('settings.filtering_explanation')}}</p> - <textarea id="muteWords" v-model="muteWordsString"></textarea> - </div> - <div> - <input type="checkbox" id="hideFilteredStatuses" v-model="hideFilteredStatusesLocal"> - <label for="hideFilteredStatuses"> - {{$t('settings.hide_filtered_statuses')}} {{$t('settings.instance_default', { value: hideFilteredStatusesDefault })}} - </label> + </div> + </div> </div> - </div> - </div> - <div :label="$t('settings.version.title')" > - <div class="setting-item"> - <ul class="setting-list"> - <li> - <p>{{$t('settings.version.backend_version')}}</p> - <ul class="option-list"> + <div :label="$t('settings.version.title')"> + <div class="setting-item"> + <ul class="setting-list"> <li> - <a :href="backendVersionLink" target="_blank">{{backendVersion}}</a> + <p>{{ $t('settings.version.backend_version') }}</p> + <ul class="option-list"> + <li> + <a + :href="backendVersionLink" + target="_blank" + >{{ backendVersion }}</a> + </li> + </ul> </li> - </ul> - </li> - <li> - <p>{{$t('settings.version.frontend_version')}}</p> - <ul class="option-list"> <li> - <a :href="frontendVersionLink" target="_blank">{{frontendVersion}}</a> + <p>{{ $t('settings.version.frontend_version') }}</p> + <ul class="option-list"> + <li> + <a + :href="frontendVersionLink" + target="_blank" + >{{ frontendVersion }}</a> + </li> + </ul> </li> </ul> - </li> - </ul> - </div> - </div> - </tab-switcher> -</keep-alive> + </div> + </div> + </tab-switcher> + </keep-alive> + </div> </div> -</div> </template> <script src="./settings.js"> diff --git a/src/components/shadow_control/shadow_control.vue b/src/components/shadow_control/shadow_control.vue index 744925d4..de8a42d1 100644 --- a/src/components/shadow_control/shadow_control.vue +++ b/src/components/shadow_control/shadow_control.vue @@ -1,134 +1,207 @@ <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" + <div + class="shadow-control" + :class="{ disabled: !present }" + > + <div class="shadow-preview-container"> + <div :disabled="!present" - class="input-number" - type="number"> - <div class="wrap"> + class="y-shift-control" + > <input v-model="selected.y" :disabled="!present" - class="input-range" - type="range" - max="20" - min="-20"> + 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> - <div class="preview-window"> - <div class="preview-block" :style="style"></div> - </div> - <div :disabled="!present" class="x-shift-control"> - <input - v-model="selected.x" + <div class="preview-window"> + <div + class="preview-block" + :style="style" + /> + </div> + <div :disabled="!present" - class="input-number" - type="number"> - <div class="wrap"> + class="x-shift-control" + > <input v-model="selected.x" :disabled="!present" - class="input-range" - type="range" - max="20" - min="-20"> + 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> - <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" + <div class="shadow-tweak"> + <div + :disabled="usingFallback" + class="id-control style-control" + > + <label + for="shadow-switcher" + class="select" :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" + > + <select + id="shadow-switcher" + v-model="selectedId" + class="shadow-switcher" + :disabled="!ready || usingFallback" + > + <option + v-for="(shadow, index) in cValue" + :key="index" + :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" - 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" + class="inset-control style-control" + > + <label + for="inset" + class="label" + > + {{ $t('settings.style.shadows.inset') }} + </label> + <input + id="inset" + v-model="selected.inset" + :disabled="!present" + name="inset" + class="input-inset" + type="checkbox" + > + <label + class="checkbox-label" + for="inset" + /> + </div> + <div :disabled="!present" - name="blur" - id="blur" - class="input-range" - type="range" - max="20" - min="0"> - <input - v-model="selected.blur" + class="blur-control style-control" + > + <label + for="spread" + class="label" + > + {{ $t('settings.style.shadows.blur') }} + </label> + <input + id="blur" + v-model="selected.blur" + :disabled="!present" + name="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="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" + class="spread-control style-control" + > + <label + for="spread" + class="label" + > + {{ $t('settings.style.shadows.spread') }} + </label> + <input + id="spread" + v-model="selected.spread" + :disabled="!present" + name="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" - name="spread" - id="spread" - class="input-range" - type="range" - max="20" - min="-20"> - <input - v-model="selected.spread" + :label="$t('settings.style.common.color')" + name="shadow" + /> + <OpacityInput + v-model="selected.alpha" :disabled="!present" - class="input-number" - type="number"> + /> + <p> + {{ $t('settings.style.shadows.hint') }} + </p> </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> diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index 6428b1b0..5b2d4473 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -1,63 +1,98 @@ <template> - <div class="side-drawer-container" + <div + class="side-drawer-container" :class="{ 'side-drawer-container-closed': closed, 'side-drawer-container-open': !closed }" > - <div class="side-drawer-darken" :class="{ 'side-drawer-darken-closed': closed}" /> - <div class="side-drawer" + <div + class="side-drawer-darken" + :class="{ 'side-drawer-darken-closed': closed}" + /> + <div + class="side-drawer" :class="{'side-drawer-closed': closed}" @touchstart="touchStart" @touchmove="touchMove" > - <div class="side-drawer-heading" @click="toggleDrawer"> - <UserCard :user="currentUser" :hideBio="true" v-if="currentUser"/> - <div class="side-drawer-logo-wrapper" v-else> - <img :src="logo"/> - <span>{{sitename}}</span> + <div + class="side-drawer-heading" + @click="toggleDrawer" + > + <UserCard + v-if="currentUser" + :user="currentUser" + :hide-bio="true" + /> + <div + v-else + class="side-drawer-logo-wrapper" + > + <img :src="logo"> + <span>{{ sitename }}</span> </div> </div> <ul> - <li v-if="!currentUser" @click="toggleDrawer"> + <li + v-if="!currentUser" + @click="toggleDrawer" + > <router-link :to="{ name: 'login' }"> {{ $t("login.login") }} </router-link> </li> - <li v-if="currentUser" @click="toggleDrawer"> + <li + v-if="currentUser" + @click="toggleDrawer" + > <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }"> {{ $t("nav.dms") }} </router-link> </li> - <li v-if="currentUser" @click="toggleDrawer"> + <li + v-if="currentUser" + @click="toggleDrawer" + > <router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }"> {{ $t("nav.interactions") }} </router-link> </li> </ul> <ul> - <li v-if="currentUser" @click="toggleDrawer"> + <li + v-if="currentUser" + @click="toggleDrawer" + > <router-link :to="{ name: 'friends' }"> {{ $t("nav.timeline") }} </router-link> </li> - <li v-if="currentUser && currentUser.locked" @click="toggleDrawer"> - <router-link to='/friend-requests'> + <li + v-if="currentUser && currentUser.locked" + @click="toggleDrawer" + > + <router-link to="/friend-requests"> {{ $t("nav.friend_requests") }} - <span v-if='followRequestCount > 0' class="badge follow-request-count"> - {{followRequestCount}} + <span + v-if="followRequestCount > 0" + class="badge follow-request-count" + > + {{ followRequestCount }} </span> - </router-link> </li> <li @click="toggleDrawer"> - <router-link to='/main/public'> + <router-link to="/main/public"> {{ $t("nav.public_tl") }} </router-link> </li> <li @click="toggleDrawer"> - <router-link to='/main/all'> + <router-link to="/main/all"> {{ $t("nav.twkn") }} </router-link> </li> - <li v-if="currentUser && chat" @click="toggleDrawer"> + <li + v-if="currentUser && chat" + @click="toggleDrawer" + > <router-link :to="{ name: 'chat' }"> {{ $t("nav.chat") }} </router-link> @@ -65,11 +100,14 @@ </ul> <ul> <li @click="toggleDrawer"> - <router-link :to="{ name: 'user-search' }"> - {{ $t("nav.user_search") }} + <router-link :to="{ name: 'search' }"> + {{ $t("nav.search") }} </router-link> </li> - <li v-if="currentUser && suggestionsEnabled" @click="toggleDrawer"> + <li + v-if="currentUser && suggestionsEnabled" + @click="toggleDrawer" + > <router-link :to="{ name: 'who-to-follow' }"> {{ $t("nav.who_to_follow") }} </router-link> @@ -84,17 +122,24 @@ {{ $t("nav.about") }} </router-link> </li> - <li v-if="currentUser" @click="toggleDrawer"> - <a @click="doLogout" href="#"> + <li + v-if="currentUser" + @click="toggleDrawer" + > + <a + href="#" + @click="doLogout" + > {{ $t("login.logout") }} </a> </li> </ul> </div> - <div class="side-drawer-click-outside" - @click.stop.prevent="toggleDrawer" + <div + class="side-drawer-click-outside" :class="{'side-drawer-click-outside-closed': closed}" - ></div> + @click.stop.prevent="toggleDrawer" + /> </div> </template> diff --git a/src/components/status/status.js b/src/components/status/status.js index ea4c2b9d..502d9583 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -1,6 +1,7 @@ import Attachment from '../attachment/attachment.vue' import FavoriteButton from '../favorite_button/favorite_button.vue' import RetweetButton from '../retweet_button/retweet_button.vue' +import Poll from '../poll/poll.vue' import ExtraButtons from '../extra_buttons/extra_buttons.vue' import PostStatusForm from '../post_status_form/post_status_form.vue' import UserCard from '../user_card/user_card.vue' @@ -8,6 +9,7 @@ import UserAvatar from '../user_avatar/user_avatar.vue' import Gallery from '../gallery/gallery.vue' import LinkPreview from '../link-preview/link-preview.vue' import AvatarList from '../avatar_list/avatar_list.vue' +import Timeago from '../timeago/timeago.vue' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import fileType from 'src/services/file_type/file_type.service' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' @@ -108,8 +110,9 @@ const Status = { }, muteWordHits () { const statusText = this.status.text.toLowerCase() + const statusSummary = this.status.summary.toLowerCase() const hits = filter(this.muteWords, (muteWord) => { - return statusText.includes(muteWord.toLowerCase()) + return statusText.includes(muteWord.toLowerCase()) || statusSummary.includes(muteWord.toLowerCase()) }) return hits @@ -171,12 +174,13 @@ const Status = { if (this.status.type === 'retweet') { return false } - var checkFollowing = this.$store.state.config.replyVisibility === 'following' + const 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.$store.getters.findUser(this.status.attentions[i].id).following) { + const taggedUser = this.$store.getters.findUser(this.status.attentions[i].id) + if (checkFollowing && taggedUser && taggedUser.following) { return false } if (this.status.attentions[i].id === this.$store.state.users.currentUser.id) { @@ -216,10 +220,10 @@ const Status = { if (!this.status.summary) return '' const decodedSummary = unescape(this.status.summary) const behavior = typeof this.$store.state.config.subjectLineBehavior === 'undefined' - ? this.$store.state.instance.subjectLineBehavior - : this.$store.state.config.subjectLineBehavior + ? this.$store.state.instance.subjectLineBehavior + : this.$store.state.config.subjectLineBehavior const startsWithRe = decodedSummary.match(/^re[: ]/i) - if (behavior !== 'noop' && startsWithRe || behavior === 'masto') { + if ((behavior !== 'noop' && startsWithRe) || behavior === 'masto') { return decodedSummary } else if (behavior === 'email') { return 're: '.concat(decodedSummary) @@ -277,6 +281,11 @@ const Status = { }, tags () { return this.status.tags.filter(tagObj => tagObj.hasOwnProperty('name')).map(tagObj => tagObj.name).join(' ') + }, + hidePostStats () { + return typeof this.$store.state.config.hidePostStats === 'undefined' + ? this.$store.state.instance.hidePostStats + : this.$store.state.config.hidePostStats } }, components: { @@ -285,11 +294,13 @@ const Status = { RetweetButton, ExtraButtons, PostStatusForm, + Poll, UserCard, UserAvatar, Gallery, LinkPreview, - AvatarList + AvatarList, + Timeago }, methods: { visibilityIcon (visibility) { @@ -311,11 +322,8 @@ const Status = { this.error = undefined }, linkClicked (event) { - let { target } = event - if (target.tagName === 'SPAN') { - target = target.parentNode - } - if (target.tagName === 'A') { + const target = event.target.closest('.status-content a') + if (target) { if (target.className.match(/mention/)) { const href = target.href const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href)) @@ -327,7 +335,7 @@ const Status = { return } } - if (target.className.match(/hashtag/)) { + if (target.rel.match(/(?:^|\s)tag(?:$|\s)/) || target.className.match(/hashtag/)) { // Extract tag name from link url const tag = extractTagFromUrl(target.href) if (tag) { @@ -377,7 +385,7 @@ const Status = { this.preview = find(statuses, { 'id': targetId }) // or if we have to fetch it if (!this.preview) { - this.$store.state.api.backendInteractor.fetchStatus({id}).then((status) => { + this.$store.state.api.backendInteractor.fetchStatus({ id }).then((status) => { this.preview = status }) } @@ -414,6 +422,18 @@ const Status = { window.scrollBy(0, rect.bottom - window.innerHeight + 50) } } + }, + 'status.repeat_num': function (num) { + // refetch repeats when repeat_num is changed in any way + if (this.isFocused && this.statusFromGlobalRepository.rebloggedBy && this.statusFromGlobalRepository.rebloggedBy.length !== num) { + this.$store.dispatch('fetchRepeats', this.status.id) + } + }, + 'status.fave_num': function (num) { + // refetch favs when fave_num is changed in any way + if (this.isFocused && this.statusFromGlobalRepository.favoritedBy && this.statusFromGlobalRepository.favoritedBy.length !== num) { + this.$store.dispatch('fetchFavs', this.status.id) + } } }, filters: { diff --git a/src/components/status/status.vue b/src/components/status/status.vue index e1dd81ac..64218f6e 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -1,186 +1,431 @@ <template> - <div class="status-el" v-if="!hideStatus" :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]"> - <div v-if="error" class="alert error"> - {{error}} - <i class="button-icon icon-cancel" @click="clearError"></i> + <!-- eslint-disable vue/no-v-html --> + <div + v-if="!hideStatus" + class="status-el" + :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]" + > + <div + v-if="error" + class="alert error" + > + {{ error }} + <i + class="button-icon icon-cancel" + @click="clearError" + /> </div> <template v-if="muted && !isPreview"> <div class="media status container muted"> <small> <router-link :to="userProfileLink"> - {{status.user.screen_name}} + {{ status.user.screen_name }} </router-link> </small> - <small class="muteWords">{{muteWordHits.join(', ')}}</small> - <a href="#" class="unmute" @click.prevent="toggleMute"><i class="button-icon icon-eye-off"></i></a> + <small class="muteWords">{{ muteWordHits.join(', ') }}</small> + <a + href="#" + class="unmute" + @click.prevent="toggleMute" + ><i class="button-icon icon-eye-off" /></a> </div> </template> <template v-else> - <div v-if="showPinned && statusoid.pinned" class="status-pin"> - <i class="fa icon-pin faint"></i> - <span class="faint">{{$t('status.pinned')}}</span> + <div + v-if="showPinned" + class="status-pin" + > + <i class="fa icon-pin faint" /> + <span class="faint">{{ $t('status.pinned') }}</span> </div> - <div v-if="retweet && !noHeading && !inConversation" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info"> - <UserAvatar class="media-left" v-if="retweet" :betterShadow="betterShadow" :user="statusoid.user"/> + <div + v-if="retweet && !noHeading && !inConversation" + :class="[repeaterClass, { highlighted: repeaterStyle }]" + :style="[repeaterStyle]" + class="media container retweet-info" + > + <UserAvatar + v-if="retweet" + class="media-left" + :better-shadow="betterShadow" + :user="statusoid.user" + /> <div class="media-body faint"> <span class="user-name"> - <router-link v-if="retweeterHtml" :to="retweeterProfileLink" v-html="retweeterHtml"/> - <router-link v-else :to="retweeterProfileLink">{{retweeter}}</router-link> + <router-link + v-if="retweeterHtml" + :to="retweeterProfileLink" + v-html="retweeterHtml" + /> + <router-link + v-else + :to="retweeterProfileLink" + >{{ retweeter }}</router-link> </span> - <i class='fa icon-retweet retweeted' :title="$t('tool_tip.repeat')"></i> - {{$t('timeline.repeated')}} + <i + class="fa icon-retweet retweeted" + :title="$t('tool_tip.repeat')" + /> + {{ $t('timeline.repeated') }} </div> </div> - <div :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet && !inConversation }]" :style="[ userStyle ]" class="media status" :data-tags="tags"> - <div v-if="!noHeading" class="media-left"> - <router-link :to="userProfileLink" @click.stop.prevent.capture.native="toggleUserExpanded"> - <UserAvatar :compact="compact" :betterShadow="betterShadow" :user="status.user"/> + <div + :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet && !inConversation }]" + :style="[ userStyle ]" + class="media status" + :data-tags="tags" + > + <div + v-if="!noHeading" + class="media-left" + > + <router-link + :to="userProfileLink" + @click.stop.prevent.capture.native="toggleUserExpanded" + > + <UserAvatar + :compact="compact" + :better-shadow="betterShadow" + :user="status.user" + /> </router-link> </div> <div class="status-body"> - <UserCard :user="status.user" :rounded="true" :bordered="true" class="status-usercard" v-if="userExpanded"/> - <div v-if="!noHeading" class="media-heading"> + <UserCard + v-if="userExpanded" + :user="status.user" + :rounded="true" + :bordered="true" + class="status-usercard" + /> + <div + v-if="!noHeading" + class="media-heading" + > <div class="heading-name-row"> <div class="name-and-account-name"> - <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> - <router-link class="account-name" :to="userProfileLink"> - {{status.user.screen_name}} + <h4 + v-if="status.user.name_html" + class="user-name" + v-html="status.user.name_html" + /> + <h4 + v-else + class="user-name" + > + {{ status.user.name }} + </h4> + <router-link + class="account-name" + :to="userProfileLink" + > + {{ status.user.screen_name }} </router-link> </div> <span class="heading-right"> - <router-link class="timeago faint-link" :to="{ name: 'conversation', params: { id: status.id } }"> - <timeago :since="status.created_at" :auto-update="60"></timeago> + <router-link + class="timeago faint-link" + :to="{ name: 'conversation', params: { id: status.id } }" + > + <Timeago + :time="status.created_at" + :auto-update="60" + /> </router-link> - <div class="button-icon visibility-icon" v-if="status.visibility"> - <i :class="visibilityIcon(status.visibility)" :title="status.visibility | capitalize"></i> + <div + v-if="status.visibility" + class="button-icon visibility-icon" + > + <i + :class="visibilityIcon(status.visibility)" + :title="status.visibility | capitalize" + /> </div> - <a :href="status.external_url" target="_blank" v-if="!status.is_local && !isPreview" class="source_url" title="Source"> - <i class="button-icon icon-link-ext-alt"></i> + <a + v-if="!status.is_local && !isPreview" + :href="status.external_url" + target="_blank" + class="source_url" + title="Source" + > + <i class="button-icon icon-link-ext-alt" /> </a> <template v-if="expandable && !isPreview"> - <a href="#" @click.prevent="toggleExpanded" title="Expand"> - <i class="button-icon icon-plus-squared"></i> + <a + href="#" + title="Expand" + @click.prevent="toggleExpanded" + > + <i class="button-icon icon-plus-squared" /> </a> </template> - <a href="#" @click.prevent="toggleMute" v-if="unmuted"><i class="button-icon icon-eye-off"></i></a> + <a + v-if="unmuted" + href="#" + @click.prevent="toggleMute" + ><i class="button-icon icon-eye-off" /></a> </span> </div> <div class="heading-reply-row"> - <div v-if="isReply" class="reply-to-and-accountname"> - <a class="reply-to" - href="#" @click.prevent="gotoOriginal(status.in_reply_to_status_id)" + <div + v-if="isReply" + class="reply-to-and-accountname" + > + <a + class="reply-to" + href="#" :aria-label="$t('tool_tip.reply')" + @click.prevent="gotoOriginal(status.in_reply_to_status_id)" @mouseenter.prevent.stop="replyEnter(status.in_reply_to_status_id, $event)" @mouseleave.prevent.stop="replyLeave()" > - <i class="button-icon icon-reply" v-if="!isPreview"></i> - <span class="faint-link reply-to-text">{{$t('status.reply_to')}}</span> + <i + v-if="!isPreview" + class="button-icon icon-reply" + /> + <span class="faint-link reply-to-text">{{ $t('status.reply_to') }}</span> </a> <router-link :to="replyProfileLink"> - {{replyToName}} + {{ replyToName }} </router-link> - <span class="faint replies-separator" v-if="replies && replies.length"> + <span + v-if="replies && replies.length" + class="faint replies-separator" + > - </span> </div> - <div class="replies" v-if="inConversation && !isPreview"> - <span class="faint" v-if="replies && replies.length">{{$t('status.replies_list')}}</span> - <span class="reply-link faint" v-if="replies" v-for="reply in replies"> - <a href="#" @click.prevent="gotoOriginal(reply.id)" @mouseenter="replyEnter(reply.id, $event)" @mouseout="replyLeave()">{{reply.name}}</a> - </span> + <div + v-if="inConversation && !isPreview" + class="replies" + > + <span + v-if="replies && replies.length" + class="faint" + >{{ $t('status.replies_list') }}</span> + <template v-if="replies"> + <span + v-for="reply in replies" + :key="reply.id" + class="reply-link faint" + > + <a + href="#" + @click.prevent="gotoOriginal(reply.id)" + @mouseenter="replyEnter(reply.id, $event)" + @mouseout="replyLeave()" + >{{ reply.name }}</a> + </span> + </template> </div> </div> - - </div> - <div v-if="showPreview" class="status-preview-container"> - <status class="status-preview" + <div + v-if="showPreview" + class="status-preview-container" + > + <status v-if="preview" - :isPreview="true" + class="status-preview" + :is-preview="true" :statusoid="preview" :compact="true" /> - <div v-else class="status-preview status-preview-loading"> - <i class="icon-spin4 animate-spin"></i> + <div + v-else + class="status-preview status-preview-loading" + > + <i class="icon-spin4 animate-spin" /> </div> </div> - <div class="status-content-wrapper" :class="{ 'tall-status': !showingLongSubject }" v-if="longSubject"> - <a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="!showingLongSubject" href="#" @click.prevent="showingLongSubject=true">{{$t("general.show_more")}}</a> - <div @click.prevent="linkClicked" class="status-content media-body" v-html="contentHtml"></div> - <a v-if="showingLongSubject" href="#" class="status-unhider" @click.prevent="showingLongSubject=false">{{$t("general.show_less")}}</a> + <div + v-if="longSubject" + class="status-content-wrapper" + :class="{ 'tall-status': !showingLongSubject }" + > + <a + v-if="!showingLongSubject" + class="tall-status-hider" + :class="{ 'tall-status-hider_focused': isFocused }" + href="#" + @click.prevent="showingLongSubject=true" + >{{ $t("general.show_more") }}</a> + <div + class="status-content media-body" + @click.prevent="linkClicked" + v-html="contentHtml" + /> + <a + v-if="showingLongSubject" + href="#" + class="status-unhider" + @click.prevent="showingLongSubject=false" + >{{ $t("general.show_less") }}</a> + </div> + <div + v-else + :class="{'tall-status': hideTallStatus}" + class="status-content-wrapper" + > + <a + v-if="hideTallStatus" + class="tall-status-hider" + :class="{ 'tall-status-hider_focused': isFocused }" + href="#" + @click.prevent="toggleShowMore" + >{{ $t("general.show_more") }}</a> + <div + v-if="!hideSubjectStatus" + class="status-content media-body" + @click.prevent="linkClicked" + v-html="contentHtml" + /> + <div + v-else + class="status-content media-body" + @click.prevent="linkClicked" + v-html="status.summary_html" + /> + <a + v-if="hideSubjectStatus" + href="#" + class="cw-status-hider" + @click.prevent="toggleShowMore" + >{{ $t("general.show_more") }}</a> + <a + v-if="showingMore" + href="#" + class="status-unhider" + @click.prevent="toggleShowMore" + >{{ $t("general.show_less") }}</a> </div> - <div :class="{'tall-status': hideTallStatus}" class="status-content-wrapper" v-else> - <a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="hideTallStatus" href="#" @click.prevent="toggleShowMore">{{$t("general.show_more")}}</a> - <div @click.prevent="linkClicked" class="status-content media-body" v-html="contentHtml" v-if="!hideSubjectStatus"></div> - <div @click.prevent="linkClicked" class="status-content media-body" v-html="status.summary_html" v-else></div> - <a v-if="hideSubjectStatus" href="#" class="cw-status-hider" @click.prevent="toggleShowMore">{{$t("general.show_more")}}</a> - <a v-if="showingMore" href="#" class="status-unhider" @click.prevent="toggleShowMore">{{$t("general.show_less")}}</a> + + <div v-if="status.poll && status.poll.options"> + <poll :base-poll="status.poll" /> </div> - <div v-if="status.attachments && (!hideSubjectStatus || showingLongSubject)" class="attachments media-body"> + <div + v-if="status.attachments && (!hideSubjectStatus || showingLongSubject)" + class="attachments media-body" + > <attachment - class="non-gallery" v-for="attachment in nonGalleryAttachments" + :key="attachment.id" + class="non-gallery" :size="attachmentSize" :nsfw="nsfwClickthrough" :attachment="attachment" - :allowPlay="true" - :setMedia="setMedia()" - :key="attachment.id" + :allow-play="true" + :set-media="setMedia()" /> <gallery v-if="galleryAttachments.length > 0" :nsfw="nsfwClickthrough" :attachments="galleryAttachments" - :setMedia="setMedia()" + :set-media="setMedia()" /> </div> - <div v-if="status.card && !hideSubjectStatus && !noHeading" class="link-preview media-body"> - <link-preview :card="status.card" :size="attachmentSize" :nsfw="nsfwClickthrough" /> + <div + v-if="status.card && !hideSubjectStatus && !noHeading" + class="link-preview media-body" + > + <link-preview + :card="status.card" + :size="attachmentSize" + :nsfw="nsfwClickthrough" + /> </div> <transition name="fade"> - <div class="favs-repeated-users" v-if="isFocused && combinedFavsAndRepeatsUsers.length > 0"> + <div + v-if="!hidePostStats && isFocused && combinedFavsAndRepeatsUsers.length > 0" + class="favs-repeated-users" + > <div class="stats"> - <div class="stat-count" v-if="statusFromGlobalRepository.rebloggedBy && statusFromGlobalRepository.rebloggedBy.length > 0"> + <div + v-if="statusFromGlobalRepository.rebloggedBy && statusFromGlobalRepository.rebloggedBy.length > 0" + class="stat-count" + > <a class="stat-title">{{ $t('status.repeats') }}</a> - <div class="stat-number">{{ statusFromGlobalRepository.rebloggedBy.length }}</div> + <div class="stat-number"> + {{ statusFromGlobalRepository.rebloggedBy.length }} + </div> </div> - <div class="stat-count" v-if="statusFromGlobalRepository.favoritedBy && statusFromGlobalRepository.favoritedBy.length > 0"> + <div + v-if="statusFromGlobalRepository.favoritedBy && statusFromGlobalRepository.favoritedBy.length > 0" + class="stat-count" + > <a class="stat-title">{{ $t('status.favorites') }}</a> - <div class="stat-number">{{ statusFromGlobalRepository.favoritedBy.length }}</div> + <div class="stat-number"> + {{ statusFromGlobalRepository.favoritedBy.length }} + </div> </div> <div class="avatar-row"> - <AvatarList :users="combinedFavsAndRepeatsUsers"></AvatarList> + <AvatarList :users="combinedFavsAndRepeatsUsers" /> </div> </div> </div> </transition> - <div v-if="!noHeading && !isPreview" class='status-actions media-body'> + <div + v-if="!noHeading && !isPreview" + class="status-actions media-body" + > <div> - <i class="button-icon icon-reply" v-on:click.prevent="toggleReplying" :title="$t('tool_tip.reply')" :class="{'button-icon-active': replying}" v-if="loggedIn"/> - <i class="button-icon button-icon-disabled icon-reply" :title="$t('tool_tip.reply')" v-else /> - <span v-if="status.replies_count > 0">{{status.replies_count}}</span> + <i + v-if="loggedIn" + class="button-icon icon-reply" + :title="$t('tool_tip.reply')" + :class="{'button-icon-active': replying}" + @click.prevent="toggleReplying" + /> + <i + v-else + class="button-icon button-icon-disabled icon-reply" + :title="$t('tool_tip.reply')" + /> + <span v-if="status.replies_count > 0">{{ status.replies_count }}</span> </div> - <retweet-button :visibility='status.visibility' :loggedIn='loggedIn' :status='status'></retweet-button> - <favorite-button :loggedIn='loggedIn' :status='status'></favorite-button> - <extra-buttons :status="status" @onError="showError" @onSuccess="clearError"></extra-buttons> + <retweet-button + :visibility="status.visibility" + :logged-in="loggedIn" + :status="status" + /> + <favorite-button + :logged-in="loggedIn" + :status="status" + /> + <extra-buttons + :status="status" + @onError="showError" + @onSuccess="clearError" + /> </div> </div> </div> - <div class="container" v-if="replying"> - <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 + v-if="replying" + class="container" + > + <post-status-form + class="reply-body" + :reply-to="status.id" + :attentions="status.attentions" + :replied-user="status.user" + :copy-message-scope="status.visibility" + :subject="replySubject" + @posted="toggleReplying" + /> </div> </template> </div> +<!-- eslint-enable vue/no-v-html --> </template> <script src="./status.js" ></script> @@ -449,6 +694,7 @@ $status-margin: 0.75em; .status-content { font-family: var(--postFont, sans-serif); line-height: 1.4em; + white-space: pre-wrap; img, video { max-width: 100%; @@ -574,11 +820,12 @@ $status-margin: 0.75em; } .status-actions { + position: relative; width: 100%; display: flex; margin-top: $status-margin; - div, favorite-button { + > * { max-width: 4em; flex: 1; } diff --git a/src/components/sticker_picker/sticker_picker.js b/src/components/sticker_picker/sticker_picker.js new file mode 100644 index 00000000..a6dcded3 --- /dev/null +++ b/src/components/sticker_picker/sticker_picker.js @@ -0,0 +1,52 @@ +/* eslint-env browser */ +import statusPosterService from '../../services/status_poster/status_poster.service.js' +import TabSwitcher from '../tab_switcher/tab_switcher.js' + +const StickerPicker = { + components: [ + TabSwitcher + ], + data () { + return { + meta: { + stickers: [] + }, + path: '' + } + }, + computed: { + pack () { + return this.$store.state.instance.stickers || [] + } + }, + methods: { + clear () { + this.meta = { + stickers: [] + } + }, + pick (sticker, name) { + const store = this.$store + // TODO remove this workaround by finding a way to bypass reuploads + fetch(sticker) + .then((res) => { + res.blob().then((blob) => { + var file = new File([blob], name, { mimetype: 'image/png' }) + var formData = new FormData() + formData.append('file', file) + statusPosterService.uploadMedia({ store, formData }) + .then((fileData) => { + this.$emit('uploaded', fileData) + this.clear() + }, (error) => { + console.warn("Can't attach sticker") + console.warn(error) + this.$emit('upload-failed', 'default') + }) + }) + }) + } + } +} + +export default StickerPicker diff --git a/src/components/sticker_picker/sticker_picker.vue b/src/components/sticker_picker/sticker_picker.vue new file mode 100644 index 00000000..938204c8 --- /dev/null +++ b/src/components/sticker_picker/sticker_picker.vue @@ -0,0 +1,62 @@ +<template> + <div + class="sticker-picker" + > + <div + class="sticker-picker-panel" + > + <tab-switcher + :render-only-focused="true" + > + <div + v-for="stickerpack in pack" + :key="stickerpack.path" + :image-tooltip="stickerpack.meta.title" + :image="stickerpack.path + stickerpack.meta.tabIcon" + class="sticker-picker-content" + > + <div + v-for="sticker in stickerpack.meta.stickers" + :key="sticker" + class="sticker" + @click="pick(stickerpack.path + sticker, stickerpack.meta.title)" + > + <img + :src="stickerpack.path + sticker" + > + </div> + </div> + </tab-switcher> + </div> + </div> +</template> + +<script src="./sticker_picker.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.sticker-picker { + .sticker-picker-panel { + display: inline-block; + width: 100%; + .sticker-picker-content { + max-height: 300px; + overflow-y: scroll; + overflow-x: auto; + .sticker { + display: inline-block; + width: 20%; + height: 20%; + img { + width: 100%; + &:hover { + filter: drop-shadow(0 0 5px var(--link, $fallback--link)); + } + } + } + } + } +} + +</style> diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue index af824fa2..3fff63f9 100644 --- a/src/components/still-image/still-image.vue +++ b/src/components/still-image/still-image.vue @@ -1,7 +1,19 @@ <template> - <div class='still-image' :class='{ animated: animated }' > - <canvas ref="canvas" v-if="animated"></canvas> - <img ref="src" :src="src" :referrerpolicy="referrerpolicy" v-on:load="onLoad" @error="onError"/> + <div + class="still-image" + :class="{ animated: animated }" + > + <canvas + v-if="animated" + ref="canvas" + /> + <img + ref="src" + :src="src" + :referrerpolicy="referrerpolicy" + @load="onLoad" + @error="onError" + > </div> </template> diff --git a/src/components/style_switcher/preview.vue b/src/components/style_switcher/preview.vue index 634f5b35..101a32bd 100644 --- a/src/components/style_switcher/preview.vue +++ b/src/components/style_switcher/preview.vue @@ -1,78 +1,101 @@ <template> -<div class="panel dummy"> - <div class="panel-heading"> - <div class="title"> - {{$t('settings.style.preview.header')}} - <span class="badge badge-notification"> - 99 + <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> - <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> + <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> + <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="button-icon icon-reply"/> - <i style="color: var(--cGreen)" class="button-icon icon-retweet"/> - <i style="color: var(--cOrange)" class="button-icon icon-star"/> - <i style="color: var(--cRed)" class="button-icon icon-cancel"/> + <div class="icons"> + <i + style="color: var(--cBlue)" + class="button-icon icon-reply" + /> + <i + style="color: var(--cGreen)" + class="button-icon icon-retweet" + /> + <i + style="color: var(--cOrange)" + class="button-icon icon-star" + /> + <i + style="color: var(--cRed)" + class="button-icon icon-cancel" + /> + </div> </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 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> - <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="separator" /> - <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 class="alert error"> + {{ $t('settings.style.preview.error') }} </span> - <button class="btn"> - {{$t('settings.style.preview.button')}} - </button> + <input + :value="$t('settings.style.preview.input')" + type="text" + > + + <div class="actions"> + <span class="checkbox"> + <input + id="preview_checkbox" + checked="very yes" + type="checkbox" + > + <label for="preview_checkbox">{{ $t('settings.style.preview.checkbox') }}</label> + </span> + <button class="btn"> + {{ $t('settings.style.preview.button') }} + </button> + </div> </div> </div> -</div> </template> diff --git a/src/components/style_switcher/style_switcher.vue b/src/components/style_switcher/style_switcher.vue index 84963c81..d24394a4 100644 --- a/src/components/style_switcher/style_switcher.vue +++ b/src/components/style_switcher/style_switcher.vue @@ -1,274 +1,593 @@ <template> -<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="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 class="style-switcher"> + <div class="presets-container"> + <div class="save-load"> + <export-import + :export-object="exportedTheme" + :export-label="$t("settings.export_theme")" + :import-label="$t("settings.import_theme")" + :import-failed-text="$t("settings.invalid_theme_imported")" + :on-import="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" + :key="style.name" + :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="save-load-options"> + <span class="keep-option"> + <input + id="keep-color" + v-model="keepColor" + type="checkbox" + > + <label for="keep-color">{{ $t('settings.style.switcher.keep_color') }}</label> + </span> + <span class="keep-option"> + <input + id="keep-shadows" + v-model="keepShadows" + type="checkbox" + > + <label for="keep-shadows">{{ $t('settings.style.switcher.keep_shadows') }}</label> + </span> + <span class="keep-option"> + <input + id="keep-opacity" + v-model="keepOpacity" + type="checkbox" + > + <label for="keep-opacity">{{ $t('settings.style.switcher.keep_opacity') }}</label> + </span> + <span class="keep-option"> + <input + id="keep-roundness" + v-model="keepRoundness" + type="checkbox" + > + <label for="keep-roundness">{{ $t('settings.style.switcher.keep_roundness') }}</label> + </span> + <span class="keep-option"> + <input + id="keep-fonts" + v-model="keepFonts" + type="checkbox" + > + <label for="keep-fonts">{{ $t('settings.style.switcher.keep_fonts') }}</label> + </span> + <p>{{ $t('settings.style.switcher.save_load_hint') }}</p> + </div> </div> - </div> - <div class="preview-container"> - <preview :style="previewRules"/> - </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"/> + <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 + v-model="bgColorLocal" + name="bgColor" + :label="$t('settings.background')" + /> + <OpacityInput + v-model="bgOpacityLocal" + name="bgOpacity" + :fallback="previewTheme.opacity.bg || 1" + /> + <ColorInput + v-model="textColorLocal" + name="textColor" + :label="$t('settings.text')" + /> + <ContrastRatio :contrast="previewContrast.bgText" /> + <ColorInput + v-model="linkColorLocal" + name="linkColor" + :label="$t('settings.links')" + /> + <ContrastRatio :contrast="previewContrast.bgLink" /> + </div> + <div class="color-item"> + <ColorInput + v-model="fgColorLocal" + name="fgColor" + :label="$t('settings.foreground')" + /> + <ColorInput + v-model="fgTextColorLocal" + name="fgTextColor" + :label="$t('settings.text')" + :fallback="previewTheme.colors.fgText" + /> + <ColorInput + v-model="fgLinkColorLocal" + name="fgLinkColor" + :label="$t('settings.links')" + :fallback="previewTheme.colors.fgLink" + /> + <p>{{ $t('settings.style.common_colors.foreground_hint') }}</p> + </div> + <h4>{{ $t('settings.style.common_colors.rgbo') }}</h4> + <div class="color-item"> + <ColorInput + v-model="cRedColorLocal" + name="cRedColor" + :label="$t('settings.cRed')" + /> + <ContrastRatio :contrast="previewContrast.bgRed" /> + <ColorInput + v-model="cBlueColorLocal" + name="cBlueColor" + :label="$t('settings.cBlue')" + /> + <ContrastRatio :contrast="previewContrast.bgBlue" /> + </div> + <div class="color-item"> + <ColorInput + v-model="cGreenColorLocal" + name="cGreenColor" + :label="$t('settings.cGreen')" + /> + <ContrastRatio :contrast="previewContrast.bgGreen" /> + <ColorInput + v-model="cOrangeColorLocal" + name="cOrangeColor" + :label="$t('settings.cOrange')" + /> + <ContrastRatio :contrast="previewContrast.bgOrange" /> + </div> + <p>{{ $t('settings.theme_help_v2_2') }}</p> </div> - <p>{{$t('settings.theme_help_v2_2')}}</p> - </div> - <div :label="$t('settings.style.advanced_colors._tab_label')" class="color-container"> - <div class="tab-header"> - <p>{{$t('settings.theme_help')}}</p> - <button class="btn" @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 + :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 + v-model="alertErrorColorLocal" + name="alertError" + :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 + v-model="badgeNotificationColorLocal" + name="badgeNotification" + :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 + v-model="panelColorLocal" + name="panelColor" + :fallback="fgColorLocal" + :label="$t('settings.background')" + /> + <OpacityInput + v-model="panelOpacityLocal" + name="panelOpacity" + :fallback="previewTheme.opacity.panel || 1" + /> + <ColorInput + v-model="panelTextColorLocal" + name="panelTextColor" + :fallback="previewTheme.colors.panelText" + :label="$t('settings.text')" + /> + <ContrastRatio + :contrast="previewContrast.panelText" + large="1" + /> + <ColorInput + v-model="panelLinkColorLocal" + name="panelLinkColor" + :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 + v-model="topBarColorLocal" + name="topBarColor" + :fallback="fgColorLocal" + :label="$t('settings.background')" + /> + <ColorInput + v-model="topBarTextColorLocal" + name="topBarTextColor" + :fallback="previewTheme.colors.topBarText" + :label="$t('settings.text')" + /> + <ContrastRatio :contrast="previewContrast.topBarText" /> + <ColorInput + v-model="topBarLinkColorLocal" + name="topBarLinkColor" + :fallback="previewTheme.colors.topBarLink" + :label="$t('settings.links')" + /> + <ContrastRatio :contrast="previewContrast.topBarLink" /> + </div> + <div class="color-item"> + <h4>{{ $t('settings.style.advanced_colors.inputs') }}</h4> + <ColorInput + v-model="inputColorLocal" + name="inputColor" + :fallback="fgColorLocal" + :label="$t('settings.background')" + /> + <OpacityInput + v-model="inputOpacityLocal" + name="inputOpacity" + :fallback="previewTheme.opacity.input || 1" + /> + <ColorInput + v-model="inputTextColorLocal" + name="inputTextColor" + :fallback="previewTheme.colors.inputText" + :label="$t('settings.text')" + /> + <ContrastRatio :contrast="previewContrast.inputText" /> + </div> + <div class="color-item"> + <h4>{{ $t('settings.style.advanced_colors.buttons') }}</h4> + <ColorInput + v-model="btnColorLocal" + name="btnColor" + :fallback="fgColorLocal" + :label="$t('settings.background')" + /> + <OpacityInput + v-model="btnOpacityLocal" + name="btnOpacity" + :fallback="previewTheme.opacity.btn || 1" + /> + <ColorInput + v-model="btnTextColorLocal" + name="btnTextColor" + :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 + v-model="borderColorLocal" + name="borderColor" + :fallback="previewTheme.colors.border" + :label="$t('settings.style.common.color')" + /> + <OpacityInput + v-model="borderOpacityLocal" + name="borderOpacity" + :fallback="previewTheme.opacity.border || 1" + /> + </div> + <div class="color-item"> + <h4>{{ $t('settings.style.advanced_colors.faint_text') }}</h4> + <ColorInput + v-model="faintColorLocal" + name="faintColor" + :fallback="previewTheme.colors.faint || 1" + :label="$t('settings.text')" + /> + <ColorInput + v-model="faintLinkColorLocal" + name="faintLinkColor" + :fallback="previewTheme.colors.faintLink" + :label="$t('settings.links')" + /> + <ColorInput + v-model="panelFaintColorLocal" + name="panelFaintColor" + :fallback="previewTheme.colors.panelFaint" + :label="$t('settings.style.advanced_colors.panel_header')" + /> + <OpacityInput + v-model="faintOpacityLocal" + name="faintOpacity" + :fallback="previewTheme.opacity.faint || 0.5" + /> + </div> </div> - </div> - <div :label="$t('settings.style.radii._tab_label')" class="radius-container"> - <div class="tab-header"> - <p>{{$t('settings.radii_help')}}</p> - <button class="btn" @click="clearRoundness">{{$t('settings.style.switcher.clear_all')}}</button> + <div + :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 + v-model="btnRadiusLocal" + name="btnRadius" + :label="$t('settings.btnRadius')" + :fallback="previewTheme.radii.btn" + max="16" + hard-min="0" + /> + <RangeInput + v-model="inputRadiusLocal" + name="inputRadius" + :label="$t('settings.inputRadius')" + :fallback="previewTheme.radii.input" + max="9" + hard-min="0" + /> + <RangeInput + v-model="checkboxRadiusLocal" + name="checkboxRadius" + :label="$t('settings.checkboxRadius')" + :fallback="previewTheme.radii.checkbox" + max="16" + hard-min="0" + /> + <RangeInput + v-model="panelRadiusLocal" + name="panelRadius" + :label="$t('settings.panelRadius')" + :fallback="previewTheme.radii.panel" + max="50" + hard-min="0" + /> + <RangeInput + v-model="avatarRadiusLocal" + name="avatarRadius" + :label="$t('settings.avatarRadius')" + :fallback="previewTheme.radii.avatar" + max="28" + hard-min="0" + /> + <RangeInput + v-model="avatarAltRadiusLocal" + name="avatarAltRadius" + :label="$t('settings.avatarAltRadius')" + :fallback="previewTheme.radii.avatarAlt" + max="28" + hard-min="0" + /> + <RangeInput + v-model="attachmentRadiusLocal" + name="attachmentRadius" + :label="$t('settings.attachmentRadius')" + :fallback="previewTheme.radii.attachment" + max="50" + hard-min="0" + /> + <RangeInput + v-model="tooltipRadiusLocal" + name="tooltipRadius" + :label="$t('settings.tooltipRadius')" + :fallback="previewTheme.radii.tooltip" + max="50" + hard-min="0" + /> </div> - <RangeInput name="btnRadius" :label="$t('settings.btnRadius')" v-model="btnRadiusLocal" :fallback="previewTheme.radii.btn" max="16" hardMin="0"/> - <RangeInput name="inputRadius" :label="$t('settings.inputRadius')" v-model="inputRadiusLocal" :fallback="previewTheme.radii.input" max="9" hardMin="0"/> - <RangeInput name="checkboxRadius" :label="$t('settings.checkboxRadius')" v-model="checkboxRadiusLocal" :fallback="previewTheme.radii.checkbox" max="16" hardMin="0"/> - <RangeInput name="panelRadius" :label="$t('settings.panelRadius')" v-model="panelRadiusLocal" :fallback="previewTheme.radii.panel" max="50" hardMin="0"/> - <RangeInput name="avatarRadius" :label="$t('settings.avatarRadius')" v-model="avatarRadiusLocal" :fallback="previewTheme.radii.avatar" max="28" hardMin="0"/> - <RangeInput name="avatarAltRadius" :label="$t('settings.avatarAltRadius')" v-model="avatarAltRadiusLocal" :fallback="previewTheme.radii.avatarAlt" max="28" hardMin="0"/> - <RangeInput name="attachmentRadius" :label="$t('settings.attachmentRadius')" v-model="attachmentRadiusLocal" :fallback="previewTheme.radii.attachment" max="50" hardMin="0"/> - <RangeInput name="tooltipRadius" :label="$t('settings.tooltipRadius')" v-model="tooltipRadiusLocal" :fallback="previewTheme.radii.tooltip" max="50" hardMin="0"/> - </div> - <div :label="$t('settings.style.shadows._tab_label')" class="shadow-container"> - <div class="tab-header shadow-selector"> - <div class="select-container"> - {{$t('settings.style.shadows.component')}} - <label for="shadow-switcher" class="select"> - <select id="shadow-switcher" v-model="shadowSelected" class="shadow-switcher"> - <option v-for="shadow in shadowsAvailable" - :value="shadow"> - {{$t('settings.style.shadows.components.' + shadow)}} - </option> - </select> - <i class="icon-down-open"/> - </label> + <div + :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" + :key="shadow" + :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 + id="override" + v-model="currentShadowOverriden" + name="override" + class="input-override" + type="checkbox" + > + <label + class="checkbox-label" + for="override" + /> + </div> + <button + class="btn" + @click="clearShadows" + > + {{ $t('settings.style.switcher.clear_all') }} + </button> </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> + <shadow-control + v-model="currentShadow" + :ready="!!currentShadowFallback" + :fallback="currentShadowFallback" + /> + <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> - <button class="btn" @click="clearShadows">{{$t('settings.style.switcher.clear_all')}}</button> </div> - <shadow-control :ready="!!currentShadowFallback" :fallback="currentShadowFallback" v-model="currentShadow"/> - <div v-if="shadowSelected === 'avatar' || shadowSelected === 'avatarStatus'"> - <i18n path="settings.style.shadows.filter_hint.always_drop_shadow" tag="p"> - <code>filter: drop-shadow()</code> - </i18n> - <p>{{$t('settings.style.shadows.filter_hint.avatar_inset')}}</p> - <i18n path="settings.style.shadows.filter_hint.drop_shadow_syntax" tag="p"> - <code>drop-shadow</code> - <code>spread-radius</code> - <code>inset</code> - </i18n> - <i18n path="settings.style.shadows.filter_hint.inset_classic" tag="p"> - <code>box-shadow</code> - </i18n> - <p>{{$t('settings.style.shadows.filter_hint.spread_zero')}}</p> - </div> - </div> - <div :label="$t('settings.style.fonts._tab_label')" class="fonts-container"> - <div class="tab-header"> - <p>{{$t('settings.style.fonts.help')}}</p> - <button class="btn" @click="clearFonts">{{$t('settings.style.switcher.clear_all')}}</button> + <div + :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 + v-model="fontsLocal.interface" + name="ui" + :label="$t('settings.style.fonts.components.interface')" + :fallback="previewTheme.fonts.interface" + no-inherit="1" + /> + <FontControl + v-model="fontsLocal.input" + name="input" + :label="$t('settings.style.fonts.components.input')" + :fallback="previewTheme.fonts.input" + /> + <FontControl + v-model="fontsLocal.post" + name="post" + :label="$t('settings.style.fonts.components.post')" + :fallback="previewTheme.fonts.post" + /> + <FontControl + v-model="fontsLocal.postCode" + name="postCode" + :label="$t('settings.style.fonts.components.postCode')" + :fallback="previewTheme.fonts.postCode" + /> </div> - <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> + </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 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> -</div> </template> <script src="./style_switcher.js"></script> diff --git a/src/components/tab_switcher/tab_switcher.js b/src/components/tab_switcher/tab_switcher.js index c949b458..08d5d08f 100644 --- a/src/components/tab_switcher/tab_switcher.js +++ b/src/components/tab_switcher/tab_switcher.js @@ -4,19 +4,19 @@ import './tab_switcher.scss' export default Vue.component('tab-switcher', { name: 'TabSwitcher', - props: ['renderOnlyFocused', 'onSwitch'], + props: ['renderOnlyFocused', 'onSwitch', 'activeTab'], data () { return { active: this.$slots.default.findIndex(_ => _.tag) } }, - methods: { - activateTab (index, dataset) { - return () => { - if (typeof this.onSwitch === 'function') { - this.onSwitch.call(null, index, this.$slots.default[index].elm.dataset) - } - this.active = index + computed: { + activeIndex () { + // In case of controlled component + if (this.activeTab) { + return this.$slots.default.findIndex(slot => this.activeTab === slot.key) + } else { + return this.active } } }, @@ -26,32 +26,54 @@ export default Vue.component('tab-switcher', { this.active = this.$slots.default.findIndex(_ => _.tag) } }, + methods: { + activateTab (index) { + return () => { + if (typeof this.onSwitch === 'function') { + this.onSwitch.call(null, this.$slots.default[index].key) + } + this.active = index + } + } + }, render (h) { const tabs = this.$slots.default - .map((slot, index) => { - if (!slot.tag) return - const classesTab = ['tab'] - const classesWrapper = ['tab-wrapper'] - - if (index === this.active) { - classesTab.push('active') - classesWrapper.push('active') - } + .map((slot, index) => { + if (!slot.tag) return + const classesTab = ['tab'] + const classesWrapper = ['tab-wrapper'] - return ( - <div class={ classesWrapper.join(' ')}> - <button - disabled={slot.data.attrs.disabled} - onClick={this.activateTab(index)} - class={classesTab.join(' ')}> - {slot.data.attrs.label}</button> - </div> - ) - }) + if (this.activeIndex === index) { + classesTab.push('active') + classesWrapper.push('active') + } + if (slot.data.attrs.image) { + return ( + <div class={classesWrapper.join(' ')}> + <button + disabled={slot.data.attrs.disabled} + onClick={this.activateTab(index)} + class={classesTab.join(' ')}> + <img src={slot.data.attrs.image} title={slot.data.attrs['image-tooltip']}/> + {slot.data.attrs.label ? '' : slot.data.attrs.label} + </button> + </div> + ) + } + return ( + <div class={classesWrapper.join(' ')}> + <button + disabled={slot.data.attrs.disabled} + onClick={this.activateTab(index)} + class={classesTab.join(' ')}> + {slot.data.attrs.label}</button> + </div> + ) + }) const contents = this.$slots.default.map((slot, index) => { if (!slot.tag) return - const active = index === this.active + const active = this.activeIndex === index if (this.renderOnlyFocused) { return active ? <div class="active">{slot}</div> diff --git a/src/components/tab_switcher/tab_switcher.scss b/src/components/tab_switcher/tab_switcher.scss index f7449439..4eeb42e0 100644 --- a/src/components/tab_switcher/tab_switcher.scss +++ b/src/components/tab_switcher/tab_switcher.scss @@ -53,6 +53,12 @@ background: transparent; z-index: 5; } + + img { + max-height: 26px; + vertical-align: top; + margin-top: -5px; + } } &:not(.active) { diff --git a/src/components/tag_timeline/tag_timeline.vue b/src/components/tag_timeline/tag_timeline.vue index 62bb579a..ace96c3f 100644 --- a/src/components/tag_timeline/tag_timeline.vue +++ b/src/components/tag_timeline/tag_timeline.vue @@ -1,5 +1,10 @@ <template> - <Timeline :title="tag" :timeline="timeline" :timeline-name="'tag'" :tag="tag" /> + <Timeline + :title="tag" + :timeline="timeline" + :timeline-name="'tag'" + :tag="tag" + /> </template> -<script src='./tag_timeline.js'></script>
\ No newline at end of file +<script src='./tag_timeline.js'></script> diff --git a/src/components/terms_of_service_panel/terms_of_service_panel.vue b/src/components/terms_of_service_panel/terms_of_service_panel.vue index eb0f2527..63dc58b8 100644 --- a/src/components/terms_of_service_panel/terms_of_service_panel.vue +++ b/src/components/terms_of_service_panel/terms_of_service_panel.vue @@ -2,8 +2,12 @@ <div> <div class="panel panel-default"> <div class="panel-body"> - <div v-html="content" class="tos-content"> - </div> + <!-- eslint-disable vue/no-v-html --> + <div + class="tos-content" + v-html="content" + /> + <!-- eslint-enable vue/no-v-html --> </div> </div> </div> diff --git a/src/components/timeago/timeago.vue b/src/components/timeago/timeago.vue new file mode 100644 index 00000000..6df0524d --- /dev/null +++ b/src/components/timeago/timeago.vue @@ -0,0 +1,51 @@ +<template> + <time + :datetime="time" + :title="localeDateString" + > + {{ $t(relativeTime.key, [relativeTime.num]) }} + </time> +</template> + +<script> +import * as DateUtils from 'src/services/date_utils/date_utils.js' + +export default { + name: 'Timeago', + props: ['time', 'autoUpdate', 'longFormat', 'nowThreshold'], + data () { + return { + relativeTime: { key: 'time.now', num: 0 }, + interval: null + } + }, + computed: { + localeDateString () { + return typeof this.time === 'string' + ? new Date(Date.parse(this.time)).toLocaleString() + : this.time.toLocaleString() + } + }, + created () { + this.refreshRelativeTimeObject() + }, + destroyed () { + clearTimeout(this.interval) + }, + methods: { + refreshRelativeTimeObject () { + const nowThreshold = typeof this.nowThreshold === 'number' ? this.nowThreshold : 1 + this.relativeTime = this.longFormat + ? DateUtils.relativeTime(this.time, nowThreshold) + : DateUtils.relativeTimeShort(this.time, nowThreshold) + + if (this.autoUpdate) { + this.interval = setTimeout( + this.refreshRelativeTimeObject, + 1000 * this.autoUpdate + ) + } + } + } +} +</script> diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index 19d9a9ac..8df48f7f 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -1,7 +1,20 @@ import Status from '../status/status.vue' import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js' import Conversation from '../conversation/conversation.vue' -import { throttle } from 'lodash' +import { throttle, keyBy } from 'lodash' + +export const getExcludedStatusIdsByPinning = (statuses, pinnedStatusIds) => { + const ids = [] + if (pinnedStatusIds && pinnedStatusIds.length > 0) { + for (let status of statuses) { + if (!pinnedStatusIds.includes(status.id)) { + break + } + ids.push(status.id) + } + } + return ids +} const Timeline = { props: [ @@ -11,7 +24,8 @@ const Timeline = { 'userId', 'tag', 'embedded', - 'count' + 'count', + 'pinnedStatusIds' ], data () { return { @@ -39,6 +53,15 @@ const Timeline = { body: ['timeline-body'].concat(!this.embedded ? ['panel-body'] : []), footer: ['timeline-footer'].concat(!this.embedded ? ['panel-footer'] : []) } + }, + // id map of statuses which need to be hidden in the main list due to pinning logic + excludedStatusIdsObject () { + const ids = getExcludedStatusIdsByPinning(this.timeline.visibleStatuses, this.pinnedStatusIds) + // Convert id array to object + return keyBy(ids) + }, + pinnedStatusIdsObject () { + return keyBy(this.pinnedStatusIds) } }, components: { @@ -78,13 +101,15 @@ const Timeline = { }, methods: { handleShortKey (e) { + // Ignore when input fields are focused + if (['textarea', 'input'].includes(e.target.tagName.toLowerCase())) return if (e.key === '.') this.showNewStatuses() }, showNewStatuses () { if (this.newStatusCount === 0) return if (this.timeline.flushMarker !== 0) { - this.$store.commit('clearTimeline', { timeline: this.timelineName }) + this.$store.commit('clearTimeline', { timeline: this.timelineName, excludeUserId: true }) this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 }) this.fetchOlderStatuses() } else { @@ -137,7 +162,7 @@ const Timeline = { if (top < 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 e6a8d458..4ad51714 100644 --- a/src/components/timeline/timeline.vue +++ b/src/components/timeline/timeline.vue @@ -2,41 +2,78 @@ <div :class="classes.root"> <div :class="classes.header"> <div class="title"> - {{title}} + {{ title }} </div> - <div @click.prevent class="loadmore-error alert error" v-if="timelineError"> - {{$t('timeline.error_fetching')}} + <div + v-if="timelineError" + class="loadmore-error alert error" + @click.prevent + > + {{ $t('timeline.error_fetching') }} </div> - <button @click.prevent="showNewStatuses" class="loadmore-button" v-if="timeline.newStatusCount > 0 && !timelineError"> - {{$t('timeline.show_new')}}{{newStatusCountStr}} + <button + v-if="timeline.newStatusCount > 0 && !timelineError" + class="loadmore-button" + @click.prevent="showNewStatuses" + > + {{ $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 + v-if="!timeline.newStatusCount > 0 && !timelineError" + class="loadmore-text faint" + @click.prevent + > + {{ $t('timeline.up_to_date') }} </div> </div> <div :class="classes.body"> <div class="timeline"> - <conversation - v-for="status in timeline.visibleStatuses" - class="status-fadein" - :key="status.id" - :statusoid="status" - :collapsable="true" - /> + <template v-for="statusId in pinnedStatusIds"> + <conversation + v-if="timeline.statusesObject[statusId]" + :key="statusId + '-pinned'" + class="status-fadein" + :statusoid="timeline.statusesObject[statusId]" + :collapsable="true" + :pinned-status-ids-object="pinnedStatusIdsObject" + /> + </template> + <template v-for="status in timeline.visibleStatuses"> + <conversation + v-if="!excludedStatusIdsObject[status.id]" + :key="status.id" + class="status-fadein" + :statusoid="status" + :collapsable="true" + /> + </template> </div> </div> <div :class="classes.footer"> - <div v-if="count===0" class="new-status-notification text-center panel-footer faint"> - {{$t('timeline.no_statuses')}} + <div + v-if="count===0" + class="new-status-notification text-center panel-footer faint" + > + {{ $t('timeline.no_statuses') }} </div> - <div v-else-if="bottomedOut" class="new-status-notification text-center panel-footer faint"> - {{$t('timeline.no_more_statuses')}} + <div + v-else-if="bottomedOut" + class="new-status-notification text-center panel-footer faint" + > + {{ $t('timeline.no_more_statuses') }} </div> - <a v-else-if="!timeline.loading" href="#" v-on:click.prevent='fetchOlderStatuses()'> - <div class="new-status-notification text-center panel-footer">{{$t('timeline.load_older')}}</div> + <a + v-else-if="!timeline.loading" + href="#" + @click.prevent="fetchOlderStatuses()" + > + <div class="new-status-notification text-center panel-footer">{{ $t('timeline.load_older') }}</div> </a> - <div v-else class="new-status-notification text-center panel-footer"> - <i class="icon-spin3 animate-spin"/> + <div + v-else + class="new-status-notification text-center panel-footer" + > + <i class="icon-spin3 animate-spin" /> </div> </div> </div> diff --git a/src/components/user_avatar/user_avatar.js b/src/components/user_avatar/user_avatar.js index a42b9c71..4adf8211 100644 --- a/src/components/user_avatar/user_avatar.js +++ b/src/components/user_avatar/user_avatar.js @@ -16,7 +16,7 @@ const UserAvatar = { }, computed: { imgSrc () { - return this.showPlaceholder ? '/images/avi.png' : this.src + return this.showPlaceholder ? '/images/avi.png' : this.user.profile_image_url_original } }, methods: { diff --git a/src/components/user_avatar/user_avatar.vue b/src/components/user_avatar/user_avatar.vue index e5466fdf..9ffb28d8 100644 --- a/src/components/user_avatar/user_avatar.vue +++ b/src/components/user_avatar/user_avatar.vue @@ -3,9 +3,9 @@ class="avatar" :alt="user.screen_name" :title="user.screen_name" - :src="user.profile_image_url_original" + :src="imgSrc" :class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }" - :imageLoadError="imageLoadError" + :image-load-error="imageLoadError" /> </template> diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js index 7c6ffa89..82d3b835 100644 --- a/src/components/user_card/user_card.js +++ b/src/components/user_card/user_card.js @@ -1,12 +1,13 @@ import UserAvatar from '../user_avatar/user_avatar.vue' import RemoteFollow from '../remote_follow/remote_follow.vue' +import ProgressButton from '../progress_button/progress_button.vue' import ModerationTools from '../moderation_tools/moderation_tools.vue' import { hex2rgb } from '../../services/color_convert/color_convert.js' import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' export default { - props: [ 'user', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered' ], + props: [ 'user', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered', 'allowZoomingAvatar' ], data () { return { followRequestInProgress: false, @@ -23,15 +24,15 @@ export default { computed: { classes () { return [{ - 'user-card-rounded-t': this.rounded === 'top', // set border-top-left-radius and border-top-right-radius - 'user-card-rounded': this.rounded === true, // set border-radius for all sides - 'user-card-bordered': this.bordered === true // set border for all sides + 'user-card-rounded-t': this.rounded === 'top', // set border-top-left-radius and border-top-right-radius + 'user-card-rounded': this.rounded === true, // set border-radius for all sides + 'user-card-bordered': this.bordered === true // set border for all sides }] }, style () { const color = this.$store.state.config.customTheme.colors - ? this.$store.state.config.customTheme.colors.bg // v2 - : this.$store.state.config.colors.bg // v1 + ? this.$store.state.config.customTheme.colors.bg // v2 + : this.$store.state.config.colors.bg // v1 if (color) { const rgb = (typeof color === 'string') ? hex2rgb(color) : color @@ -73,12 +74,12 @@ export default { userHighlightType: { get () { const data = this.$store.state.config.highlight[this.user.screen_name] - return data && data.type || 'disabled' + 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 }) + 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 }) } @@ -104,13 +105,14 @@ export default { components: { UserAvatar, RemoteFollow, - ModerationTools + ModerationTools, + ProgressButton }, methods: { followUser () { const store = this.$store this.followRequestInProgress = true - requestFollow(this.user, store).then(({sent}) => { + requestFollow(this.user, store).then(({ sent }) => { this.followRequestInProgress = false this.followRequestSent = sent }) @@ -135,13 +137,19 @@ export default { unmuteUser () { this.$store.dispatch('unmuteUser', this.user.id) }, + subscribeUser () { + return this.$store.dispatch('subscribeUser', this.user.id) + }, + unsubscribeUser () { + return this.$store.dispatch('unsubscribeUser', this.user.id) + }, setProfileView (v) { if (this.switcher) { const store = this.$store store.commit('setProfileView', { v }) } }, - linkClicked ({target}) { + linkClicked ({ target }) { if (target.tagName === 'SPAN') { target = target.parentNode } @@ -154,6 +162,14 @@ export default { }, reportUser () { this.$store.dispatch('openUserReportingModal', this.user.id) + }, + zoomAvatar () { + const attachment = { + url: this.user.profile_image_url_original, + mimetype: 'image' + } + this.$store.dispatch('setMedia', [attachment]) + this.$store.dispatch('setCurrent', attachment) } } } diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index b4495673..fc18e240 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -1,65 +1,143 @@ <template> -<div class="user-card" :class="classes" :style="style"> - <div class="panel-heading"> - <div class='user-info'> - <div class='container'> - <router-link :to="userProfileLink(user)"> - <UserAvatar :betterShadow="betterShadow" :user="user"/> - </router-link> - <div class="user-summary"> - <div class="top-line"> - <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 :to="{ name: 'user-settings' }" v-if="!isOtherUser"> - <i class="button-icon icon-wrench usersettings" :title="$t('tool_tip.user_settings')"></i> - </router-link> - <a :href="user.statusnet_profile_url" target="_blank" v-if="isOtherUser && !user.is_local"> - <i class="icon-link-ext usersettings"></i> - </a> + <div + class="user-card" + :class="classes" + :style="style" + > + <div class="panel-heading"> + <div class="user-info"> + <div class="container"> + <a + v-if="allowZoomingAvatar" + class="user-info-avatar-link" + @click="zoomAvatar" + > + <UserAvatar + :better-shadow="betterShadow" + :user="user" + /> + <div class="user-info-avatar-link-overlay"> + <i class="button-icon icon-zoom-in" /> + </div> + </a> + <router-link + v-else + :to="userProfileLink(user)" + > + <UserAvatar + :better-shadow="betterShadow" + :user="user" + /> + </router-link> + <div class="user-summary"> + <div class="top-line"> + <!-- eslint-disable vue/no-v-html --> + <div + v-if="user.name_html" + :title="user.name" + class="user-name" + v-html="user.name_html" + /> + <!-- eslint-enable vue/no-v-html --> + <div + v-else + :title="user.name" + class="user-name" + > + {{ user.name }} + </div> + <router-link + v-if="!isOtherUser" + :to="{ name: 'user-settings' }" + > + <i + class="button-icon icon-wrench usersettings" + :title="$t('tool_tip.user_settings')" + /> + </router-link> + <a + v-if="isOtherUser && !user.is_local" + :href="user.statusnet_profile_url" + target="_blank" + > + <i class="icon-link-ext usersettings" /> + </a> + </div> + + <div class="bottom-line"> + <router-link + class="user-screen-name" + :to="userProfileLink(user)" + > + @{{ user.screen_name }} + </router-link> + <span + v-if="!hideBio && !!visibleRole" + class="alert staff" + >{{ visibleRole }}</span> + <span v-if="user.locked"><i class="icon icon-lock" /></span> + <span + v-if="!hideUserStatsLocal && !hideBio" + class="dailyAvg" + >{{ dailyAvg }} {{ $t('user_card.per_day') }}</span> + </div> </div> - - <div class="bottom-line"> - <router-link class="user-screen-name" :to="userProfileLink(user)">@{{user.screen_name}}</router-link> - <span class="alert staff" v-if="!hideBio && !!visibleRole">{{visibleRole}}</span> - <span v-if="user.locked"><i class="icon icon-lock"></i></span> - <span v-if="!hideUserStatsLocal && !hideBio" class="dailyAvg">{{dailyAvg}} {{ $t('user_card.per_day') }}</span> - </div> - </div> - </div> - <div class="user-meta"> - <div v-if="user.follows_you && loggedIn && isOtherUser" class="following"> - {{ $t('user_card.follows_you') }} </div> - <div class="highlighter" v-if="isOtherUser && (loggedIn || !switcher)"> - <!-- 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 class="user-meta"> + <div + v-if="user.follows_you && loggedIn && isOtherUser" + class="following" + > + {{ $t('user_card.follows_you') }} + </div> + <div + v-if="isOtherUser && (loggedIn || !switcher)" + class="highlighter" + > + <!-- id's need to be unique, otherwise vue confuses which user-card checkbox belongs to --> + <input + v-if="userHighlightType !== 'disabled'" + :id="'userHighlightColorTx'+user.id" + v-model="userHighlightColor" + class="userHighlightText" + type="text" + > + <input + v-if="userHighlightType !== 'disabled'" + :id="'userHighlightColor'+user.id" + v-model="userHighlightColor" + class="userHighlightCl" + type="color" + > + <label + for="style-switcher" + class="userHighlightSel select" + > + <select + :id="'userHighlightSel'+user.id" + v-model="userHighlightType" + class="userHighlightSel" + > + <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 v-if="isOtherUser" class="user-interactions"> - <div class="follow" v-if="loggedIn"> - <span v-if="user.following"> - <!--Following them!--> - <button @click="unfollowUser" class="pressed" :disabled="followRequestInProgress" :title="$t('user_card.follow_unfollow')"> - <template v-if="followRequestInProgress"> - {{ $t('user_card.follow_progress') }} - </template> - <template v-else> - {{ $t('user_card.following') }} - </template> - </button> - </span> - <span v-if="!user.following"> - <button @click="followUser" :disabled="followRequestInProgress" :title="followRequestSent ? $t('user_card.follow_again') : ''"> + <div + v-if="loggedIn && isOtherUser" + class="user-interactions" + > + <div v-if="!user.following"> + <button + class="btn btn-default btn-block" + :disabled="followRequestInProgress" + :title="followRequestSent ? $t('user_card.follow_again') : ''" + @click="followUser" + > <template v-if="followRequestInProgress"> {{ $t('user_card.follow_progress') }} </template> @@ -70,65 +148,148 @@ {{ $t('user_card.follow') }} </template> </button> - </span> - </div> - <div class='mute' v-if='isOtherUser && loggedIn'> - <span v-if='user.muted'> - <button @click="unmuteUser" class="pressed"> + </div> + <div v-else-if="followRequestInProgress"> + <button + class="btn btn-default btn-block pressed" + disabled + :title="$t('user_card.follow_unfollow')" + @click="unfollowUser" + > + {{ $t('user_card.follow_progress') }} + </button> + </div> + <div + v-else + class="btn-group" + > + <button + class="btn btn-default pressed" + :title="$t('user_card.follow_unfollow')" + @click="unfollowUser" + > + {{ $t('user_card.following') }} + </button> + <ProgressButton + v-if="!user.subscribed" + class="btn btn-default" + :click="subscribeUser" + :title="$t('user_card.subscribe')" + > + <i class="icon-bell-alt" /> + </ProgressButton> + <ProgressButton + v-else + class="btn btn-default pressed" + :click="unsubscribeUser" + :title="$t('user_card.unsubscribe')" + > + <i class="icon-bell-ringing-o" /> + </ProgressButton> + </div> + + <div> + <button + v-if="user.muted" + class="btn btn-default btn-block pressed" + @click="unmuteUser" + > {{ $t('user_card.muted') }} </button> - </span> - <span v-if='!user.muted'> - <button @click="muteUser"> + <button + v-else + class="btn btn-default btn-block" + @click="muteUser" + > {{ $t('user_card.mute') }} </button> - </span> - </div> - <div v-if='!loggedIn && user.is_local'> - <RemoteFollow :user="user" /> - </div> - <div class='block' v-if='isOtherUser && loggedIn'> - <span v-if='user.statusnet_blocking'> - <button @click="unblockUser" class="pressed"> + </div> + + <div> + <button + v-if="user.statusnet_blocking" + class="btn btn-default btn-block pressed" + @click="unblockUser" + > {{ $t('user_card.blocked') }} </button> - </span> - <span v-if='!user.statusnet_blocking'> - <button @click="blockUser"> + <button + v-else + class="btn btn-default btn-block" + @click="blockUser" + > {{ $t('user_card.block') }} </button> - </span> - </div> - <div class='block' v-if='isOtherUser && loggedIn'> - <span> - <button @click="reportUser"> + </div> + + <div> + <button + class="btn btn-default btn-block" + @click="reportUser" + > {{ $t('user_card.report') }} </button> - </span> + </div> + + <ModerationTools + v-if="loggedIn.role === "admin"" + :user="user" + /> + </div> + <div + v-if="!loggedIn && user.is_local" + class="user-interactions" + > + <RemoteFollow :user="user" /> </div> - <ModerationTools :user='user' v-if='loggedIn.role === "admin"'/> </div> </div> - </div> - <div class="panel-body" v-if="!hideBio"> - <div v-if="!hideUserStatsLocal && switcher" class="user-counts"> - <div class="user-count" v-on:click.prevent="setProfileView('statuses')"> - <h5>{{ $t('user_card.statuses') }}</h5> - <span>{{user.statuses_count}} <br></span> - </div> - <div class="user-count" v-on:click.prevent="setProfileView('friends')"> - <h5>{{ $t('user_card.followees') }}</h5> - <span>{{user.friends_count}}</span> - </div> - <div class="user-count" v-on:click.prevent="setProfileView('followers')"> - <h5>{{ $t('user_card.followers') }}</h5> - <span>{{user.followers_count}}</span> + <div + v-if="!hideBio" + class="panel-body" + > + <div + v-if="!hideUserStatsLocal && switcher" + class="user-counts" + > + <div + class="user-count" + @click.prevent="setProfileView('statuses')" + > + <h5>{{ $t('user_card.statuses') }}</h5> + <span>{{ user.statuses_count }} <br></span> + </div> + <div + class="user-count" + @click.prevent="setProfileView('friends')" + > + <h5>{{ $t('user_card.followees') }}</h5> + <span>{{ user.friends_count }}</span> + </div> + <div + class="user-count" + @click.prevent="setProfileView('followers')" + > + <h5>{{ $t('user_card.followers') }}</h5> + <span>{{ user.followers_count }}</span> + </div> </div> + <!-- eslint-disable vue/no-v-html --> + <p + v-if="!hideBio && user.description_html" + class="user-card-bio" + @click.prevent="linkClicked" + v-html="user.description_html" + /> + <!-- eslint-enable vue/no-v-html --> + <p + v-else-if="!hideBio" + class="user-card-bio" + > + {{ user.description }} + </p> </div> - <p @click.prevent="linkClicked" v-if="!hideBio && user.description_html" class="user-card-bio" v-html="user.description_html"></p> - <p v-else-if="!hideBio" class="user-card-bio">{{ user.description }}</p> </div> -</div> </template> <script src="./user_card.js"></script> @@ -138,7 +299,6 @@ .user-card { background-size: cover; - overflow: hidden; .panel-heading { padding: .5em 0; @@ -153,6 +313,8 @@ 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%); + border-bottom-right-radius: inherit; + border-bottom-left-radius: inherit; } p { @@ -205,6 +367,7 @@ .container { padding: 16px 0 6px; display: flex; + align-items: flex-start; max-height: 56px; .avatar { @@ -226,6 +389,35 @@ } } + &-avatar-link { + position: relative; + cursor: pointer; + + &-overlay { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.3); + display: flex; + justify-content: center; + align-items: center; + border-radius: $fallback--avatarRadius; + border-radius: var(--avatarRadius, $fallback--avatarRadius); + opacity: 0; + transition: opacity .2s ease; + + i { + color: #FFF; + } + } + + &:hover &-overlay { + opacity: 1; + } + } + .usersettings { color: $fallback--lightText; color: var(--lightText, $fallback--lightText); @@ -358,43 +550,26 @@ } } .user-interactions { + position: relative; display: flex; flex-flow: row wrap; justify-content: space-between; - margin-right: -.75em; - div { + > * { flex: 1 0 0; - margin-right: .75em; - margin-bottom: .6em; + margin: 0 .75em .6em 0; white-space: nowrap; } - .mute { - max-width: 220px; - min-height: 28px; - } - - .follow { - max-width: 220px; - min-height: 28px; - } - button { - width: 100%; - height: 100%; margin: 0; - } - .remote-button { - height: 28px !important; - width: 92%; - } - - .pressed { - border-bottom-color: rgba(255, 255, 255, 0.2); - border-top-color: rgba(0, 0, 0, 0.2); + &.pressed { + // TODO: This should be themed. + border-bottom-color: rgba(255, 255, 255, 0.2); + border-top-color: rgba(0, 0, 0, 0.2); + } } } } diff --git a/src/components/user_finder/user_finder.js b/src/components/user_finder/user_finder.js deleted file mode 100644 index 27153f45..00000000 --- a/src/components/user_finder/user_finder.js +++ /dev/null @@ -1,20 +0,0 @@ -const UserFinder = { - data: () => ({ - username: undefined, - hidden: true, - error: false, - loading: false - }), - methods: { - findUser (username) { - this.$router.push({ name: 'user-search', query: { query: username } }) - this.$refs.userSearchInput.focus() - }, - toggleHidden () { - this.hidden = !this.hidden - this.$emit('toggled', this.hidden) - } - } -} - -export default UserFinder diff --git a/src/components/user_finder/user_finder.vue b/src/components/user_finder/user_finder.vue deleted file mode 100644 index a118ffe2..00000000 --- a/src/components/user_finder/user_finder.vue +++ /dev/null @@ -1,44 +0,0 @@ -<template> - <div> - <div class="user-finder-container"> - <i class="icon-spin4 user-finder-icon animate-spin-slow" v-if="loading" /> - <a href="#" v-if="hidden" :title="$t('finder.find_user')"><i class="icon-user-plus user-finder-icon" @click.prevent.stop="toggleHidden" /></a> - <template v-else> - <input class="user-finder-input" ref="userSearchInput" @keyup.enter="findUser(username)" v-model="username" :placeholder="$t('finder.find_user')" id="user-finder-input" type="text"/> - <button class="btn search-button" @click="findUser(username)"> - <i class="icon-search"/> - </button> - <i class="button-icon icon-cancel user-finder-icon" @click.prevent.stop="toggleHidden"/> - </template> - </div> - </div> -</template> - -<script src="./user_finder.js"></script> - -<style lang="scss"> -@import '../../_variables.scss'; - -.user-finder-container { - max-width: 100%; - display: inline-flex; - align-items: baseline; - vertical-align: baseline; - - - .user-finder-input, - .search-button { - height: 29px; - } - .user-finder-input { - // TODO: do this properly without a rough guesstimate of 2 icons + paddings - max-width: calc(100% - 30px - 30px - 20px); - } - - .search-button { - margin-left: .5em; - margin-right: .5em; - } -} - -</style> diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue index 37e28ca5..c92630e3 100644 --- a/src/components/user_panel/user_panel.vue +++ b/src/components/user_panel/user_panel.vue @@ -1,13 +1,23 @@ <template> <div class="user-panel"> - - <div v-if="signedIn" key="user-panel" class="panel panel-default signed-in"> - <UserCard :user="user" :hideBio="true" rounded="top"/> + <div + v-if="signedIn" + key="user-panel" + class="panel panel-default signed-in" + > + <UserCard + :user="user" + :hide-bio="true" + rounded="top" + /> <div class="panel-footer"> - <post-status-form v-if='user'></post-status-form> + <post-status-form v-if="user" /> </div> </div> - <auth-form v-else key="user-panel"/> + <auth-form + v-else + key="user-panel" + /> </div> </template> diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index eab330e7..00055707 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -3,7 +3,6 @@ import UserCard from '../user_card/user_card.vue' import FollowCard from '../follow_card/follow_card.vue' import Timeline from '../timeline/timeline.vue' import Conversation from '../conversation/conversation.vue' -import ModerationTools from '../moderation_tools/moderation_tools.vue' import List from '../list/list.vue' import withLoadMore from '../../hocs/with_load_more/with_load_more' @@ -23,19 +22,23 @@ const FriendList = withLoadMore({ additionalPropNames: ['userId'] })(List) +const defaultTabKey = 'statuses' + const UserProfile = { data () { return { error: false, - userId: null + userId: null, + tab: defaultTabKey } }, created () { const routeParams = this.$route.params this.load(routeParams.name || routeParams.id) + this.tab = get(this.$route, 'query.tab', defaultTabKey) }, destroyed () { - this.cleanUp() + this.stopFetching() }, computed: { timeline () { @@ -66,17 +69,36 @@ const UserProfile = { }, methods: { load (userNameOrId) { + const startFetchingTimeline = (timeline, userId) => { + // Clear timeline only if load another user's profile + if (userId !== this.$store.state.statuses.timelines[timeline].userId) { + this.$store.commit('clearTimeline', { timeline }) + } + this.$store.dispatch('startFetchingTimeline', { timeline, userId }) + } + + const loadById = (userId) => { + this.userId = userId + startFetchingTimeline('user', userId) + startFetchingTimeline('media', userId) + if (this.isUs) { + startFetchingTimeline('favorites', userId) + } + // Fetch all pinned statuses immediately + this.$store.dispatch('fetchPinnedStatuses', userId) + } + + // Reset view + this.userId = null + this.error = false + // Check if user data is already loaded in store const user = this.$store.getters.findUser(userNameOrId) if (user) { - this.userId = user.id - this.fetchTimelines() + loadById(user.id) } else { this.$store.dispatch('fetchUser', userNameOrId) - .then(({ id }) => { - this.userId = id - this.fetchTimelines() - }) + .then(({ id }) => loadById(id)) .catch((reason) => { const errorMessage = get(reason, 'error.error') if (errorMessage === 'No user with such user_id') { // Known error @@ -89,40 +111,33 @@ const UserProfile = { }) } }, - fetchTimelines () { - const userId = this.userId - this.$store.dispatch('startFetchingTimeline', { timeline: 'user', userId }) - this.$store.dispatch('startFetchingTimeline', { timeline: 'media', userId }) - if (this.isUs) { - this.$store.dispatch('startFetchingTimeline', { timeline: 'favorites', userId }) - } - // Fetch all pinned statuses immediately - this.$store.dispatch('fetchPinnedStatuses', userId) - }, - cleanUp () { + stopFetching () { this.$store.dispatch('stopFetching', 'user') this.$store.dispatch('stopFetching', 'favorites') this.$store.dispatch('stopFetching', 'media') - this.$store.commit('clearTimeline', { timeline: 'user' }) - this.$store.commit('clearTimeline', { timeline: 'favorites' }) - this.$store.commit('clearTimeline', { timeline: 'media' }) + }, + switchUser (userNameOrId) { + this.stopFetching() + this.load(userNameOrId) + }, + onTabSwitch (tab) { + this.tab = tab + this.$router.replace({ query: { tab } }) } }, watch: { '$route.params.id': function (newVal) { if (newVal) { - this.cleanUp() - this.load(newVal) + this.switchUser(newVal) } }, '$route.params.name': function (newVal) { if (newVal) { - this.cleanUp() - this.load(newVal) + this.switchUser(newVal) } }, - $route () { - this.$refs.tabSwitcher.activateTab(0)() + '$route.query': function (newVal) { + this.tab = newVal.tab || defaultTabKey } }, components: { @@ -130,7 +145,6 @@ const UserProfile = { Timeline, FollowerList, FriendList, - ModerationTools, FollowCard, Conversation } diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index 48b774ea..42516916 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -1,75 +1,105 @@ <template> -<div> - <div v-if="user" class="user-profile panel panel-default"> - <UserCard :user="user" :switcher="true" :selected="timeline.viewing" rounded="top"/> - <tab-switcher :renderOnlyFocused="true" ref="tabSwitcher"> - <div :label="$t('user_card.statuses')" :disabled="!user.statuses_count"> - <div class="timeline"> - <template v-for="statusId in user.pinnedStatuseIds"> - <Conversation - v-if="timeline.statusesObject[statusId]" - class="status-fadein" - :key="statusId" - :statusoid="timeline.statusesObject[statusId]" - :collapsable="true" - :showPinned="true" - /> - </template> - </div> + <div> + <div + v-if="user" + class="user-profile panel panel-default" + > + <UserCard + :user="user" + :switcher="true" + :selected="timeline.viewing" + :allow-zooming-avatar="true" + rounded="top" + /> + <tab-switcher + :active-tab="tab" + :render-only-focused="true" + :on-switch="onTabSwitch" + > <Timeline + key="statuses" + :label="$t('user_card.statuses')" :count="user.statuses_count" :embedded="true" :title="$t('user_profile.timeline_title')" :timeline="timeline" - :timeline-name="'user'" + timeline-name="user" :user-id="userId" + :pinned-status-ids="user.pinnedStatusIds" /> + <div + v-if="followsTabVisible" + key="followees" + :label="$t('user_card.followees')" + :disabled="!user.friends_count" + > + <FriendList :user-id="userId"> + <template + slot="item" + slot-scope="{item}" + > + <FollowCard :user="item" /> + </template> + </FriendList> + </div> + <div + v-if="followersTabVisible" + key="followers" + :label="$t('user_card.followers')" + :disabled="!user.followers_count" + > + <FollowerList :user-id="userId"> + <template + slot="item" + slot-scope="{item}" + > + <FollowCard + :user="item" + :no-follows-you="isUs" + /> + </template> + </FollowerList> + </div> + <Timeline + key="media" + :label="$t('user_card.media')" + :disabled="!media.visibleStatuses.length" + :embedded="true" + :title="$t('user_card.media')" + timeline-name="media" + :timeline="media" + :user-id="userId" + /> + <Timeline + v-if="isUs" + key="favorites" + :label="$t('user_card.favorites')" + :disabled="!favorites.visibleStatuses.length" + :embedded="true" + :title="$t('user_card.favorites')" + timeline-name="favorites" + :timeline="favorites" + /> + </tab-switcher> + </div> + <div + v-else + class="panel user-profile-placeholder" + > + <div class="panel-heading"> + <div class="title"> + {{ $t('settings.profile_tab') }} + </div> </div> - <div :label="$t('user_card.followees')" v-if="followsTabVisible" :disabled="!user.friends_count"> - <FriendList :userId="userId"> - <template slot="item" slot-scope="{item}"> - <FollowCard :user="item" /> - </template> - </FriendList> - </div> - <div :label="$t('user_card.followers')" v-if="followersTabVisible" :disabled="!user.followers_count"> - <FollowerList :userId="userId"> - <template slot="item" slot-scope="{item}"> - <FollowCard :user="item" :noFollowsYou="isUs" /> - </template> - </FollowerList> - </div> - <Timeline - :label="$t('user_card.media')" - :disabled="!media.visibleStatuses.length" - :embedded="true" :title="$t('user_card.media')" - timeline-name="media" - :timeline="media" - :user-id="userId" - /> - <Timeline - v-if="isUs" - :label="$t('user_card.favorites')" - :disabled="!favorites.visibleStatuses.length" - :embedded="true" - :title="$t('user_card.favorites')" - timeline-name="favorites" - :timeline="favorites" - /> - </tab-switcher> - </div> - <div v-else class="panel user-profile-placeholder"> - <div class="panel-heading"> - <div class="title"> - {{ $t('settings.profile_tab') }} + <div class="panel-body"> + <span v-if="error">{{ error }}</span> + <i + v-else + class="icon-spin3 animate-spin" + /> </div> </div> - <div class="panel-body"> - <span v-if="error">{{ error }}</span> - <i class="icon-spin3 animate-spin" v-else></i> - </div> </div> -</div> </template> <script src="./user_profile.js"></script> diff --git a/src/components/user_reporting_modal/user_reporting_modal.vue b/src/components/user_reporting_modal/user_reporting_modal.vue index 432dd14d..c79a3707 100644 --- a/src/components/user_reporting_modal/user_reporting_modal.vue +++ b/src/components/user_reporting_modal/user_reporting_modal.vue @@ -1,45 +1,75 @@ <template> -<div class="modal-view" @click="closeModal" v-if="isOpen"> - <div class="user-reporting-panel panel" @click.stop=""> - <div class="panel-heading"> - <div class="title">{{$t('user_reporting.title', [user.screen_name])}}</div> - </div> - <div class="panel-body"> - <div class="user-reporting-panel-left"> - <div> - <p>{{$t('user_reporting.add_comment_description')}}</p> - <textarea - v-model="comment" - class="form-control" - :placeholder="$t('user_reporting.additional_comments')" - rows="1" - @input="resize" - /> - </div> - <div v-if="!user.is_local"> - <p>{{$t('user_reporting.forward_description')}}</p> - <Checkbox v-model="forward">{{$t('user_reporting.forward_to', [remoteInstance])}}</Checkbox> - </div> - <div> - <button class="btn btn-default" @click="reportUser" :disabled="processing">{{$t('user_reporting.submit')}}</button> - <div class="alert error" v-if="error"> - {{$t('user_reporting.generic_error')}} - </div> + <div + v-if="isOpen" + class="modal-view" + @click="closeModal" + > + <div + class="user-reporting-panel panel" + @click.stop="" + > + <div class="panel-heading"> + <div class="title"> + {{ $t('user_reporting.title', [user.screen_name]) }} </div> </div> - <div class="user-reporting-panel-right"> - <List :items="statuses"> - <template slot="item" slot-scope="{item}"> - <div class="status-fadein user-reporting-panel-sitem"> - <Status :inConversation="false" :focused="false" :statusoid="item" /> - <Checkbox :checked="isChecked(item.id)" @change="checked => toggleStatus(checked, item.id)" /> + <div class="panel-body"> + <div class="user-reporting-panel-left"> + <div> + <p>{{ $t('user_reporting.add_comment_description') }}</p> + <textarea + v-model="comment" + class="form-control" + :placeholder="$t('user_reporting.additional_comments')" + rows="1" + @input="resize" + /> + </div> + <div v-if="!user.is_local"> + <p>{{ $t('user_reporting.forward_description') }}</p> + <Checkbox v-model="forward"> + {{ $t('user_reporting.forward_to', [remoteInstance]) }} + </Checkbox> + </div> + <div> + <button + class="btn btn-default" + :disabled="processing" + @click="reportUser" + > + {{ $t('user_reporting.submit') }} + </button> + <div + v-if="error" + class="alert error" + > + {{ $t('user_reporting.generic_error') }} </div> - </template> - </List> + </div> + </div> + <div class="user-reporting-panel-right"> + <List :items="statuses"> + <template + slot="item" + slot-scope="{item}" + > + <div class="status-fadein user-reporting-panel-sitem"> + <Status + :in-conversation="false" + :focused="false" + :statusoid="item" + /> + <Checkbox + :checked="isChecked(item.id)" + @change="checked => toggleStatus(checked, item.id)" + /> + </div> + </template> + </List> + </div> </div> </div> </div> -</div> </template> <script src="./user_reporting_modal.js"></script> diff --git a/src/components/user_search/user_search.js b/src/components/user_search/user_search.js deleted file mode 100644 index 62dafdf1..00000000 --- a/src/components/user_search/user_search.js +++ /dev/null @@ -1,51 +0,0 @@ -import FollowCard from '../follow_card/follow_card.vue' -import map from 'lodash/map' - -const userSearch = { - components: { - FollowCard - }, - props: [ - 'query' - ], - data () { - return { - username: '', - userIds: [], - loading: false - } - }, - computed: { - users () { - return this.userIds.map(userId => this.$store.getters.findUser(userId)) - } - }, - mounted () { - this.search(this.query) - }, - watch: { - query (newV) { - this.search(newV) - } - }, - methods: { - newQuery (query) { - this.$router.push({ name: 'user-search', query: { query } }) - this.$refs.userSearchInput.focus() - }, - search (query) { - if (!query) { - this.users = [] - return - } - this.loading = true - this.$store.dispatch('searchUsers', query) - .then((res) => { - this.loading = false - this.userIds = map(res, 'id') - }) - } - } -} - -export default userSearch diff --git a/src/components/user_search/user_search.vue b/src/components/user_search/user_search.vue deleted file mode 100644 index 890b3c13..00000000 --- a/src/components/user_search/user_search.vue +++ /dev/null @@ -1,37 +0,0 @@ -<template> - <div class="user-search panel panel-default"> - <div class="panel-heading"> - {{$t('nav.user_search')}} - </div> - <div class="user-search-input-container"> - <input class="user-finder-input" ref="userSearchInput" @keyup.enter="newQuery(username)" v-model="username" :placeholder="$t('finder.find_user')"/> - <button class="btn search-button" @click="newQuery(username)"> - <i class="icon-search"/> - </button> - </div> - <div v-if="loading" class="text-center loading-icon"> - <i class="icon-spin3 animate-spin"/> - </div> - <div v-else class="panel-body"> - <FollowCard v-for="user in users" :key="user.id" :user="user" class="list-item"/> - </div> - </div> -</template> - -<script src="./user_search.js"></script> - -<style lang="scss"> -.user-search-input-container { - margin: 0.5em; - display: flex; - justify-content: center; - - .search-button { - margin-left: 0.5em; - } -} - -.loading-icon { - padding: 1em; -} -</style> diff --git a/src/components/user_settings/confirm.vue b/src/components/user_settings/confirm.vue index 46a42e38..69b3811b 100644 --- a/src/components/user_settings/confirm.vue +++ b/src/components/user_settings/confirm.vue @@ -1,13 +1,21 @@ <template> -<div> - <slot></slot> - <button class="btn btn-default" @click="confirm" :disabled="disabled"> - {{$t('general.confirm')}} - </button> - <button class="btn btn-default" @click="cancel" :disabled="disabled"> - {{$t('general.cancel')}} - </button> -</div> + <div> + <slot /> + <button + class="btn btn-default" + :disabled="disabled" + @click="confirm" + > + {{ $t('general.confirm') }} + </button> + <button + class="btn btn-default" + :disabled="disabled" + @click="cancel" + > + {{ $t('general.cancel') }} + </button> + </div> </template> <script src="./confirm.js"> diff --git a/src/components/user_settings/mfa.js b/src/components/user_settings/mfa.js index 2acee862..3090138a 100644 --- a/src/components/user_settings/mfa.js +++ b/src/components/user_settings/mfa.js @@ -7,6 +7,7 @@ import { mapState } from 'vuex' const Mfa = { data: () => ({ settings: { // current settings of MFA + available: false, enabled: false, totp: false }, @@ -106,7 +107,7 @@ const Mfa = { this.setupState.setupOTPState = 'confirm' }) }, - doConfirmOTP () { // handler confirm enable OTP + doConfirmOTP () { // handler confirm enable OTP this.error = null this.backendInteractor.mfaConfirmOTP({ token: this.otpConfirmToken, @@ -139,7 +140,9 @@ const Mfa = { // fetch settings from server async fetchSettings () { let result = await this.backendInteractor.fetchSettingsMFA() + if (result.error) return this.settings = result.settings + this.settings.available = true return result } }, diff --git a/src/components/user_settings/mfa.vue b/src/components/user_settings/mfa.vue index ded426dd..14ea10a1 100644 --- a/src/components/user_settings/mfa.vue +++ b/src/components/user_settings/mfa.vue @@ -1,86 +1,138 @@ <template> -<div class="setting-item mfa-settings" v-if="readyInit"> - - <div class="mfa-heading"> - <h2>{{$t('settings.mfa.title')}}</h2> - </div> - - <div> - <div class="setting-item" v-if="!setupInProgress"> - <!-- Enabled methods --> - <h3>{{$t('settings.mfa.authentication_methods')}}</h3> - <totp-item :settings="settings" @deactivate="fetchSettings" @activate="activateOTP"/> - <br /> - - <div v-if="settings.enabled"> <!-- backup codes block--> - <recovery-codes :backup-codes="backupCodes" v-if="!confirmNewBackupCodes" /> - <button class="btn btn-default" @click="getBackupCodes" v-if="!confirmNewBackupCodes"> - {{$t('settings.mfa.generate_new_recovery_codes')}} - </button> + <div + v-if="readyInit && settings.available" + class="setting-item mfa-settings" + > + <div class="mfa-heading"> + <h2>{{ $t('settings.mfa.title') }}</h2> + </div> - <div v-if="confirmNewBackupCodes"> - <confirm @confirm="confirmBackupCodes" @cancel="cancelBackupCodes" - :disabled="backupCodes.inProgress"> - <p class="warning">{{$t('settings.mfa.warning_of_generate_new_codes')}}</p> - </confirm> + <div> + <div + v-if="!setupInProgress" + class="setting-item" + > + <!-- Enabled methods --> + <h3>{{ $t('settings.mfa.authentication_methods') }}</h3> + <totp-item + :settings="settings" + @deactivate="fetchSettings" + @activate="activateOTP" + /> + <br> + + <div v-if="settings.enabled"> + <!-- backup codes block--> + <recovery-codes + v-if="!confirmNewBackupCodes" + :backup-codes="backupCodes" + /> + <button + v-if="!confirmNewBackupCodes" + class="btn btn-default" + @click="getBackupCodes" + > + {{ $t('settings.mfa.generate_new_recovery_codes') }} + </button> + + <div v-if="confirmNewBackupCodes"> + <confirm + :disabled="backupCodes.inProgress" + @confirm="confirmBackupCodes" + @cancel="cancelBackupCodes" + > + <p class="warning"> + {{ $t('settings.mfa.warning_of_generate_new_codes') }} + </p> + </confirm> + </div> </div> </div> - </div> - - <div v-if="setupInProgress"> <!-- setup block--> - <h3>{{$t('settings.mfa.setup_otp')}}</h3> + <div v-if="setupInProgress"> + <!-- setup block--> - <recovery-codes :backup-codes="backupCodes" v-if="!setupOTPInProgress"/> + <h3>{{ $t('settings.mfa.setup_otp') }}</h3> + <recovery-codes + v-if="!setupOTPInProgress" + :backup-codes="backupCodes" + /> - <button class="btn btn-default" @click="cancelSetup" v-if="canSetupOTP"> - {{$t('general.cancel')}} - </button> - - <button class="btn btn-default" v-if="canSetupOTP" @click="setupOTP"> - {{$t('settings.mfa.setup_otp')}} - </button> - - <template v-if="setupOTPInProgress"> - <i v-if="prepareOTP">{{$t('settings.mfa.wait_pre_setup_otp')}}</i> + <button + v-if="canSetupOTP" + class="btn btn-default" + @click="cancelSetup" + > + {{ $t('general.cancel') }} + </button> - <div v-if="confirmOTP"> - <div class="setup-otp"> - <div class="qr-code"> - <h4>{{$t('settings.mfa.scan.title')}}</h4> - <p>{{$t('settings.mfa.scan.desc')}}</p> - <qrcode :value="otpSettings.provisioning_uri" :options="{ width: 200 }"></qrcode> - <p> - {{$t('settings.mfa.scan.secret_code')}}: - {{otpSettings.key}} - </p> - </div> + <button + v-if="canSetupOTP" + class="btn btn-default" + @click="setupOTP" + > + {{ $t('settings.mfa.setup_otp') }} + </button> - <div class="verify"> - <h4>{{$t('general.verify')}}</h4> - <p>{{$t('settings.mfa.verify.desc')}}</p> - <input type="text" v-model="otpConfirmToken"> + <template v-if="setupOTPInProgress"> + <i v-if="prepareOTP">{{ $t('settings.mfa.wait_pre_setup_otp') }}</i> + + <div v-if="confirmOTP"> + <div class="setup-otp"> + <div class="qr-code"> + <h4>{{ $t('settings.mfa.scan.title') }}</h4> + <p>{{ $t('settings.mfa.scan.desc') }}</p> + <qrcode + :value="otpSettings.provisioning_uri" + :options="{ width: 200 }" + /> + <p> + {{ $t('settings.mfa.scan.secret_code') }}: + {{ otpSettings.key }} + </p> + </div> - <p>{{$t('settings.enter_current_password_to_confirm')}}:</p> - <input type="password" v-model="currentPassword"> - <div class="confirm-otp-actions"> - <button class="btn btn-default" @click="doConfirmOTP"> - {{$t('settings.mfa.confirm_and_enable')}} - </button> - <button class="btn btn-default" @click="cancelSetup"> - {{$t('general.cancel')}} - </button> + <div class="verify"> + <h4>{{ $t('general.verify') }}</h4> + <p>{{ $t('settings.mfa.verify.desc') }}</p> + <input + v-model="otpConfirmToken" + type="text" + > + + <p>{{ $t('settings.enter_current_password_to_confirm') }}:</p> + <input + v-model="currentPassword" + type="password" + > + <div class="confirm-otp-actions"> + <button + class="btn btn-default" + @click="doConfirmOTP" + > + {{ $t('settings.mfa.confirm_and_enable') }} + </button> + <button + class="btn btn-default" + @click="cancelSetup" + > + {{ $t('general.cancel') }} + </button> + </div> + <div + v-if="error" + class="alert error" + > + {{ error }} + </div> </div> - <div class="alert error" v-if="error">{{error}}</div> </div> </div> - </div> - </template> + </template> + </div> </div> - </div> -</div> </template> <script src="./mfa.js"></script> diff --git a/src/components/user_settings/mfa_backup_codes.vue b/src/components/user_settings/mfa_backup_codes.vue index c275bd63..e6c8ede2 100644 --- a/src/components/user_settings/mfa_backup_codes.vue +++ b/src/components/user_settings/mfa_backup_codes.vue @@ -1,12 +1,23 @@ <template> -<div> - <h4 v-if="displayTitle">{{$t('settings.mfa.recovery_codes')}}</h4> - <i v-if="inProgress">{{$t('settings.mfa.waiting_a_recovery_codes')}}</i> - <template v-if="ready"> - <p class="alert warning">{{$t('settings.mfa.recovery_codes_warning')}}</p> - <ul class="backup-codes"><li v-for="code in backupCodes.codes">{{code}}</li></ul> - </template> -</div> + <div> + <h4 v-if="displayTitle"> + {{ $t('settings.mfa.recovery_codes') }} + </h4> + <i v-if="inProgress">{{ $t('settings.mfa.waiting_a_recovery_codes') }}</i> + <template v-if="ready"> + <p class="alert warning"> + {{ $t('settings.mfa.recovery_codes_warning') }} + </p> + <ul class="backup-codes"> + <li + v-for="code in backupCodes.codes" + :key="code" + > + {{ code }} + </li> + </ul> + </template> + </div> </template> <script src="./mfa_backup_codes.js"></script> <style lang="scss"> diff --git a/src/components/user_settings/mfa_totp.vue b/src/components/user_settings/mfa_totp.vue index 6b73c8f4..c6f2cc7b 100644 --- a/src/components/user_settings/mfa_totp.vue +++ b/src/components/user_settings/mfa_totp.vue @@ -1,23 +1,43 @@ <template> -<div> - <div class="method-item"> - <strong>{{$t('settings.mfa.otp')}}</strong> - <button class="btn btn-default" v-if="!isActivated" @click="doActivate"> - {{$t('general.enable')}} - </button> + <div> + <div class="method-item"> + <strong>{{ $t('settings.mfa.otp') }}</strong> + <button + v-if="!isActivated" + class="btn btn-default" + @click="doActivate" + > + {{ $t('general.enable') }} + </button> - <button class="btn btn-default" :disabled="deactivate" @click="doDeactivate" - v-if="isActivated"> - {{$t('general.disable')}} - </button> - </div> + <button + v-if="isActivated" + class="btn btn-default" + :disabled="deactivate" + @click="doDeactivate" + > + {{ $t('general.disable') }} + </button> + </div> - <confirm @confirm="confirmDeactivate" @cancel="cancelDeactivate" - :disabled="inProgress" v-if="deactivate"> - {{$t('settings.enter_current_password_to_confirm')}}: - <input type="password" v-model="currentPassword"> - </confirm> - <div class="alert error" v-if="error">{{error}}</div> -</div> + <confirm + v-if="deactivate" + :disabled="inProgress" + @confirm="confirmDeactivate" + @cancel="cancelDeactivate" + > + {{ $t('settings.enter_current_password_to_confirm') }}: + <input + v-model="currentPassword" + type="password" + > + </confirm> + <div + v-if="error" + class="alert error" + > + {{ error }} + </div> + </div> </template> <script src="./mfa_totp.js"></script> diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js index 69505806..b5a7f0df 100644 --- a/src/components/user_settings/user_settings.js +++ b/src/components/user_settings/user_settings.js @@ -12,11 +12,11 @@ import MuteCard from '../mute_card/mute_card.vue' import SelectableList from '../selectable_list/selectable_list.vue' import ProgressButton from '../progress_button/progress_button.vue' import EmojiInput from '../emoji-input/emoji-input.vue' +import suggestor from '../emoji-input/suggestor.js' import Autosuggest from '../autosuggest/autosuggest.vue' import Importer from '../importer/importer.vue' import Exporter from '../exporter/exporter.vue' import withSubscription from '../../hocs/with_subscription/with_subscription' -import userSearchApi from '../../services/new_api/user_search.js' import Mfa from './mfa.vue' const BlockList = withSubscription({ @@ -46,7 +46,9 @@ const UserSettings = { pickAvatarBtnVisible: true, bannerUploading: false, backgroundUploading: false, + banner: null, bannerPreview: null, + background: null, backgroundPreview: null, bannerUploadError: null, backgroundUploadError: null, @@ -83,6 +85,22 @@ const UserSettings = { user () { return this.$store.state.users.currentUser }, + emojiUserSuggestor () { + return suggestor({ + emoji: [ + ...this.$store.state.instance.emoji, + ...this.$store.state.instance.customEmoji + ], + users: this.$store.state.users.users, + updateUsersList: (input) => this.$store.dispatch('searchUsers', input) + }) + }, + emojiSuggestor () { + return suggestor({ emoji: [ + ...this.$store.state.instance.emoji, + ...this.$store.state.instance.customEmoji + ] }) + }, pleromaBackend () { return this.$store.state.instance.pleromaBackend }, @@ -126,10 +144,10 @@ const UserSettings = { hide_followers: this.hideFollowers, show_role: this.showRole /* eslint-enable camelcase */ - }}).then((user) => { - this.$store.commit('addNewUsers', [user]) - this.$store.commit('setCurrentUser', user) - }) + } }).then((user) => { + this.$store.commit('addNewUsers', [user]) + this.$store.commit('setCurrentUser', user) + }) }, updateNotificationSettings () { this.$store.state.api.backendInteractor @@ -144,12 +162,12 @@ const UserSettings = { if (file.size > this.$store.state.instance[slot + 'limit']) { const filesize = fileSizeFormatService.fileSizeFormat(file.size) const allowedsize = fileSizeFormatService.fileSizeFormat(this.$store.state.instance[slot + 'limit']) - this[slot + 'UploadError'] = this.$t('upload.error.base') + ' ' + this.$t('upload.error.file_too_big', {filesize: filesize.num, filesizeunit: filesize.unit, allowedsize: allowedsize.num, allowedsizeunit: allowedsize.unit}) + this[slot + 'UploadError'] = this.$t('upload.error.base') + ' ' + this.$t('upload.error.file_too_big', { filesize: filesize.num, filesizeunit: filesize.unit, allowedsize: allowedsize.num, allowedsizeunit: allowedsize.unit }) return } // eslint-disable-next-line no-undef const reader = new FileReader() - reader.onload = ({target}) => { + reader.onload = ({ target }) => { const img = target.result this[slot + 'Preview'] = img this[slot] = file @@ -185,7 +203,7 @@ const UserSettings = { if (!this.bannerPreview) { return } this.bannerUploading = true - this.$store.state.api.backendInteractor.updateBanner({banner: this.banner}) + this.$store.state.api.backendInteractor.updateBanner({ banner: this.banner }) .then((user) => { this.$store.commit('addNewUsers', [user]) this.$store.commit('setCurrentUser', user) @@ -198,22 +216,12 @@ const UserSettings = { }, submitBg () { if (!this.backgroundPreview) { return } - let img = this.backgroundPreview - // eslint-disable-next-line no-undef - let imginfo = new Image() - let cropX, cropY, cropW, cropH - imginfo.src = img - cropX = 0 - cropY = 0 - cropW = imginfo.width - cropH = imginfo.width + let background = this.background this.backgroundUploading = true - this.$store.state.api.backendInteractor.updateBg({params: {img, cropX, cropY, cropW, cropH}}).then((data) => { + this.$store.state.api.backendInteractor.updateBg({ background }).then((data) => { if (!data.error) { - let clone = JSON.parse(JSON.stringify(this.$store.state.users.currentUser)) - clone.background_image = data.url - this.$store.commit('addNewUsers', [clone]) - this.$store.commit('setCurrentUser', clone) + this.$store.commit('addNewUsers', [data]) + this.$store.commit('setCurrentUser', data) this.backgroundPreview = null } else { this.backgroundUploadError = this.$t('upload.error.base') + data.error @@ -261,11 +269,11 @@ const UserSettings = { this.deletingAccount = true }, deleteAccount () { - this.$store.state.api.backendInteractor.deleteAccount({password: this.deleteAccountConfirmPasswordInput}) + this.$store.state.api.backendInteractor.deleteAccount({ password: this.deleteAccountConfirmPasswordInput }) .then((res) => { if (res.status === 'success') { this.$store.dispatch('logout') - this.$router.push({name: 'root'}) + this.$router.push({ name: 'root' }) } else { this.deleteAccountError = res.error } @@ -314,11 +322,8 @@ const UserSettings = { }) }, queryUserIds (query) { - return userSearchApi.search({query, store: this.$store}) - .then((users) => { - this.$store.dispatch('addNewUsers', users) - return map(users, 'id') - }) + return this.$store.dispatch('searchUsers', query) + .then((users) => map(users, 'id')) }, blockUsers (ids) { return this.$store.dispatch('blockUsers', ids) diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue index bbe41f11..34ea8569 100644 --- a/src/components/user_settings/user_settings.vue +++ b/src/components/user_settings/user_settings.vue @@ -2,15 +2,23 @@ <div class="settings panel panel-default"> <div class="panel-heading"> <div class="title"> - {{$t('settings.user_settings')}} + {{ $t('settings.user_settings') }} </div> <transition name="fade"> <template v-if="currentSaveStateNotice"> - <div @click.prevent class="alert error" v-if="currentSaveStateNotice.error"> + <div + v-if="currentSaveStateNotice.error" + class="alert error" + @click.prevent + > {{ $t('settings.saving_err') }} </div> - <div @click.prevent class="alert transparent" v-if="!currentSaveStateNotice.error"> + <div + v-if="!currentSaveStateNotice.error" + class="alert transparent" + @click.prevent + > {{ $t('settings.saving_ok') }} </div> </template> @@ -19,133 +27,267 @@ <div class="panel-body profile-edit"> <tab-switcher> <div :label="$t('settings.profile_tab')"> - <div class="setting-item" > - <h2>{{$t('settings.name_bio')}}</h2> - <p>{{$t('settings.name')}}</p> + <div class="setting-item"> + <h2>{{ $t('settings.name_bio') }}</h2> + <p>{{ $t('settings.name') }}</p> <EmojiInput - type="text" v-model="newName" - id="username" - classname="name-changer" - /> - <p>{{$t('settings.bio')}}</p> + :suggest="emojiSuggestor" + > + <input + id="username" + v-model="newName" + classname="name-changer" + > + </EmojiInput> + <p>{{ $t('settings.bio') }}</p> <EmojiInput - type="textarea" v-model="newBio" - classname="bio" - /> + :suggest="emojiUserSuggestor" + > + <textarea + v-model="newBio" + classname="bio" + /> + </EmojiInput> <p> - <input type="checkbox" v-model="newLocked" id="account-locked"> - <label for="account-locked">{{$t('settings.lock_account_description')}}</label> + <input + id="account-locked" + v-model="newLocked" + type="checkbox" + > + <label for="account-locked">{{ $t('settings.lock_account_description') }}</label> </p> <div> - <label for="default-vis">{{$t('settings.default_vis')}}</label> - <div id="default-vis" class="visibility-tray"> + <label for="default-vis">{{ $t('settings.default_vis') }}</label> + <div + id="default-vis" + class="visibility-tray" + > <scope-selector - :showAll="true" - :userDefault="newDefaultScope" - :initialScope="newDefaultScope" - :onScopeChange="changeVis"/> + :show-all="true" + :user-default="newDefaultScope" + :initial-scope="newDefaultScope" + :on-scope-change="changeVis" + /> </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> + <input + id="account-no-rich-text" + v-model="newNoRichText" + type="checkbox" + > + <label for="account-no-rich-text">{{ $t('settings.no_rich_text_description') }}</label> </p> <p> - <input type="checkbox" v-model="hideFollows" id="account-hide-follows"> - <label for="account-hide-follows">{{$t('settings.hide_follows_description')}}</label> + <input + id="account-hide-follows" + v-model="hideFollows" + type="checkbox" + > + <label for="account-hide-follows">{{ $t('settings.hide_follows_description') }}</label> </p> <p> - <input type="checkbox" v-model="hideFollowers" id="account-hide-followers"> - <label for="account-hide-followers">{{$t('settings.hide_followers_description')}}</label> + <input + id="account-hide-followers" + v-model="hideFollowers" + type="checkbox" + > + <label for="account-hide-followers">{{ $t('settings.hide_followers_description') }}</label> </p> <p> - <input type="checkbox" v-model="showRole" id="account-show-role"> - <label for="account-show-role" v-if="role === 'admin'">{{$t('settings.show_admin_badge')}}</label> - <label for="account-show-role" v-if="role === 'moderator'">{{$t('settings.show_moderator_badge')}}</label> + <input + id="account-show-role" + v-model="showRole" + type="checkbox" + > + <label + v-if="role === 'admin'" + for="account-show-role" + >{{ $t('settings.show_admin_badge') }}</label> + <label + v-if="role === 'moderator'" + for="account-show-role" + >{{ $t('settings.show_moderator_badge') }}</label> </p> - <button :disabled='newName && newName.length === 0' class="btn btn-default" @click="updateProfile">{{$t('general.submit')}}</button> + <button + :disabled="newName && newName.length === 0" + class="btn btn-default" + @click="updateProfile" + > + {{ $t('general.submit') }} + </button> </div> <div class="setting-item"> - <h2>{{$t('settings.avatar')}}</h2> - <p class="visibility-notice">{{$t('settings.avatar_size_instruction')}}</p> - <p>{{$t('settings.current_avatar')}}</p> - <img :src="user.profile_image_url_original" class="current-avatar" /> - <p>{{$t('settings.set_new_avatar')}}</p> - <button class="btn" type="button" id="pick-avatar" v-show="pickAvatarBtnVisible">{{$t('settings.upload_a_photo')}}</button> - <image-cropper trigger="#pick-avatar" :submitHandler="submitAvatar" @open="pickAvatarBtnVisible=false" @close="pickAvatarBtnVisible=true" /> + <h2>{{ $t('settings.avatar') }}</h2> + <p class="visibility-notice"> + {{ $t('settings.avatar_size_instruction') }} + </p> + <p>{{ $t('settings.current_avatar') }}</p> + <img + :src="user.profile_image_url_original" + class="current-avatar" + > + <p>{{ $t('settings.set_new_avatar') }}</p> + <button + v-show="pickAvatarBtnVisible" + id="pick-avatar" + class="btn" + type="button" + > + {{ $t('settings.upload_a_photo') }} + </button> + <image-cropper + trigger="#pick-avatar" + :submit-handler="submitAvatar" + @open="pickAvatarBtnVisible=false" + @close="pickAvatarBtnVisible=true" + /> </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" /> - <p>{{$t('settings.set_new_profile_banner')}}</p> - <img class="banner" v-bind:src="bannerPreview" v-if="bannerPreview" /> + <h2>{{ $t('settings.profile_banner') }}</h2> + <p>{{ $t('settings.current_profile_banner') }}</p> + <img + :src="user.cover_photo" + class="banner" + > + <p>{{ $t('settings.set_new_profile_banner') }}</p> + <img + v-if="bannerPreview" + class="banner" + :src="bannerPreview" + > <div> - <input type="file" @change="uploadFile('banner', $event)" /> + <input + type="file" + @change="uploadFile('banner', $event)" + > </div> - <i class=" icon-spin4 animate-spin uploading" v-if="bannerUploading"></i> - <button class="btn btn-default" v-else-if="bannerPreview" @click="submitBanner">{{$t('general.submit')}}</button> - <div class='alert error' v-if="bannerUploadError"> + <i + v-if="bannerUploading" + class=" icon-spin4 animate-spin uploading" + /> + <button + v-else-if="bannerPreview" + class="btn btn-default" + @click="submitBanner" + > + {{ $t('general.submit') }} + </button> + <div + v-if="bannerUploadError" + class="alert error" + > Error: {{ bannerUploadError }} - <i class="button-icon icon-cancel" @click="clearUploadError('banner')"></i> + <i + class="button-icon icon-cancel" + @click="clearUploadError('banner')" + /> </div> </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="backgroundPreview" v-if="backgroundPreview" /> + <h2>{{ $t('settings.profile_background') }}</h2> + <p>{{ $t('settings.set_new_profile_background') }}</p> + <img + v-if="backgroundPreview" + class="bg" + :src="backgroundPreview" + > <div> - <input type="file" @change="uploadFile('background', $event)" /> + <input + type="file" + @change="uploadFile('background', $event)" + > </div> - <i class=" icon-spin4 animate-spin uploading" v-if="backgroundUploading"></i> - <button class="btn btn-default" v-else-if="backgroundPreview" @click="submitBg">{{$t('general.submit')}}</button> - <div class='alert error' v-if="backgroundUploadError"> + <i + v-if="backgroundUploading" + class=" icon-spin4 animate-spin uploading" + /> + <button + v-else-if="backgroundPreview" + class="btn btn-default" + @click="submitBg" + > + {{ $t('general.submit') }} + </button> + <div + v-if="backgroundUploadError" + class="alert error" + > Error: {{ backgroundUploadError }} - <i class="button-icon icon-cancel" @click="clearUploadError('background')"></i> + <i + class="button-icon icon-cancel" + @click="clearUploadError('background')" + /> </div> </div> </div> <div :label="$t('settings.security_tab')"> <div class="setting-item"> - <h2>{{$t('settings.change_password')}}</h2> + <h2>{{ $t('settings.change_password') }}</h2> <div> - <p>{{$t('settings.current_password')}}</p> - <input type="password" v-model="changePasswordInputs[0]"> + <p>{{ $t('settings.current_password') }}</p> + <input + v-model="changePasswordInputs[0]" + type="password" + > </div> <div> - <p>{{$t('settings.new_password')}}</p> - <input type="password" v-model="changePasswordInputs[1]"> + <p>{{ $t('settings.new_password') }}</p> + <input + v-model="changePasswordInputs[1]" + type="password" + > </div> <div> - <p>{{$t('settings.confirm_new_password')}}</p> - <input type="password" v-model="changePasswordInputs[2]"> + <p>{{ $t('settings.confirm_new_password') }}</p> + <input + v-model="changePasswordInputs[2]" + type="password" + > </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> + <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.oauth_tokens')}}</h2> + <h2>{{ $t('settings.oauth_tokens') }}</h2> <table class="oauth-tokens"> <thead> <tr> - <th>{{$t('settings.app_name')}}</th> - <th>{{$t('settings.valid_until')}}</th> - <th></th> + <th>{{ $t('settings.app_name') }}</th> + <th>{{ $t('settings.valid_until') }}</th> + <th /> </tr> </thead> <tbody> - <tr v-for="oauthToken in oauthTokens" :key="oauthToken.id"> - <td>{{oauthToken.appName}}</td> - <td>{{oauthToken.validUntil}}</td> + <tr + v-for="oauthToken in oauthTokens" + :key="oauthToken.id" + > + <td>{{ oauthToken.appName }}</td> + <td>{{ oauthToken.validUntil }}</td> <td class="actions"> - <button class="btn btn-default" @click="revokeToken(oauthToken.id)"> - {{$t('settings.revoke_token')}} + <button + class="btn btn-default" + @click="revokeToken(oauthToken.id)" + > + {{ $t('settings.revoke_token') }} </button> </td> </tr> @@ -154,123 +296,250 @@ </div> <mfa /> <div class="setting-item"> - <h2>{{$t('settings.delete_account')}}</h2> - <p v-if="!deletingAccount">{{$t('settings.delete_account_description')}}</p> + <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> + <p>{{ $t('settings.delete_account_instructions') }}</p> + <p>{{ $t('login.password') }}</p> + <input + v-model="deleteAccountConfirmPasswordInput" + type="password" + > + <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> + <p v-if="deleteAccountError !== false"> + {{ $t('settings.delete_account_error') }} + </p> + <p v-if="deleteAccountError"> + {{ deleteAccountError }} + </p> + <button + v-if="!deletingAccount" + class="btn btn-default" + @click="confirmDelete" + > + {{ $t('general.submit') }} + </button> </div> </div> - <div :label="$t('settings.notifications')" v-if="pleromaBackend"> + <div + v-if="pleromaBackend" + :label="$t('settings.notifications')" + > <div class="setting-item"> <div class="select-multiple"> - <span class="label">{{$t('settings.notification_setting')}}</span> + <span class="label">{{ $t('settings.notification_setting') }}</span> <ul class="option-list"> <li> - <input type="checkbox" id="notification-setting-follows" v-model="notificationSettings.follows"> + <input + id="notification-setting-follows" + v-model="notificationSettings.follows" + type="checkbox" + > <label for="notification-setting-follows"> - {{$t('settings.notification_setting_follows')}} + {{ $t('settings.notification_setting_follows') }} </label> </li> <li> - <input type="checkbox" id="notification-setting-followers" v-model="notificationSettings.followers"> + <input + id="notification-setting-followers" + v-model="notificationSettings.followers" + type="checkbox" + > <label for="notification-setting-followers"> - {{$t('settings.notification_setting_followers')}} + {{ $t('settings.notification_setting_followers') }} </label> </li> <li> - <input type="checkbox" id="notification-setting-non-follows" v-model="notificationSettings.non_follows"> + <input + id="notification-setting-non-follows" + v-model="notificationSettings.non_follows" + type="checkbox" + > <label for="notification-setting-non-follows"> - {{$t('settings.notification_setting_non_follows')}} + {{ $t('settings.notification_setting_non_follows') }} </label> </li> <li> - <input type="checkbox" id="notification-setting-non-followers" v-model="notificationSettings.non_followers"> + <input + id="notification-setting-non-followers" + v-model="notificationSettings.non_followers" + type="checkbox" + > <label for="notification-setting-non-followers"> - {{$t('settings.notification_setting_non_followers')}} + {{ $t('settings.notification_setting_non_followers') }} </label> </li> </ul> </div> - <p>{{$t('settings.notification_mutes')}}</p> - <p>{{$t('settings.notification_blocks')}}</p> - <button class="btn btn-default" @click="updateNotificationSettings">{{$t('general.submit')}}</button> + <p>{{ $t('settings.notification_mutes') }}</p> + <p>{{ $t('settings.notification_blocks') }}</p> + <button + class="btn btn-default" + @click="updateNotificationSettings" + > + {{ $t('general.submit') }} + </button> </div> </div> - <div :label="$t('settings.data_import_export_tab')" v-if="pleromaBackend"> + <div + v-if="pleromaBackend" + :label="$t('settings.data_import_export_tab')" + > <div class="setting-item"> - <h2>{{$t('settings.follow_import')}}</h2> - <p>{{$t('settings.import_followers_from_a_csv_file')}}</p> - <Importer :submitHandler="importFollows" :successMessage="$t('settings.follows_imported')" :errorMessage="$t('settings.follow_import_error')" /> + <h2>{{ $t('settings.follow_import') }}</h2> + <p>{{ $t('settings.import_followers_from_a_csv_file') }}</p> + <Importer + :submit-handler="importFollows" + :success-message="$t('settings.follows_imported')" + :error-message="$t('settings.follow_import_error')" + /> </div> <div class="setting-item"> - <h2>{{$t('settings.follow_export')}}</h2> - <Exporter :getContent="getFollowsContent" filename="friends.csv" :exportButtonLabel="$t('settings.follow_export_button')" /> + <h2>{{ $t('settings.follow_export') }}</h2> + <Exporter + :get-content="getFollowsContent" + filename="friends.csv" + :export-button-label="$t('settings.follow_export_button')" + /> </div> <div class="setting-item"> - <h2>{{$t('settings.block_import')}}</h2> - <p>{{$t('settings.import_blocks_from_a_csv_file')}}</p> - <Importer :submitHandler="importBlocks" :successMessage="$t('settings.blocks_imported')" :errorMessage="$t('settings.block_import_error')" /> + <h2>{{ $t('settings.block_import') }}</h2> + <p>{{ $t('settings.import_blocks_from_a_csv_file') }}</p> + <Importer + :submit-handler="importBlocks" + :success-message="$t('settings.blocks_imported')" + :error-message="$t('settings.block_import_error')" + /> </div> <div class="setting-item"> - <h2>{{$t('settings.block_export')}}</h2> - <Exporter :getContent="getBlocksContent" filename="blocks.csv" :exportButtonLabel="$t('settings.block_export_button')" /> + <h2>{{ $t('settings.block_export') }}</h2> + <Exporter + :get-content="getBlocksContent" + filename="blocks.csv" + :export-button-label="$t('settings.block_export_button')" + /> </div> </div> <div :label="$t('settings.blocks_tab')"> <div class="profile-edit-usersearch-wrapper"> - <Autosuggest :filter="filterUnblockedUsers" :query="queryUserIds" :placeholder="$t('settings.search_user_to_block')"> - <BlockCard slot-scope="row" :userId="row.item"/> + <Autosuggest + :filter="filterUnblockedUsers" + :query="queryUserIds" + :placeholder="$t('settings.search_user_to_block')" + > + <BlockCard + slot-scope="row" + :user-id="row.item" + /> </Autosuggest> </div> - <BlockList :refresh="true" :getKey="identity"> - <template slot="header" slot-scope="{selected}"> + <BlockList + :refresh="true" + :get-key="identity" + > + <template + slot="header" + slot-scope="{selected}" + > <div class="profile-edit-bulk-actions"> - <ProgressButton class="btn btn-default" v-if="selected.length > 0" :click="() => blockUsers(selected)"> + <ProgressButton + v-if="selected.length > 0" + class="btn btn-default" + :click="() => blockUsers(selected)" + > {{ $t('user_card.block') }} - <template slot="progress">{{ $t('user_card.block_progress') }}</template> + <template slot="progress"> + {{ $t('user_card.block_progress') }} + </template> </ProgressButton> - <ProgressButton class="btn btn-default" v-if="selected.length > 0" :click="() => unblockUsers(selected)"> + <ProgressButton + v-if="selected.length > 0" + class="btn btn-default" + :click="() => unblockUsers(selected)" + > {{ $t('user_card.unblock') }} - <template slot="progress">{{ $t('user_card.unblock_progress') }}</template> + <template slot="progress"> + {{ $t('user_card.unblock_progress') }} + </template> </ProgressButton> </div> </template> - <template slot="item" slot-scope="{item}"><BlockCard :userId="item" /></template> - <template slot="empty">{{$t('settings.no_blocks')}}</template> + <template + slot="item" + slot-scope="{item}" + > + <BlockCard :user-id="item" /> + </template> + <template slot="empty"> + {{ $t('settings.no_blocks') }} + </template> </BlockList> </div> <div :label="$t('settings.mutes_tab')"> <div class="profile-edit-usersearch-wrapper"> - <Autosuggest :filter="filterUnMutedUsers" :query="queryUserIds" :placeholder="$t('settings.search_user_to_mute')"> - <MuteCard slot-scope="row" :userId="row.item"/> + <Autosuggest + :filter="filterUnMutedUsers" + :query="queryUserIds" + :placeholder="$t('settings.search_user_to_mute')" + > + <MuteCard + slot-scope="row" + :user-id="row.item" + /> </Autosuggest> </div> - <MuteList :refresh="true" :getKey="identity"> - <template slot="header" slot-scope="{selected}"> + <MuteList + :refresh="true" + :get-key="identity" + > + <template + slot="header" + slot-scope="{selected}" + > <div class="profile-edit-bulk-actions"> - <ProgressButton class="btn btn-default" v-if="selected.length > 0" :click="() => muteUsers(selected)"> + <ProgressButton + v-if="selected.length > 0" + class="btn btn-default" + :click="() => muteUsers(selected)" + > {{ $t('user_card.mute') }} - <template slot="progress">{{ $t('user_card.mute_progress') }}</template> + <template slot="progress"> + {{ $t('user_card.mute_progress') }} + </template> </ProgressButton> - <ProgressButton class="btn btn-default" v-if="selected.length > 0" :click="() => unmuteUsers(selected)"> + <ProgressButton + v-if="selected.length > 0" + class="btn btn-default" + :click="() => unmuteUsers(selected)" + > {{ $t('user_card.unmute') }} - <template slot="progress">{{ $t('user_card.unmute_progress') }}</template> + <template slot="progress"> + {{ $t('user_card.unmute_progress') }} + </template> </ProgressButton> </div> </template> - <template slot="item" slot-scope="{item}"><MuteCard :userId="item" /></template> - <template slot="empty">{{$t('settings.no_mutes')}}</template> + <template + slot="item" + slot-scope="{item}" + > + <MuteCard :user-id="item" /> + </template> + <template slot="empty"> + {{ $t('settings.no_mutes') }} + </template> </MuteList> </div> </tab-switcher> diff --git a/src/components/video_attachment/video_attachment.vue b/src/components/video_attachment/video_attachment.vue index 68de201e..97ddf1cd 100644 --- a/src/components/video_attachment/video_attachment.vue +++ b/src/components/video_attachment/video_attachment.vue @@ -1,10 +1,11 @@ <template> - <video class="video" - @loadeddata="onVideoDataLoad" + <video + class="video" :src="attachment.url" :loop="loopVideo" :controls="controls" playsinline + @loadeddata="onVideoDataLoad" /> </template> diff --git a/src/components/who_to_follow/who_to_follow.js b/src/components/who_to_follow/who_to_follow.js index 7ae602a2..8fab6c4d 100644 --- a/src/components/who_to_follow/who_to_follow.js +++ b/src/components/who_to_follow/who_to_follow.js @@ -21,7 +21,8 @@ const WhoToFollow = { name: i.display_name, screen_name: i.acct, profile_image_url: i.avatar || '/images/avi.png', - profile_image_url_original: i.avatar || '/images/avi.png' + profile_image_url_original: i.avatar || '/images/avi.png', + statusnet_profile_url: i.url } this.users.push(user) @@ -37,7 +38,7 @@ const WhoToFollow = { getWhoToFollow () { const credentials = this.$store.state.users.currentUser.credentials if (credentials) { - apiService.suggestions({credentials: credentials}) + apiService.suggestions({ credentials: credentials }) .then((reply) => { this.showWhoToFollow(reply) }) diff --git a/src/components/who_to_follow/who_to_follow.vue b/src/components/who_to_follow/who_to_follow.vue index 8bc9a728..3a17d0e2 100644 --- a/src/components/who_to_follow/who_to_follow.vue +++ b/src/components/who_to_follow/who_to_follow.vue @@ -1,10 +1,15 @@ <template> <div class="panel panel-default"> <div class="panel-heading"> - {{$t('who_to_follow.who_to_follow')}} + {{ $t('who_to_follow.who_to_follow') }} </div> <div class="panel-body"> - <FollowCard v-for="user in users" :key="user.id" :user="user" class="list-item"/> + <FollowCard + v-for="user in users" + :key="user.id" + :user="user" + class="list-item" + /> </div> </div> </template> 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 index a56a27ea..7d01678b 100644 --- a/src/components/who_to_follow_panel/who_to_follow_panel.js +++ b/src/components/who_to_follow_panel/who_to_follow_panel.js @@ -29,7 +29,7 @@ function getWhoToFollow (panel) { panel.usersToFollow.forEach(toFollow => { toFollow.name = 'Loading...' }) - apiService.suggestions({credentials: credentials}) + apiService.suggestions({ credentials: credentials }) .then((reply) => { showWhoToFollow(panel, reply) }) 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 index 74e82789..518acd97 100644 --- a/src/components/who_to_follow_panel/who_to_follow_panel.vue +++ b/src/components/who_to_follow_panel/who_to_follow_panel.vue @@ -3,19 +3,23 @@ <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')}} + {{ $t('who_to_follow.who_to_follow') }} </div> </div> <div class="who-to-follow"> - <p v-for="user in usersToFollow" class="who-to-follow-items"> - <img v-bind:src="user.img" /> - <router-link v-bind:to="userProfileLink(user.id, user.name)"> - {{user.name}} - </router-link><br /> + <p + v-for="user in usersToFollow" + :key="user.id" + class="who-to-follow-items" + > + <img :src="user.img"> + <router-link :to="userProfileLink(user.id, user.name)"> + {{ user.name }} + </router-link><br> </p> <p class="who-to-follow-more"> <router-link :to="{ name: 'who-to-follow' }"> - {{$t('who_to_follow.more')}} + {{ $t('who_to_follow.more') }} </router-link> </p> </div> |
