* upstream/develop: (469 commits) Feature/add sticker picker guard more secure routes guard secure routes by redirecting to root closest can returns itself as well find inside status-content div only try to use the closest a tag as target Update es.json Also apply keyword filter to subjects Remove files I accidentally pushed in fix issues caused by merges in usersearch on @ Add user search at fix eslint warnings remove vue-popperjs fix moderation menu partially hidden by usercard boundary migrate popper css rewrite ModerationTools using v-tooltip make popover position for status action dropdow relative to parent node rewrite ExtraButtons using v-tooltip install v-tooltip i18n/Update pedantic Japanese translation ...emoji-mastoapi
commit
b3aff9bbae
@ -1,61 +1,62 @@
|
|||||||
var path = require('path')
|
var path = require('path')
|
||||||
var config = require('../config')
|
var config = require('../config')
|
||||||
var ExtractTextPlugin = require('extract-text-webpack-plugin')
|
var sass = require('sass')
|
||||||
|
var MiniCssExtractPlugin = require('mini-css-extract-plugin')
|
||||||
|
|
||||||
exports.assetsPath = function (_path) {
|
exports.assetsPath = function (_path) {
|
||||||
var assetsSubDirectory = process.env.NODE_ENV === 'production'
|
var assetsSubDirectory = process.env.NODE_ENV === 'production'
|
||||||
? config.build.assetsSubDirectory
|
? config.build.assetsSubDirectory
|
||||||
: config.dev.assetsSubDirectory
|
: config.dev.assetsSubDirectory
|
||||||
return path.posix.join(assetsSubDirectory, _path)
|
return path.posix.join(assetsSubDirectory, _path)
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.cssLoaders = function (options) {
|
exports.cssLoaders = function (options) {
|
||||||
options = options || {}
|
options = options || {}
|
||||||
// generate loader string to be used with extract text plugin
|
|
||||||
function generateLoaders (loaders) {
|
|
||||||
var sourceLoader = loaders.map(function (loader) {
|
|
||||||
var extraParamChar
|
|
||||||
if (/\?/.test(loader)) {
|
|
||||||
loader = loader.replace(/\?/, '-loader?')
|
|
||||||
extraParamChar = '&'
|
|
||||||
} else {
|
|
||||||
loader = loader + '-loader'
|
|
||||||
extraParamChar = '?'
|
|
||||||
}
|
|
||||||
return loader + (options.sourceMap ? extraParamChar + 'sourceMap' : '')
|
|
||||||
}).join('!')
|
|
||||||
|
|
||||||
|
function generateLoaders (loaders) {
|
||||||
// Extract CSS when that option is specified
|
// Extract CSS when that option is specified
|
||||||
// (which is the case during production build)
|
// (which is the case during production build)
|
||||||
if (options.extract) {
|
if (options.extract) {
|
||||||
return ExtractTextPlugin.extract('vue-style-loader', sourceLoader)
|
return [MiniCssExtractPlugin.loader].concat(loaders)
|
||||||
} else {
|
} else {
|
||||||
return ['vue-style-loader', sourceLoader].join('!')
|
return ['vue-style-loader'].concat(loaders)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// http://vuejs.github.io/vue-loader/configurations/extract-css.html
|
// http://vuejs.github.io/vue-loader/configurations/extract-css.html
|
||||||
return {
|
return [
|
||||||
css: generateLoaders(['css']),
|
{
|
||||||
postcss: generateLoaders(['css']),
|
test: /\.(post)?css$/,
|
||||||
less: generateLoaders(['css', 'less']),
|
use: generateLoaders(['css-loader']),
|
||||||
sass: generateLoaders(['css', 'sass?indentedSyntax']),
|
},
|
||||||
scss: generateLoaders(['css', 'sass']),
|
{
|
||||||
stylus: generateLoaders(['css', 'stylus']),
|
test: /\.less$/,
|
||||||
styl: generateLoaders(['css', 'stylus'])
|
use: generateLoaders(['css-loader', 'less-loader']),
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
test: /\.sass$/,
|
||||||
|
use: generateLoaders([
|
||||||
|
'css-loader',
|
||||||
|
{
|
||||||
|
loader: 'sass-loader',
|
||||||
|
options: {
|
||||||
|
indentedSyntax: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.scss$/,
|
||||||
|
use: generateLoaders(['css-loader', 'sass-loader'])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.styl(us)?$/,
|
||||||
|
use: generateLoaders(['css-loader', 'stylus-loader']),
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate loaders for standalone style files (outside of .vue)
|
// Generate loaders for standalone style files (outside of .vue)
|
||||||
exports.styleLoaders = function (options) {
|
exports.styleLoaders = function (options) {
|
||||||
var output = []
|
return exports.cssLoaders(options)
|
||||||
var loaders = exports.cssLoaders(options)
|
|
||||||
for (var extension in loaders) {
|
|
||||||
var loader = loaders[extension]
|
|
||||||
output.push({
|
|
||||||
test: new RegExp('\\.' + extension + '$'),
|
|
||||||
loader: loader
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return output
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,26 @@
|
|||||||
|
import LoginForm from '../login_form/login_form.vue'
|
||||||
|
import MFARecoveryForm from '../mfa_form/recovery_form.vue'
|
||||||
|
import MFATOTPForm from '../mfa_form/totp_form.vue'
|
||||||
|
import { mapGetters } from 'vuex'
|
||||||
|
|
||||||
|
const AuthForm = {
|
||||||
|
name: 'AuthForm',
|
||||||
|
render (createElement) {
|
||||||
|
return createElement('component', { is: this.authForm })
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
authForm () {
|
||||||
|
if (this.requiredTOTP) { return 'MFATOTPForm' }
|
||||||
|
if (this.requiredRecovery) { return 'MFARecoveryForm' }
|
||||||
|
return 'LoginForm'
|
||||||
|
},
|
||||||
|
...mapGetters('authFlow', ['requiredTOTP', 'requiredRecovery'])
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MFARecoveryForm,
|
||||||
|
MFATOTPForm,
|
||||||
|
LoginForm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthForm
|
@ -0,0 +1,52 @@
|
|||||||
|
const debounceMilliseconds = 500
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
query: { // function to query results and return a promise
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
filter: { // function to filter results in real time
|
||||||
|
type: Function
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: 'Search...'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
term: '',
|
||||||
|
timeout: null,
|
||||||
|
results: [],
|
||||||
|
resultsVisible: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
filtered () {
|
||||||
|
return this.filter ? this.filter(this.results) : this.results
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
term (val) {
|
||||||
|
this.fetchResults(val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fetchResults (term) {
|
||||||
|
clearTimeout(this.timeout)
|
||||||
|
this.timeout = setTimeout(() => {
|
||||||
|
this.results = []
|
||||||
|
if (term) {
|
||||||
|
this.query(term).then((results) => { this.results = results })
|
||||||
|
}
|
||||||
|
}, debounceMilliseconds)
|
||||||
|
},
|
||||||
|
onInputClick () {
|
||||||
|
this.resultsVisible = true
|
||||||
|
},
|
||||||
|
onClickOutside () {
|
||||||
|
this.resultsVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<script src="./autosuggest.js"></script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@import '../../_variables.scss';
|
||||||
|
|
||||||
|
.autosuggest {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&-input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-results {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
max-height: 400px;
|
||||||
|
background-color: $fallback--lightBg;
|
||||||
|
background-color: var(--lightBg, $fallback--lightBg);
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: $fallback--border;
|
||||||
|
border-color: var(--border, $fallback--border);
|
||||||
|
border-radius: $fallback--inputRadius;
|
||||||
|
border-radius: var(--inputRadius, $fallback--inputRadius);
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6);
|
||||||
|
box-shadow: var(--panelShadow);
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -0,0 +1,21 @@
|
|||||||
|
import UserAvatar from '../user_avatar/user_avatar.vue'
|
||||||
|
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
|
||||||
|
|
||||||
|
const AvatarList = {
|
||||||
|
props: ['users'],
|
||||||
|
computed: {
|
||||||
|
slicedUsers () {
|
||||||
|
return this.users ? this.users.slice(0, 15) : []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
UserAvatar
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
userProfileLink (user) {
|
||||||
|
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AvatarList
|
@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<div class="avatars">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<script src="./avatar_list.js" ></script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@import '../../_variables.scss';
|
||||||
|
|
||||||
|
.avatars {
|
||||||
|
display: flex;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
// For hiding overflowing elements
|
||||||
|
flex-wrap: wrap;
|
||||||
|
height: 24px;
|
||||||
|
|
||||||
|
.avatars-item {
|
||||||
|
margin: 0 0 5px 5px;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
padding-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-small {
|
||||||
|
border-radius: $fallback--avatarAltRadius;
|
||||||
|
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<conversation
|
<conversation
|
||||||
:collapsable="false"
|
:collapsable="false"
|
||||||
isPage="true"
|
is-page="true"
|
||||||
:statusoid="statusoid"
|
:statusoid="statusoid"
|
||||||
></conversation>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script src="./conversation-page.js"></script>
|
<script src="./conversation-page.js"></script>
|
||||||
|
@ -1,17 +0,0 @@
|
|||||||
const DeleteButton = {
|
|
||||||
props: [ 'status' ],
|
|
||||||
methods: {
|
|
||||||
deleteStatus () {
|
|
||||||
const confirmed = window.confirm('Do you really want to delete this status?')
|
|
||||||
if (confirmed) {
|
|
||||||
this.$store.dispatch('deleteStatus', { id: this.status.id })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
currentUser () { return this.$store.state.users.currentUser },
|
|
||||||
canDelete () { return this.currentUser && this.currentUser.rights.delete_others_notice || this.status.user.id === this.currentUser.id }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DeleteButton
|
|
@ -1,21 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="canDelete">
|
|
||||||
<a href="#" v-on:click.prevent="deleteStatus()">
|
|
||||||
<i class='button-icon icon-cancel delete-status'></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script src="./delete_button.js" ></script>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
@import '../../_variables.scss';
|
|
||||||
|
|
||||||
.icon-cancel,.delete-status {
|
|
||||||
cursor: pointer;
|
|
||||||
&:hover {
|
|
||||||
color: $fallback--cRed;
|
|
||||||
color: var(--cRed, $fallback--cRed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -0,0 +1,14 @@
|
|||||||
|
const DialogModal = {
|
||||||
|
props: {
|
||||||
|
darkOverlay: {
|
||||||
|
default: true,
|
||||||
|
type: Boolean
|
||||||
|
},
|
||||||
|
onCancel: {
|
||||||
|
default: () => {},
|
||||||
|
type: Function
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DialogModal
|
@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<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" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-modal-content">
|
||||||
|
<slot name="default" />
|
||||||
|
</div>
|
||||||
|
<div class="dialog-modal-footer user-interactions panel-footer">
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script src="./dialog_modal.js"></script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@import '../../_variables.scss';
|
||||||
|
|
||||||
|
// TODO: unify with other modals.
|
||||||
|
.dark-overlay {
|
||||||
|
&::before {
|
||||||
|
bottom: 0;
|
||||||
|
content: " ";
|
||||||
|
display: block;
|
||||||
|
cursor: default;
|
||||||
|
left: 0;
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
background: rgba(27,31,35,.5);
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-modal.panel {
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
max-height: 80vh;
|
||||||
|
max-width: 90vw;
|
||||||
|
margin: 15vh auto;
|
||||||
|
position: fixed;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 999;
|
||||||
|
cursor: default;
|
||||||
|
display: block;
|
||||||
|
background-color: $fallback--bg;
|
||||||
|
background-color: var(--bg, $fallback--bg);
|
||||||
|
|
||||||
|
.dialog-modal-heading {
|
||||||
|
padding: .5em .5em;
|
||||||
|
margin-right: auto;
|
||||||
|
margin-bottom: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--panelText);
|
||||||
|
background-color: $fallback--fg;
|
||||||
|
background-color: var(--panel, $fallback--fg);
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-bottom: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-modal-content {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem 1rem;
|
||||||
|
background-color: $fallback--lightBg;
|
||||||
|
background-color: var(--lightBg, $fallback--lightBg);
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-modal-footer {
|
||||||
|
margin: 0;
|
||||||
|
padding: .5em .5em;
|
||||||
|
background-color: $fallback--lightBg;
|
||||||
|
background-color: var(--lightBg, $fallback--lightBg);
|
||||||
|
border-top: 1px solid $fallback--bg;
|
||||||
|
border-top: 1px solid var(--bg, $fallback--bg);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: auto;
|
||||||
|
margin-left: .5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
@ -1,5 +1,9 @@
|
|||||||
<template>
|
<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>
|
</template>
|
||||||
|
|
||||||
<script src="./dm_timeline.js"></script>
|
<script src="./dm_timeline.js"></script>
|
||||||
|
@ -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 */
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
const Exporter = {
|
||||||
|
props: {
|
||||||
|
getContent: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
filename: {
|
||||||
|
type: String,
|
||||||
|
default: 'export.csv'
|
||||||
|
},
|
||||||
|
exportButtonLabel: {
|
||||||
|
type: String,
|
||||||
|
default () {
|
||||||
|
return this.$t('exporter.export')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
processingMessage: {
|
||||||
|
type: String,
|
||||||
|
default () {
|
||||||
|
return this.$t('exporter.processing')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
processing: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
process () {
|
||||||
|
this.processing = true
|
||||||
|
this.getContent()
|
||||||
|
.then((content) => {
|
||||||
|
const fileToDownload = document.createElement('a')
|
||||||
|
fileToDownload.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(content))
|
||||||
|
fileToDownload.setAttribute('download', this.filename)
|
||||||
|
fileToDownload.style.display = 'none'
|
||||||
|
document.body.appendChild(fileToDownload)
|
||||||
|
fileToDownload.click()
|
||||||
|
document.body.removeChild(fileToDownload)
|
||||||
|
// Add delay before hiding processing state since browser takes some time to handle file download
|
||||||
|
setTimeout(() => { this.processing = false }, 2000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Exporter
|
@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<div class="exporter">
|
||||||
|
<div v-if="processing">
|
||||||
|
<i class="icon-spin4 animate-spin exporter-processing" />
|
||||||
|
<span>{{ processingMessage }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="btn btn-default"
|
||||||
|
@click="process"
|
||||||
|
>
|
||||||
|
{{ exportButtonLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script src="./exporter.js"></script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.exporter {
|
||||||
|
&-processing {
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin: 0.25em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -0,0 +1,40 @@
|
|||||||
|
const ExtraButtons = {
|
||||||
|
props: [ 'status' ],
|
||||||
|
methods: {
|
||||||
|
deleteStatus () {
|
||||||
|
const confirmed = window.confirm(this.$t('status.delete_confirm'))
|
||||||
|
if (confirmed) {
|
||||||
|
this.$store.dispatch('deleteStatus', { id: this.status.id })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pinStatus () {
|
||||||
|
this.$store.dispatch('pinStatus', this.status.id)
|
||||||
|
.then(() => this.$emit('onSuccess'))
|
||||||
|
.catch(err => this.$emit('onError', err.error.error))
|
||||||
|
},
|
||||||
|
unpinStatus () {
|
||||||
|
this.$store.dispatch('unpinStatus', this.status.id)
|
||||||
|
.then(() => this.$emit('onSuccess'))
|
||||||
|
.catch(err => this.$emit('onError', err.error.error))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
currentUser () { return this.$store.state.users.currentUser },
|
||||||
|
canDelete () {
|
||||||
|
if (!this.currentUser) { return }
|
||||||
|
const superuser = this.currentUser.rights.moderator || this.currentUser.rights.admin
|
||||||
|
return superuser || this.status.user.id === this.currentUser.id
|
||||||
|
},
|
||||||
|
ownStatus () {
|
||||||
|
return this.status.user.id === this.currentUser.id
|
||||||
|
},
|
||||||
|
canPin () {
|
||||||
|
return this.ownStatus && (this.status.visibility === 'public' || this.status.visibility === 'unlisted')
|
||||||
|
},
|
||||||
|
enabled () {
|
||||||
|
return this.canPin || this.canDelete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExtraButtons
|
@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<v-popover
|
||||||
|
v-if="enabled"
|
||||||
|
trigger="click"
|
||||||
|
placement="top"
|
||||||
|
class="extra-button-popover"
|
||||||
|
:offset="5"
|
||||||
|
:container="false"
|
||||||
|
>
|
||||||
|
<div slot="popover">
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<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">
|
||||||
|
<i class="icon-ellipsis" />
|
||||||
|
</div>
|
||||||
|
</v-popover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script src="./extra_buttons.js" ></script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@import '../../_variables.scss';
|
||||||
|
@import '../popper/popper.scss';
|
||||||
|
|
||||||
|
.icon-ellipsis {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
.extra-button-popover.open & {
|
||||||
|
color: $fallback--text;
|
||||||
|
color: var(--text, $fallback--text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,5 +1,9 @@
|
|||||||
<template>
|
<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>
|
</template>
|
||||||
|
|
||||||
<script src="./friends_timeline.js"></script>
|
<script src="./friends_timeline.js"></script>
|
||||||
|
@ -0,0 +1,53 @@
|
|||||||
|
const Importer = {
|
||||||
|
props: {
|
||||||
|
submitHandler: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
submitButtonLabel: {
|
||||||
|
type: String,
|
||||||
|
default () {
|
||||||
|
return this.$t('importer.submit')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
successMessage: {
|
||||||
|
type: String,
|
||||||
|
default () {
|
||||||
|
return this.$t('importer.success')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
errorMessage: {
|
||||||
|
type: String,
|
||||||
|
default () {
|
||||||
|
return this.$t('importer.error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
file: null,
|
||||||
|
error: false,
|
||||||
|
success: false,
|
||||||
|
submitting: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
change () {
|
||||||
|
this.file = this.$refs.input.files[0]
|
||||||
|
},
|
||||||
|
submit () {
|
||||||
|
this.dismiss()
|
||||||
|
this.submitting = true
|
||||||
|
this.submitHandler(this.file)
|
||||||
|
.then(() => { this.success = true })
|
||||||
|
.catch(() => { this.error = true })
|
||||||
|
.finally(() => { this.submitting = false })
|
||||||
|
},
|
||||||
|
dismiss () {
|
||||||
|
this.success = false
|
||||||
|
this.error = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Importer
|
@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<div class="importer">
|
||||||
|
<form>
|
||||||
|
<input
|
||||||
|
ref="input"
|
||||||
|
type="file"
|
||||||
|
@change="change"
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<p>{{ successMessage }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="error">
|
||||||
|
<i
|
||||||
|
class="icon-cross"
|
||||||
|
@click="dismiss"
|
||||||
|
/>
|
||||||
|
<p>{{ errorMessage }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script src="./importer.js"></script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.importer {
|
||||||
|
&-uploading {
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin: 0.25em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -0,0 +1,25 @@
|
|||||||
|
import Notifications from '../notifications/notifications.vue'
|
||||||
|
|
||||||
|
const tabModeDict = {
|
||||||
|
mentions: ['mention'],
|
||||||
|
'likes+repeats': ['repeat', 'like'],
|
||||||
|
follows: ['follow']
|
||||||
|
}
|
||||||
|
|
||||||
|
const Interactions = {
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
filterMode: tabModeDict['mentions']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onModeSwitch (index, dataset) {
|
||||||
|
this.filterMode = tabModeDict[dataset.filter]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Notifications
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Interactions
|
@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div class="title">
|
||||||
|
{{ $t("nav.interactions") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<tab-switcher
|
||||||
|
ref="tabSwitcher"
|
||||||
|
:on-switch="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')"
|
||||||
|
/>
|
||||||
|
</tab-switcher>
|
||||||
|
<Notifications
|
||||||
|
ref="notifications"
|
||||||
|
:no-heading="true"
|
||||||
|
:minimal-mode="true"
|
||||||
|
:filter-mode="filterMode"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script src="./interactions.js"></script>
|
@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<div class="list">
|
||||||
|
<div
|
||||||
|
v-for="item in items"
|
||||||
|
:key="getKey(item)"
|
||||||
|
class="list-item"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
name="item"
|
||||||
|
:item="item"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="items.length === 0 && !!$slots.empty"
|
||||||
|
class="list-empty-content faint"
|
||||||
|
>
|
||||||
|
<slot name="empty" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
getKey: {
|
||||||
|
type: Function,
|
||||||
|
default: item => item.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@import '../../_variables.scss';
|
||||||
|
|
||||||
|
.list {
|
||||||
|
&-item:not(:last-child) {
|
||||||
|
border-bottom: 1px solid;
|
||||||
|
border-bottom-color: $fallback--border;
|
||||||
|
border-bottom-color: var(--border, $fallback--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-empty-content {
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,5 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<Timeline :title="$t('nav.mentions')" v-bind:timeline="timeline" v-bind:timeline-name="'mentions'"/>
|
<Timeline
|
||||||
|
:title="$t('nav.interactions')"
|
||||||
|
:timeline="timeline"
|
||||||
|
:timeline-name="'mentions'"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script src="./mentions.js"></script>
|
<script src="./mentions.js"></script>
|
||||||
|
@ -0,0 +1,41 @@
|
|||||||
|
import mfaApi from '../../services/new_api/mfa.js'
|
||||||
|
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data: () => ({
|
||||||
|
code: null,
|
||||||
|
error: false
|
||||||
|
}),
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
authApp: 'authFlow/app',
|
||||||
|
authSettings: 'authFlow/settings'
|
||||||
|
}),
|
||||||
|
...mapState({ instance: 'instance' })
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapMutations('authFlow', ['requireTOTP', 'abortMFA']),
|
||||||
|
...mapActions({ login: 'authFlow/login' }),
|
||||||
|
clearError () { this.error = false },
|
||||||
|
submit () {
|
||||||
|
const data = {
|
||||||
|
app: this.authApp,
|
||||||
|
instance: this.instance.server,
|
||||||
|
mfaToken: this.authSettings.mfa_token,
|
||||||
|
code: this.code
|
||||||
|
}
|
||||||
|
|
||||||
|
mfaApi.verifyRecoveryCode(data).then((result) => {
|
||||||
|
if (result.error) {
|
||||||
|
this.error = result.error
|
||||||
|
this.code = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.login(result).then(() => {
|
||||||
|
this.$router.push({ name: 'friends' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login panel panel-default">
|
||||||
|
<!-- Default panel contents -->
|
||||||
|
|
||||||
|
<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
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-default"
|
||||||
|
>
|
||||||
|
{{ $t('general.verify') }}
|
||||||
|
</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>
|
||||||
|
</template>
|
||||||
|
<script src="./recovery_form.js" ></script>
|
@ -0,0 +1,40 @@
|
|||||||
|
import mfaApi from '../../services/new_api/mfa.js'
|
||||||
|
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
|
||||||
|
export default {
|
||||||
|
data: () => ({
|
||||||
|
code: null,
|
||||||
|
error: false
|
||||||
|
}),
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
authApp: 'authFlow/app',
|
||||||
|
authSettings: 'authFlow/settings'
|
||||||
|
}),
|
||||||
|
...mapState({ instance: 'instance' })
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapMutations('authFlow', ['requireRecovery', 'abortMFA']),
|
||||||
|
...mapActions({ login: 'authFlow/login' }),
|
||||||
|
clearError () { this.error = false },
|
||||||
|
submit () {
|
||||||
|
const data = {
|
||||||
|
app: this.authApp,
|
||||||
|
instance: this.instance.server,
|
||||||
|
mfaToken: this.authSettings.mfa_token,
|
||||||
|
code: this.code
|
||||||
|
}
|
||||||
|
|
||||||
|
mfaApi.verifyOTPCode(data).then((result) => {
|
||||||
|
if (result.error) {
|
||||||
|
this.error = result.error
|
||||||
|
this.code = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.login(result).then(() => {
|
||||||
|
this.$router.push({ name: 'friends' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login panel panel-default">
|
||||||
|
<!-- Default panel contents -->
|
||||||
|
|
||||||
|
<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
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-default"
|
||||||
|
>
|
||||||
|
{{ $t('general.verify') }}
|
||||||
|
</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>
|
||||||
|
</template>
|
||||||
|
<script src="./totp_form.js"></script>
|
@ -0,0 +1,101 @@
|
|||||||
|
import DialogModal from '../dialog_modal/dialog_modal.vue'
|
||||||
|
|
||||||
|
const FORCE_NSFW = 'mrf_tag:media-force-nsfw'
|
||||||
|
const STRIP_MEDIA = 'mrf_tag:media-strip'
|
||||||
|
const FORCE_UNLISTED = 'mrf_tag:force-unlisted'
|
||||||
|
const DISABLE_REMOTE_SUBSCRIPTION = 'mrf_tag:disable-remote-subscription'
|
||||||
|
const DISABLE_ANY_SUBSCRIPTION = 'mrf_tag:disable-any-subscription'
|
||||||
|
const SANDBOX = 'mrf_tag:sandbox'
|
||||||
|
const QUARANTINE = 'mrf_tag:quarantine'
|
||||||
|
|
||||||
|
const ModerationTools = {
|
||||||
|
props: [
|
||||||
|
'user'
|
||||||
|
],
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
showDropDown: false,
|
||||||
|
tags: {
|
||||||
|
FORCE_NSFW,
|
||||||
|
STRIP_MEDIA,
|
||||||
|
FORCE_UNLISTED,
|
||||||
|
DISABLE_REMOTE_SUBSCRIPTION,
|
||||||
|
DISABLE_ANY_SUBSCRIPTION,
|
||||||
|
SANDBOX,
|
||||||
|
QUARANTINE
|
||||||
|
},
|
||||||
|
showDeleteUserDialog: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
DialogModal
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
tagsSet () {
|
||||||
|
return new Set(this.user.tags)
|
||||||
|
},
|
||||||
|
hasTagPolicy () {
|
||||||
|
return this.$store.state.instance.tagPolicyAvailable
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
hasTag (tagName) {
|
||||||
|
return this.tagsSet.has(tagName)
|
||||||
|
},
|
||||||
|
toggleTag (tag) {
|
||||||
|
const store = this.$store
|
||||||
|
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 })
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
store.state.api.backendInteractor.tagUser(this.user, tag).then(response => {
|
||||||
|
if (!response.ok) { return }
|
||||||
|
store.commit('tagUser', { user: this.user, tag })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleRight (right) {
|
||||||
|
const store = this.$store
|
||||||
|
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 })
|
||||||
|
})
|
||||||
|
} 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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleActivationStatus () {
|
||||||
|
const store = this.$store
|
||||||
|
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 })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteUserDialog (show) {
|
||||||
|
this.showDeleteUserDialog = show
|
||||||
|
},
|
||||||
|
deleteUser () {
|
||||||
|
const store = this.$store
|
||||||
|
const user = this.user
|
||||||
|
const { id, name } = user
|
||||||
|
store.state.api.backendInteractor.deleteUser(user)
|
||||||
|
.then(e => {
|
||||||
|
this.$store.dispatch('markStatusesAsDeleted', status => user.id === status.user.id)
|
||||||
|
const isProfile = this.$route.name === 'external-user-profile' || this.$route.name === 'user-profile'
|
||||||
|
const isTargetUser = this.$route.params.name === name || this.$route.params.id === id
|
||||||
|
if (isProfile && isTargetUser) {
|
||||||
|
window.history.back()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModerationTools
|
@ -0,0 +1,193 @@
|
|||||||
|
<template>
|
||||||
|
<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="deleteUserDialog(true)"
|
||||||
|
>
|
||||||
|
{{ $t('user_card.admin_menu.delete_account') }}
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
<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') }}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@import '../../_variables.scss';
|
||||||
|
@import '../popper/popper.scss';
|
||||||
|
|
||||||
|
.menu-checkbox {
|
||||||
|
float: right;
|
||||||
|
min-width: 22px;
|
||||||
|
max-width: 22px;
|
||||||
|
min-height: 22px;
|
||||||
|
max-height: 22px;
|
||||||
|
line-height: 22px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 0px;
|
||||||
|
background-color: $fallback--fg;
|
||||||
|
background-color: var(--input, $fallback--fg);
|
||||||
|
box-shadow: 0px 0px 2px black inset;
|
||||||
|
box-shadow: var(--inputShadow);
|
||||||
|
|
||||||
|
&.menu-checkbox-checked::after {
|
||||||
|
content: '✔';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.moderation-tools-popover {
|
||||||
|
height: 100%;
|
||||||
|
.trigger {
|
||||||
|
display: flex !important;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
@ -0,0 +1,148 @@
|
|||||||
|
@import '../../_variables.scss';
|
||||||
|
|
||||||
|
.tooltip.popover {
|
||||||
|
z-index: 8;
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[x-placement^="left"] {
|
||||||
|
margin-right: 5px;
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[aria-hidden='true'] {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .15s, visibility .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[aria-hidden='false'] {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity .15s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
display: block;
|
||||||
|
padding: .5rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
list-style: none;
|
||||||
|
max-width: 100vw;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
.dropdown-divider {
|
||||||
|
height: 0;
|
||||||
|
margin: .5rem 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-top: 1px solid $fallback--border;
|
||||||
|
border-top: 1px solid var(--border, $fallback--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
line-height: 21px;
|
||||||
|
margin-right: 5px;
|
||||||
|
overflow: auto;
|
||||||
|
display: block;
|
||||||
|
padding: .25rem 1.0rem .25rem 1.5rem;
|
||||||
|
clear: both;
|
||||||
|
font-weight: 400;
|
||||||
|
text-align: inherit;
|
||||||
|
white-space: normal;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0px;
|
||||||
|
background-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
&-icon {
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
|
||||||
|
i {
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
// TODO: improve the look on breeze themes
|
||||||
|
background-color: $fallback--fg;
|
||||||
|
background-color: var(--btn, $fallback--fg);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,38 @@
|
|||||||
|
<template>
|
||||||
|
<button
|
||||||
|
:disabled="progress || disabled"
|
||||||
|
@click="onClick"
|
||||||
|
>
|
||||||
|
<template v-if="progress && $slots.progress">
|
||||||
|
<slot name="progress" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<slot />
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
disabled: {
|
||||||
|
type: Boolean
|
||||||
|
},
|
||||||
|
click: { // click event handler. Must return a promise
|
||||||
|
type: Function,
|
||||||
|
default: () => Promise.resolve()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
progress: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onClick () {
|
||||||
|
this.progress = true
|
||||||
|
this.click().then(() => { this.progress = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue