Merge branch 'master' into feature/update-server-configuration

This commit is contained in:
Angelina Filippova 2020-01-09 22:37:49 +07:00
commit 306c79eadd
157 changed files with 2359 additions and 2058 deletions

View file

@ -8,26 +8,36 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Changed
- moves emoji pack configuration from the main menu to settings tab, redesigns it and fixes bugs
- **breaking** PleromaFE login feature relies on `admin` scope presence in PleromaFE token (older versions of PleromaFE don't support it)
- Moves emoji pack configuration from the main menu to settings tab, redesigns it and fixes bugs
- `mailerEnabled` must be set to `true` in order to require password reset (password reset currently only works via email)
- remove fetching initial data for configuring server settings
- Remove fetching initial data for configuring server settings
- Actions in users module (ActivateUsers, AddRight, DeactivateUsers, DeleteRight, DeleteUsers) now accept an array of users instead of one user
- Leave dropdown menu open after clicking an action
- Move current try/catch error handling from view files to module, add it where necessary
### Added
- Optimistic update for actions in users module and fetching users after api function finished its execution
- Relay management
- Ability to fetch all statuses from a given instance
- Grouped reports: now you can view reports, which are grouped by status (pagination is not implemented yet, though)
- Ability to confirm users' emails and resend confirmation emails
- Report notes
- Ability to moderate users on the statuses page
### Fixed
- Show checkmarks when tag is applied
- Reports update (also, now it's optimistic)
- Remove duplicated success message
## [1.2.0] - 2019-09-27
### Added
- Emoji pack configuration
- Statuses page: fetch all statuses from a given instance
- Ability to require user's password reset
Ability to track admin/moderator actions, a.k.a. "the moderation log"

View file

@ -6,6 +6,13 @@
Admin UI for pleroma instance owners.
### Branches
There are two main branches here:
- `develop`: ongoing work and all merge requests go here, *unstable*
- `master`: after `develop` is stabilized it is merged to `master`, `master` is *stable*, allegedly
### Features
1. User administration: grant roles to users (admin/moderator), deactivate/delete as well as force their statuses to have NSFW tag, strip media and many more
@ -18,13 +25,17 @@ You can have any combination of these features (i.e. you can disable anything, b
## Usage
### Bundled
AdminFE is bundled with Pleroma, i.e. you can just visit `https://your.instance/pleroma/admin/` to try it out.
### Development
To run AdminFE locally execute `yarn dev`
### Build
To compile everything for production run `yarn build:prod`.
To compile everything for production run `yarn build:prod`, this will build admin-fe into `dist` folder, which you will need to upload to your server and/or point your webserver of choice to.
#### Disabling features

View file

@ -8,7 +8,6 @@
<title>Admin FE</title>
</head>
<body>
<script src=<%= BASE_URL %>/tinymce4.7.5/tinymce.min.js></script>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>

View file

@ -43,7 +43,7 @@
"driver.js": "0.8.1",
"dropzone": "5.2.0",
"echarts": "4.1.0",
"element-ui": "^2.10.0",
"element-ui": "^2.13.0",
"file-saver": "1.3.8",
"fuse.js": "3.4.2",
"js-cookie": "2.2.0",

View file

@ -8,7 +8,7 @@ export async function loginByUsername(username, password, authHost) {
const verifyHost = user.authHost === authHost
const data = {
'token_type': 'Bearer',
'scope': 'read write follow',
'scope': 'read write follow push admin',
'refresh_token': 'foo123',
'me': 'bob',
'expires_in': 600,

View file

@ -11,20 +11,42 @@ const reports = [
{ created_at: '2019-05-18T13:01:33.000Z', account: { acct: 'nick', display_name: 'Nick Keys', tags: [] }, actor: { acct: 'admin' }, state: 'closed', id: '4', content: '', statuses: [] }
]
export async function fetchReports(limit, max_id, authHost, token) {
const paginatedReports = max_id.length > 0 ? reports.slice(5) : reports.slice(0, 5)
return Promise.resolve({ data: { reports: paginatedReports }})
const groupedReports = [
{ account: { avatar: 'http://localhost:4000/images/avi.png', confirmation_pending: false, deactivated: false, display_name: 'leo', id: '9oG0YghgBi94EATI9I', local: true, nickname: 'leo', roles: { admin: false, moderator: false }, tags: [] },
actors: [{ acct: 'admin', avatar: 'http://localhost:4000/images/avi.png', deactivated: false, display_name: 'admin', id: '9oFz4pTauG0cnJ581w', local: true, nickname: 'admin', roles: { admin: false, moderator: false }, tags: [], url: 'http://localhost:4000/users/admin', username: 'admin' }],
date: '2019-11-23T12:56:11.969772Z',
reports: [
{ created_at: '2019-05-21T21:35:33.000Z', account: { acct: 'benj', display_name: 'Benjamin Fame', tags: [] }, actor: { acct: 'admin' }, state: 'open', id: '2', content: 'This is a report', statuses: [] },
{ created_at: '2019-05-20T22:45:33.000Z', account: { acct: 'alice', display_name: 'Alice Pool', tags: [] }, actor: { acct: 'admin2' }, state: 'resolved', id: '7', content: 'Please block this user', statuses: [
{ account: { display_name: 'Alice Pool', avatar: '' }, visibility: 'public', sensitive: false, id: '11', content: 'Hey!', url: '', created_at: '2019-05-10T21:35:33.000Z' },
{ account: { display_name: 'Alice Pool', avatar: '' }, visibility: 'unlisted', sensitive: true, id: '10', content: 'Bye!', url: '', created_at: '2019-05-10T21:00:33.000Z' }
] }
],
status: {
account: { acct: 'leo' },
content: 'At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis',
created_at: '2019-11-23T12:55:20.000Z',
id: '9pFoQO69piu7cUDnJg',
url: 'http://localhost:4000/notice/9pFoQO69piu7cUDnJg',
visibility: 'unlisted',
sensitive: true
},
status_deleted: false
}
]
export async function fetchReports(filter, page, pageSize, authHost, token) {
return filter.length > 0
? Promise.resolve({ data: { reports: reports.filter(report => report.state === filter) }})
: Promise.resolve({ data: { reports }})
}
export async function filterReports(filter, limit, max_id, authHost, token) {
const filteredReports = reports.filter(report => report.state === filter)
const paginatedReports = max_id.length > 0 ? filteredReports.slice(5) : filteredReports.slice(0, 5)
return Promise.resolve({ data: { reports: paginatedReports }})
export async function fetchGroupedReports(authHost, token) {
return Promise.resolve({ data: { reports: groupedReports }})
}
export async function changeState(state, id, authHost, token) {
const report = reports.find(report => report.id === id)
return Promise.resolve({ data: { ...report, state }})
export async function changeState(reportsData, authHost, token) {
return Promise.resolve({ data: '' })
}
export async function changeStatusScope(id, sensitive, visibility, authHost, token) {

View file

@ -0,0 +1,7 @@
export async function changeStatusScope(id, sensitive, visibility, authHost, token) {
return Promise.resolve()
}
export async function deleteStatus(id, authHost, token) {
return Promise.resolve()
}

View file

@ -4,6 +4,10 @@ export let users = [
{ active: false, deactivated: true, id: 'abc', nickname: 'john', local: true, external: false, roles: { admin: false, moderator: false }, tags: ['strip_media'] }
]
const userProfile = { avatar: 'avatar.jpg', display_name: 'Allis', nickname: 'allis', id: '2', tags: [], roles: { admin: true, moderator: false }, local: true, external: false }
const userStatuses = []
const filterUsers = (str) => {
const filters = str.split(',').filter(item => item.length > 0)
if (filters.length === 0) {
@ -20,6 +24,10 @@ const filterUsers = (str) => {
return applyFilters([], filters, users)
}
export async function fetchUser(id, authHost, token) {
return Promise.resolve({ data: userProfile })
}
export async function fetchUsers(filters, authHost, token, page = 1) {
const filteredUsers = filterUsers(filters)
return Promise.resolve({ data: {
@ -29,6 +37,10 @@ export async function fetchUsers(filters, authHost, token, page = 1) {
}})
}
export async function fetchUserStatuses(id, authHost, godmode, token) {
return Promise.resolve({ data: userStatuses })
}
export async function getPasswordResetToken(nickname, authHost, token) {
return Promise.resolve({ data: { token: 'g05lxnBJQnL', link: 'http://url/api/pleroma/password_reset/g05lxnBJQnL' }})
}

View file

@ -9,7 +9,7 @@ export async function loginByUsername(username, password, authHost) {
data: {
client_name: `AdminFE_${Math.random()}`,
redirect_uris: `${window.location.origin}/oauth-callback`,
scopes: 'read write follow'
scopes: 'read write follow push admin'
}
})

14
src/api/peers.js Normal file
View file

@ -0,0 +1,14 @@
import request from '@/utils/request'
import { getToken } from '@/utils/auth'
import { baseName } from './utils'
export async function fetchPeers(authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/v1/instance/peers`,
method: 'get',
headers: authHeaders(token)
})
}
const authHeaders = (token) => token ? { 'Authorization': `Bearer ${getToken()}` } : {}

View file

@ -2,51 +2,54 @@ import request from '@/utils/request'
import { getToken } from '@/utils/auth'
import { baseName } from './utils'
export async function changeState(state, id, authHost, token) {
export async function changeState(reports, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/reports/${id}`,
method: 'put',
url: `/api/pleroma/admin/reports`,
method: 'patch',
headers: authHeaders(token),
data: { state }
data: { reports }
})
}
export async function changeStatusScope(id, sensitive, visibility, authHost, token) {
export async function fetchReports(filter, page, pageSize, authHost, token) {
const url = filter.length > 0
? `/api/pleroma/admin/reports?state=${filter}&page=${page}&page_size=${pageSize}`
: `/api/pleroma/admin/reports?page=${page}&page_size=${pageSize}`
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/statuses/${id}`,
method: 'put',
headers: authHeaders(token),
data: { sensitive, visibility }
})
}
export async function deleteStatus(id, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/statuses/${id}`,
method: 'delete',
headers: authHeaders(token)
})
}
export async function fetchReports(limit, max_id, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/reports?limit=${limit}&max_id=${max_id}`,
url,
method: 'get',
headers: authHeaders(token)
})
}
export async function filterReports(filter, limit, max_id, authHost, token) {
export async function fetchGroupedReports(authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/reports?state=${filter}&limit=${limit}&max_id=${max_id}`,
url: `/api/pleroma/admin/grouped_reports`,
method: 'get',
headers: authHeaders(token)
})
}
export async function createNote(content, reportID, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/reports/${reportID}/notes`,
method: `post`,
headers: authHeaders(token),
data: { content }
})
}
export async function deleteNote(noteID, reportID, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/reports/${reportID}/notes/${noteID}`,
method: `delete`,
headers: authHeaders(token)
})
}
const authHeaders = (token) => token ? { 'Authorization': `Bearer ${getToken()}` } : {}

33
src/api/status.js Normal file
View file

@ -0,0 +1,33 @@
import request from '@/utils/request'
import { getToken } from '@/utils/auth'
import { baseName } from './utils'
export async function changeStatusScope(id, sensitive, visibility, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/statuses/${id}`,
method: 'put',
headers: authHeaders(token),
data: { sensitive, visibility }
})
}
export async function deleteStatus(id, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/statuses/${id}`,
method: 'delete',
headers: authHeaders(token)
})
}
export async function fetchStatusesByInstance(instance, authHost, token, pageSize, page = 1) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/instances/${instance}/statuses?page=${page}&page_size=${pageSize}`,
method: 'get',
headers: authHeaders(token)
})
}
const authHeaders = (token) => token ? { 'Authorization': `Bearer ${getToken()}` } : {}

View file

@ -136,4 +136,24 @@ export async function fetchUserStatuses(id, authHost, godmode, token) {
})
}
export async function confirmUserEmail(nicknames, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: '/api/pleroma/admin/users/confirm_email',
method: 'patch',
headers: authHeaders(token),
data: { nicknames }
})
}
export async function resendConfirmationEmail(nicknames, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: '/api/pleroma/admin/users/resend_confirmation_email',
method: 'patch',
headers: authHeaders(token),
data: { nicknames }
})
}
const authHeaders = (token) => token ? { 'Authorization': `Bearer ${getToken()}` } : {}

View file

@ -0,0 +1,278 @@
<template>
<div>
<el-card v-if="!status.deleted" class="status-card">
<div slot="header">
<div class="status-header">
<div class="status-account-container">
<div class="status-account">
<el-checkbox @change="handleStatusSelection(status.account)">
<img :src="status.account.avatar" class="status-avatar-img">
<h3 class="status-account-name">{{ status.account.display_name }}</h3>
</el-checkbox>
</div>
<a :href="status.account.url" target="_blank" class="account">
@{{ status.account.acct }}
</a>
</div>
<div class="status-actions">
<el-tag v-if="status.sensitive" type="warning" size="large">{{ $t('reports.sensitive') }}</el-tag>
<el-tag size="large">{{ capitalizeFirstLetter(status.visibility) }}</el-tag>
<el-dropdown trigger="click">
<el-button plain size="small" icon="el-icon-edit" class="status-actions-button">
{{ $t('reports.changeScope') }}<i class="el-icon-arrow-down el-icon--right"/>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item
v-if="!status.sensitive"
@click.native="changeStatus(status.id, true, status.visibility)">
{{ $t('reports.addSensitive') }}
</el-dropdown-item>
<el-dropdown-item
v-if="status.sensitive"
@click.native="changeStatus(status.id, false, status.visibility)">
{{ $t('reports.removeSensitive') }}
</el-dropdown-item>
<el-dropdown-item
v-if="status.visibility !== 'public'"
@click.native="changeStatus(status.id, status.sensitive, 'public')">
{{ $t('reports.public') }}
</el-dropdown-item>
<el-dropdown-item
v-if="status.visibility !== 'private'"
@click.native="changeStatus(status.id, status.sensitive, 'private')">
{{ $t('reports.private') }}
</el-dropdown-item>
<el-dropdown-item
v-if="status.visibility !== 'unlisted'"
@click.native="changeStatus(status.id, status.sensitive, 'unlisted')">
{{ $t('reports.unlisted') }}
</el-dropdown-item>
<el-dropdown-item
@click.native="deleteStatus(status.id)">
{{ $t('reports.deleteStatus') }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
</div>
<div class="status-body">
<div v-if="status.spoiler_text">
<strong>{{ status.spoiler_text }}</strong>
<el-button v-if="!showHiddenStatus" size="mini" class="show-more-button" @click="showHiddenStatus = true">Show more</el-button>
<el-button v-if="showHiddenStatus" size="mini" class="show-more-button" @click="showHiddenStatus = false">Show less</el-button>
<div v-if="showHiddenStatus">
<span class="status-content" v-html="status.content"/>
<div v-if="status.poll" class="poll">
<ul>
<li v-for="(option, index) in status.poll.options" :key="index">
{{ option.title }}
<el-progress :percentage="optionPercent(status.poll, option)" />
</li>
</ul>
</div>
<div v-for="(attachment, index) in status.media_attachments" :key="index" class="image">
<img :src="attachment.preview_url">
</div>
</div>
</div>
<div v-if="!status.spoiler_text">
<span class="status-content" v-html="status.content"/>
<div v-if="status.poll" class="poll">
<ul>
<li v-for="(option, index) in status.poll.options" :key="index">
{{ option.title }}
<el-progress :percentage="optionPercent(status.poll, option)" />
</li>
</ul>
</div>
<div v-for="(attachment, index) in status.media_attachments" :key="index" class="image">
<img :src="attachment.preview_url">
</div>
</div>
<a :href="status.url" target="_blank" class="account">
{{ parseTimestamp(status.created_at) }}
</a>
</div>
</el-card>
<el-card v-else class="status-card">
<div slot="header">
<div class="status-header">
<div class="status-account-container">
<div class="status-account">
<h4 class="status-deleted">{{ $t('reports.statusDeleted') }}</h4>
</div>
</div>
</div>
</div>
<div class="status-body">
<span v-if="status.content" class="status-content" v-html="status.content"/>
<span v-else class="status-without-content">no content</span>
</div>
<a v-if="status.created_at" :href="status.url" target="_blank" class="account">
{{ parseTimestamp(status.created_at) }}
</a>
</el-card>
</div>
</template>
<script>
import moment from 'moment'
export default {
name: 'Status',
props: {
status: {
type: Object,
required: true
},
page: {
type: Number,
required: false,
default: 0
},
userId: {
type: String,
required: false,
default: ''
},
godmode: {
type: Boolean,
required: false,
default: false
}
},
data() {
return {
showHiddenStatus: false
}
},
methods: {
capitalizeFirstLetter(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
},
changeStatus(statusId, isSensitive, visibility) {
this.$store.dispatch('ChangeStatusScope', { statusId, isSensitive, visibility, reportCurrentPage: this.page, userId: this.userId, godmode: this.godmode })
},
deleteStatus(statusId) {
this.$confirm('Are you sure you want to delete this status?', 'Warning', {
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
type: 'warning'
}).then(() => {
this.$store.dispatch('DeleteStatus', { statusId, reportCurrentPage: this.page, userId: this.userId, godmode: this.godmode })
this.$message({
type: 'success',
message: 'Delete completed'
})
}).catch(() => {
this.$message({
type: 'info',
message: 'Delete canceled'
})
})
},
optionPercent(poll, pollOption) {
const allVotes = poll.options.reduce((acc, option) => (acc + option.votes_count), 0)
if (allVotes === 0) {
return 0
}
return +(pollOption.votes_count / allVotes * 100).toFixed(1)
},
parseTimestamp(timestamp) {
return moment(timestamp).format('YYYY-MM-DD HH:mm')
},
handleStatusSelection(account) {
this.$emit('status-selection', account)
}
}
}
</script>
<style rel='stylesheet/scss' lang='scss'>
.status-card {
.account {
text-decoration: underline;
line-height: 26px;
font-size: 13px;
}
.image {
width: 20%;
img {
width: 100%;
}
}
.show-more-button {
margin-left: 5px;
}
.status-account {
display: flex;
align-items: center;
}
.status-avatar-img {
display: inline-block;
width: 15px;
height: 15px;
margin-right: 5px;
}
.status-account-name {
display: inline-block;
margin: 0;
height: 22px;
}
.status-body {
display: flex;
flex-direction: column;
}
.status-content {
font-size: 15px;
line-height: 26px;
}
.status-card {
margin-bottom: 15px;
}
.status-deleted {
font-style: italic;
margin-top: 3px;
}
.status-header {
display: flex;
justify-content: space-between;
}
.status-without-content {
font-style: italic;
}
}
@media
only screen and (max-width: 760px),
(min-device-width: 768px) and (max-device-width: 1024px) {
.el-message {
min-width: 80%;
}
.el-message-box {
width: 80%;
}
.status-card {
.el-card__header {
padding: 10px 17px;
}
.el-tag {
margin: 3px 4px 3px 0;
}
.status-account-container {
margin-bottom: 5px;
}
.status-actions-button {
margin: 3px 0 3px;
}
.status-actions {
display: flex;
flex-wrap: wrap;
}
.status-header {
display: flex;
flex-direction: column;
}
}
}
</style>

View file

@ -1,103 +0,0 @@
<template>
<div class="upload-container">
<el-button :style="{background:color,borderColor:color}" icon="el-icon-upload" size="mini" type="primary" @click=" dialogVisible=true">上传图片
</el-button>
<el-dialog :visible.sync="dialogVisible">
<el-upload
:multiple="true"
:file-list="fileList"
:show-file-list="true"
:on-remove="handleRemove"
:on-success="handleSuccess"
:before-upload="beforeUpload"
class="editor-slide-upload"
action="https://httpbin.org/post"
list-type="picture-card">
<el-button size="small" type="primary">点击上传</el-button>
</el-upload>
<el-button @click="dialogVisible = false"> </el-button>
<el-button type="primary" @click="handleSubmit"> </el-button>
</el-dialog>
</div>
</template>
<script>
// import { getToken } from 'api/qiniu'
export default {
name: 'EditorSlideUpload',
props: {
color: {
type: String,
default: '#1890ff'
}
},
data: function() {
return {
dialogVisible: false,
listObj: {},
fileList: []
}
},
methods: {
checkAllSuccess() {
return Object.keys(this.listObj).every(item => this.listObj[item].hasSuccess)
},
handleSubmit() {
const arr = Object.keys(this.listObj).map(v => this.listObj[v])
if (!this.checkAllSuccess()) {
this.$message('请等待所有图片上传成功 或 出现了网络问题,请刷新页面重新上传!')
return
}
this.$emit('successCBK', arr)
this.listObj = {}
this.fileList = []
this.dialogVisible = false
},
handleSuccess(response, file) {
const uid = file.uid
const objKeyArr = Object.keys(this.listObj)
for (let i = 0, len = objKeyArr.length; i < len; i++) {
if (this.listObj[objKeyArr[i]].uid === uid) {
this.listObj[objKeyArr[i]].url = response.files.file
this.listObj[objKeyArr[i]].hasSuccess = true
return
}
}
},
handleRemove(file) {
const uid = file.uid
const objKeyArr = Object.keys(this.listObj)
for (let i = 0, len = objKeyArr.length; i < len; i++) {
if (this.listObj[objKeyArr[i]].uid === uid) {
delete this.listObj[objKeyArr[i]]
return
}
}
},
beforeUpload(file) {
const _self = this
const _URL = window.URL || window.webkitURL
const fileName = file.uid
this.listObj[fileName] = {}
return new Promise((resolve, reject) => {
const img = new Image()
img.src = _URL.createObjectURL(file)
img.onload = function() {
_self.listObj[fileName] = { hasSuccess: false, uid: file.uid, width: this.width, height: this.height }
}
resolve(true)
})
}
}
}
</script>
<style rel="stylesheet/scss" lang="scss" scoped>
.editor-slide-upload {
margin-bottom: 20px;
/deep/ .el-upload--picture-card {
width: 100%;
}
}
</style>

View file

@ -1,210 +0,0 @@
<template>
<div :class="{fullscreen:fullscreen}" class="tinymce-container editor-container">
<textarea :id="tinymceId" class="tinymce-textarea"/>
<div class="editor-custom-btn-container">
<editorImage color="#1890ff" class="editor-upload-btn" @successCBK="imageSuccessCBK"/>
</div>
</div>
</template>
<script>
import editorImage from './components/editorImage'
import plugins from './plugins'
import toolbar from './toolbar'
export default {
name: 'Tinymce',
components: { editorImage },
props: {
id: {
type: String,
default: function() {
return 'vue-tinymce-' + +new Date() + ((Math.random() * 1000).toFixed(0) + '')
}
},
value: {
type: String,
default: ''
},
toolbar: {
type: Array,
required: false,
default() {
return []
}
},
menubar: {
type: String,
default: 'file edit insert view format table'
},
height: {
type: Number,
required: false,
default: 360
}
},
data: function() {
return {
hasChange: false,
hasInit: false,
tinymceId: this.id,
fullscreen: false,
languageTypeList: {
'en': 'en',
'zh': 'zh_CN'
}
}
},
computed: {
language() {
return this.languageTypeList[this.$store.getters.language]
}
},
watch: {
value(val) {
if (!this.hasChange && this.hasInit) {
this.$nextTick(() =>
window.tinymce.get(this.tinymceId).setContent(val || ''))
}
},
language() {
this.destroyTinymce()
this.$nextTick(() => this.initTinymce())
}
},
mounted() {
this.initTinymce()
},
activated() {
this.initTinymce()
},
deactivated() {
this.destroyTinymce()
},
destroyed() {
this.destroyTinymce()
},
methods: {
initTinymce() {
const _this = this
window.tinymce.init({
language: this.language,
selector: `#${this.tinymceId}`,
height: this.height,
body_class: 'panel-body ',
object_resizing: false,
toolbar: this.toolbar.length > 0 ? this.toolbar : toolbar,
menubar: this.menubar,
plugins: plugins,
end_container_on_empty_block: true,
powerpaste_word_import: 'clean',
code_dialog_height: 450,
code_dialog_width: 1000,
advlist_bullet_styles: 'square',
advlist_number_styles: 'default',
imagetools_cors_hosts: ['www.tinymce.com', 'codepen.io'],
default_link_target: '_blank',
link_title: false,
nonbreaking_force_tab: true, // inserting nonbreaking space &nbsp; need Nonbreaking Space Plugin
init_instance_callback: editor => {
if (_this.value) {
editor.setContent(_this.value)
}
_this.hasInit = true
editor.on('NodeChange Change KeyUp SetContent', () => {
this.hasChange = true
this.$emit('input', editor.getContent())
})
},
setup(editor) {
editor.on('FullscreenStateChanged', (e) => {
_this.fullscreen = e.state
})
}
//
// images_dataimg_filter(img) {
// setTimeout(() => {
// const $image = $(img);
// $image.removeAttr('width');
// $image.removeAttr('height');
// if ($image[0].height && $image[0].width) {
// $image.attr('data-wscntype', 'image');
// $image.attr('data-wscnh', $image[0].height);
// $image.attr('data-wscnw', $image[0].width);
// $image.addClass('wscnph');
// }
// }, 0);
// return img
// },
// images_upload_handler(blobInfo, success, failure, progress) {
// progress(0);
// const token = _this.$store.getters.token;
// getToken(token).then(response => {
// const url = response.data.qiniu_url;
// const formData = new FormData();
// formData.append('token', response.data.qiniu_token);
// formData.append('key', response.data.qiniu_key);
// formData.append('file', blobInfo.blob(), url);
// upload(formData).then(() => {
// success(url);
// progress(100);
// })
// }).catch(err => {
// failure('')
// console.log(err);
// });
// },
})
},
destroyTinymce() {
const tinymce = window.tinymce.get(this.tinymceId)
if (this.fullscreen) {
tinymce.execCommand('mceFullScreen')
}
if (tinymce) {
tinymce.destroy()
}
},
setContent(value) {
window.tinymce.get(this.tinymceId).setContent(value)
},
getContent() {
window.tinymce.get(this.tinymceId).getContent()
},
imageSuccessCBK(arr) {
const _this = this
arr.forEach(v => {
window.tinymce.get(_this.tinymceId).insertContent(`<img class="wscnph" src="${v.url}" >`)
})
}
}
}
</script>
<style scoped>
.tinymce-container {
position: relative;
line-height: normal;
}
.tinymce-container>>>.mce-fullscreen {
z-index: 10000;
}
.tinymce-textarea {
visibility: hidden;
z-index: -1;
}
.editor-custom-btn-container {
position: absolute;
right: 4px;
top: 4px;
/*z-index: 2005;*/
}
.fullscreen .editor-custom-btn-container {
z-index: 10000;
position: fixed;
}
.editor-upload-btn {
display: inline-block;
}
</style>

View file

@ -1,7 +0,0 @@
// Any plugins you want to use has to be imported
// Detail plugins list see https://www.tinymce.com/docs/plugins/
// Custom builds see https://www.tinymce.com/download/custom-builds/
const plugins = ['advlist anchor autolink autosave code codesample colorpicker colorpicker contextmenu directionality emoticons fullscreen hr image imagetools insertdatetime link lists media nonbreaking noneditable pagebreak paste preview print save searchreplace spellchecker tabfocus table template textcolor textpattern visualblocks visualchars wordcount']
export default plugins

View file

@ -1,6 +0,0 @@
// Here is a list of the toolbar
// Detail list see https://www.tinymce.com/docs/advanced/editor-control-identifiers/#toolbarcontrols
const toolbar = ['searchreplace bold italic underline strikethrough alignleft aligncenter alignright outdent indent blockquote undo redo removeformat subscript superscript code codesample', 'hr bullist numlist link image charmap preview anchor pagebreak insertdatetime media table emoticons forecolor backcolor fullscreen']
export default toolbar

View file

@ -1,5 +1,5 @@
import Vue from 'vue'
import SvgIcon from '@/components/SvgIcon'// svg组件
import SvgIcon from '@/components/element-ui/SvgIcon'// svg组件
// register globally
Vue.component('svg-icon', SvgIcon)

View file

@ -10,7 +10,6 @@ export default {
icons: 'Icons',
components: 'Components',
componentIndex: 'Introduction',
tinymce: 'Tinymce',
markdown: 'Markdown',
jsonEditor: 'JSON Editor',
dndList: 'Dnd List',
@ -105,8 +104,7 @@ export default {
},
components: {
documentation: 'Documentation',
tinymceTips: 'Rich text editor is a core part of management system, but at the same time is a place with lots of problems. In the process of selecting rich texts, I also walked a lot of detours. The common rich text editors in the market are basically used, and the finally chose Tinymce. See documentation for more detailed rich text editor comparisons and introductions.',
dropzoneTips: 'Because my business has special needs, and has to upload images to qiniu, so instead of a third party, I chose encapsulate it by myself. It is very simple, you can see the detail code in @/components/Dropzone.',
dropzoneTips: 'Because my business has special needs, and has to upload images to qiniu, so instead of a third party, I chose encapsulate it by myself. It is very simple, you can see the detail code in @/components/element-ui/Dropzone.',
stickyTips: 'when the page is scrolled to the preset position will be sticky on the top.',
backToTopTips1: 'When the page is scrolled to the specified position, the Back to Top button appears in the lower right corner',
backToTopTips2: 'You can customize the style of the button, show / hide, height of appearance, height of the return. If you need a text prompt, you can use element-ui el-tooltip elements externally',
@ -177,6 +175,7 @@ export default {
external: 'external',
deactivated: 'deactivated',
active: 'active',
unconfirmed: 'unconfirmed',
actions: 'Actions',
activate: 'Activate',
deactivate: 'Deactivate',
@ -215,6 +214,8 @@ export default {
addTagForMultipleUsersConfirmation: 'Are you sure you want to apply tag to all selected users?',
removeTagFromMultipleUsersConfirmation: 'Are you sure you want to remove tag from all selected users?',
requirePasswordResetConfirmation: 'Are you sure you want to require password reset for all selected users?',
confirmAccountsConfirmation: 'Are you sure you want to confirm emails for all selected users?',
resendEmailConfirmation: 'Are you sure you want to resend confirmation email for all selected users?',
mailerMustBeEnabled: 'To require user\'s password reset you must enable mailer.',
ok: 'Okay',
completed: 'Completed',
@ -232,17 +233,33 @@ export default {
invalidNicknameError: 'Username can include "a-z", "A-Z" and "0-9" characters',
getPasswordResetToken: 'Get password reset token',
passwordResetTokenCreated: 'Password reset token was created',
accountCreated: 'New account was created!'
accountCreated: 'New account was created!',
unconfirmedEmail: 'User didn\'t confirm the email',
confirmAccount: 'Confirm account',
confirmAccounts: 'Confirm accounts',
resendConfirmation: 'Resend confirmation email'
},
statuses: {
statuses: 'Statuses by instance',
instanceFilter: 'Instance filter',
loadMore: 'Load more',
noInstances: 'No other instances found'
},
userProfile: {
tags: 'Tags',
moderator: 'Moderator',
admin: 'Admin',
local: 'Local',
local: 'local',
external: 'external',
localUppercase: 'Local',
nickname: 'Nickname',
deactivated: 'Deactivated',
recentStatuses: 'Recent Statues',
showPrivateStatuses: 'Show private statuses'
showPrivateStatuses: 'Show private statuses',
roles: 'Roles',
activeUppercase: 'Active',
active: 'active',
deactivated: 'deactivated',
noStatuses: 'No statuses to show'
},
usersFilter: {
inputPlaceholder: 'Select filter',
@ -255,6 +272,7 @@ export default {
},
reports: {
reports: 'Reports',
groupedReports: 'Grouped reports',
reply: 'Reply',
from: 'From',
showNotes: 'Show notes',
@ -266,19 +284,35 @@ export default {
deleteCompleted: 'Delete comleted',
deleteCanceled: 'Delete canceled',
noNotes: 'No notes to display',
changeState: 'Change report state',
changeState: "Change report's state",
changeAllReports: 'Change all reports',
changeScope: 'Change scope',
moderateUser: 'Moderate user',
resolve: 'Resolve',
reopen: 'Reopen',
close: 'Close',
resolveAll: 'Resolve all',
reopenAll: 'Reopen all',
closeAll: 'Close all',
addSensitive: 'Add Sensitive flag',
removeSensitive: 'Remove Sensitive flag',
public: 'Make status public',
private: 'Make status private',
unlisted: 'Make status unlisted',
sensitive: 'Sensitive',
deleteStatus: 'Delete status'
deleteStatus: 'Delete status',
reportOn: 'Report on',
reportsOn: 'Reports on',
id: 'ID',
account: 'Account',
actor: 'Actor',
actors: 'Actors',
content: 'Content',
reportedStatus: 'Reported status',
statusDeleted: 'This status has been deleted',
leaveNote: 'Leave a note',
postNote: 'Send',
deleteNote: 'Delete'
},
reportsFilter: {
inputPlaceholder: 'Select filter',
@ -365,7 +399,13 @@ export default {
deletePack: 'Delete pack',
downloadSharedPack: 'Download shared pack to current instance',
downloadAsOptional: 'Download as (optional)',
downloadPackArchive: 'Download pack archive'
downloadPackArchive: 'Download pack archive',
successfullyDownloaded: 'Successfully downloaded',
successfullyImported: 'Successfully imported',
nowNewPacksToImport: 'No new packs to import',
successfullyUpdated: 'Successfully updated',
metadatLowerCase: 'metadata',
files: 'files'
},
invites: {
inviteTokens: 'Invite tokens',

View file

@ -10,7 +10,6 @@ export default {
icons: 'Iconos',
components: 'Componentes',
componentIndex: 'Introducción',
tinymce: 'Tinymce',
markdown: 'Markdown',
jsonEditor: 'Editor JSON',
dndList: 'Lista Dnd',
@ -96,8 +95,7 @@ export default {
},
components: {
documentation: 'Documentación',
tinymceTips: 'Rich text editor is a core part of management system, but at the same time is a place with lots of problems. In the process of selecting rich texts, I also walked a lot of detours. The common rich text editors in the market are basically used, and the finally chose Tinymce. See documentation for more detailed rich text editor comparisons and introductions.',
dropzoneTips: 'Because my business has special needs, and has to upload images to qiniu, so instead of a third party, I chose encapsulate it by myself. It is very simple, you can see the detail code in @/components/Dropzone.',
dropzoneTips: 'Because my business has special needs, and has to upload images to qiniu, so instead of a third party, I chose encapsulate it by myself. It is very simple, you can see the detail code in @/components/element-ui/Dropzone.',
stickyTips: 'when the page is scrolled to the preset position will be sticky on the top.',
backToTopTips1: 'When the page is scrolled to the specified position, the Back to Top button appears in the lower right corner',
backToTopTips2: 'You can customize the style of the button, show / hide, height of appearance, height of the return. If you need a text prompt, you can use element-ui el-tooltip elements externally',

View file

@ -10,7 +10,6 @@ export default {
icons: 'Icònas',
components: 'Compausants',
componentIndex: 'Introduccion',
tinymce: 'Tinymce',
markdown: 'Markdown',
jsonEditor: 'JSON Editor',
dndList: 'Dnd List',
@ -97,8 +96,7 @@ export default {
},
components: {
documentation: 'Documentacion',
tinymceTips: 'Rich text editor is a core part of management system, but at the same time is a place with lots of problems. In the process of selecting rich texts, I also walked a lot of detours. The common rich text editors in the market are basically used, and the finally chose Tinymce. See documentation for more detailed rich text editor comparisons and introductions.',
dropzoneTips: 'Because my business has special needs, and has to upload images to qiniu, so instead of a third party, I chose encapsulate it by myself. It is very simple, you can see the detail code in @/components/Dropzone.',
dropzoneTips: 'Because my business has special needs, and has to upload images to qiniu, so instead of a third party, I chose encapsulate it by myself. It is very simple, you can see the detail code in @/components/element-ui/Dropzone.',
stickyTips: 'when the page is scrolled to the preset position will be sticky on the top.',
backToTopTips1: 'When the page is scrolled to the specified position, the Back to Top button appears in the lower right corner',
backToTopTips2: 'You can customize the style of the button, show / hide, height of appearance, height of the return. If you need a text prompt, you can use element-ui el-tooltip elements externally',

View file

@ -10,7 +10,6 @@ export default {
icons: '图标',
components: '组件',
componentIndex: '介绍',
tinymce: '富文本编辑器',
markdown: 'Markdown',
jsonEditor: 'JSON编辑器',
dndList: '列表拖拽',
@ -96,8 +95,7 @@ export default {
},
components: {
documentation: '文档',
tinymceTips: '富文本是管理后台一个核心的功能但同时又是一个有很多坑的地方。在选择富文本的过程中我也走了不少的弯路市面上常见的富文本都基本用过了最终权衡了一下选择了Tinymce。更详细的富文本比较和介绍见',
dropzoneTips: '由于我司业务有特殊需求,而且要传七牛 所以没用第三方,选择了自己封装。代码非常的简单,具体代码你可以在这里看到 @/components/Dropzone',
dropzoneTips: '由于我司业务有特殊需求,而且要传七牛 所以没用第三方,选择了自己封装。代码非常的简单,具体代码你可以在这里看到 @/components/element-ui/Dropzone',
stickyTips: '当页面滚动到预设的位置会吸附在顶部',
backToTopTips1: '页面滚动到指定位置会在右下角出现返回顶部按钮',
backToTopTips2: '可自定义按钮的样式、show/hide、出现的高度、返回的位置 如需文字提示可在外部使用Element的el-tooltip元素',

View file

@ -21,6 +21,20 @@ const settings = {
]
}
const statusesDisabled = disabledFeatures.includes('statuses')
const statuses = {
path: '/statuses',
component: Layout,
children: [
{
path: 'index',
component: () => import('@/views/statuses/index'),
name: 'Statuses',
meta: { title: 'Statuses', icon: 'form', noCache: true }
}
]
}
const reportsDisabled = disabledFeatures.includes('reports')
const reports = {
path: '/reports',
@ -126,6 +140,7 @@ export const asyncRouterMap = [
}
]
},
...(statusesDisabled ? [] : [statuses]),
...(reportsDisabled ? [] : [reports]),
...(invitesDisabled ? [] : [invites]),
...(moderationLogDisabled ? [] : [moderationLog]),

View file

@ -4,10 +4,12 @@ import app from './modules/app'
import errorLog from './modules/errorLog'
import moderationLog from './modules/moderationLog'
import invites from './modules/invites'
import peers from './modules/peers'
import permission from './modules/permission'
import relays from './modules/relays'
import reports from './modules/reports'
import settings from './modules/settings'
import status from './modules/status'
import tagsView from './modules/tagsView'
import user from './modules/user'
import userProfile from './modules/userProfile'
@ -23,10 +25,12 @@ const store = new Vuex.Store({
errorLog,
moderationLog,
invites,
peers,
permission,
relays,
reports,
settings,
status,
tagsView,
user,
userProfile,

View file

@ -8,7 +8,7 @@ import {
savePackMetadata,
importFromFS,
updatePackFile } from '@/api/emojiPacks'
import i18n from '@/lang'
import { Message } from 'element-ui'
import Vue from 'vue'
@ -44,34 +44,30 @@ const packs = {
}
},
actions: {
async SetLocalEmojiPacks({ commit, getters, state }) {
const { data } = await listPacks(getters.authHost)
commit('SET_LOCAL_PACKS', data)
async CreatePack({ getters }, { name }) {
await createPack(getters.authHost, getters.token, name)
},
async SetRemoteEmojiPacks({ commit, getters, state }, { remoteInstance }) {
const { data } = await listRemotePacks(getters.authHost, getters.token, remoteInstance)
commit('SET_REMOTE_PACKS', data)
async DeletePack({ getters }, { name }) {
await deletePack(getters.authHost, getters.token, name)
},
async DownloadFrom({ commit, getters, state }, { instanceAddress, packName, as }) {
async DownloadFrom({ getters }, { instanceAddress, packName, as }) {
const result = await downloadFrom(getters.authHost, instanceAddress, packName, as, getters.token)
if (result.data === 'ok') {
Message({
message: `Successfully downloaded ${packName}`,
message: `${i18n.t('settings.successfullyDownloaded')} ${packName}`,
type: 'success',
duration: 5 * 1000
})
}
},
async ReloadEmoji({ commit, getters, state }) {
await reloadEmoji(getters.authHost, getters.token)
},
async ImportFromFS({ commit, getters, state }) {
async ImportFromFS({ getters }) {
const result = await importFromFS(getters.authHost, getters.token)
if (result.status === 200) {
const message = result.data.length > 0 ? `Successfully imported ${result.data}` : 'No new packs to import'
const message = result.data.length > 0
? `${i18n.t('settings.successfullyImported')} ${result.data}`
: i18n.t('settings.nowNewPacksToImport')
Message({
message,
@ -80,17 +76,9 @@ const packs = {
})
}
},
async DeletePack({ commit, getters, state }, { name }) {
await deletePack(getters.authHost, getters.token, name)
async ReloadEmoji({ getters }) {
await reloadEmoji(getters.authHost, getters.token)
},
async CreatePack({ commit, getters, state }, { name }) {
await createPack(getters.authHost, getters.token, name)
},
async UpdateLocalPackVal({ commit, getters, state }, args) {
commit('UPDATE_LOCAL_PACK_VAL', args)
},
async SavePackMetadata({ commit, getters, state }, { packName }) {
const result =
await savePackMetadata(
@ -102,7 +90,7 @@ const packs = {
if (result.status === 200) {
Message({
message: `Successfully updated ${packName} metadata`,
message: `${i18n.t('settings.successfullyUpdated')} ${packName} ${i18n.t('settings.metadatLowerCase')}`,
type: 'success',
duration: 5 * 1000
})
@ -110,21 +98,32 @@ const packs = {
commit('UPDATE_LOCAL_PACK_PACK', { name: packName, pack: result.data })
}
},
async SetLocalEmojiPacks({ commit, getters }) {
const { data } = await listPacks(getters.authHost)
commit('SET_LOCAL_PACKS', data)
},
async SetRemoteEmojiPacks({ commit, getters }, { remoteInstance }) {
const { data } = await listRemotePacks(getters.authHost, getters.token, remoteInstance)
async UpdateAndSavePackFile({ commit, getters, state }, args) {
commit('SET_REMOTE_PACKS', data)
},
async UpdateAndSavePackFile({ commit, getters }, args) {
const result = await updatePackFile(getters.authHost, getters.token, args)
if (result.status === 200) {
const { packName } = args
Message({
message: `Successfully updated ${packName} files`,
message: `${i18n.t('settings.successfullyUpdated')} ${packName} ${i18n.t('settings.metadatLowerCase')}`,
type: 'success',
duration: 5 * 1000
})
commit('UPDATE_LOCAL_PACK_FILES', { name: packName, files: result.data })
}
},
async UpdateLocalPackVal({ commit }, args) {
commit('UPDATE_LOCAL_PACK_VAL', args)
}
}
}

View file

@ -1,4 +1,6 @@
import { generateInviteToken, inviteViaEmail, listInviteTokens, revokeToken } from '@/api/invites'
import { Message } from 'element-ui'
import i18n from '@/lang'
const invites = {
state: {
@ -25,18 +27,35 @@ const invites = {
commit('SET_LOADING', false)
},
async GenerateInviteToken({ commit, dispatch, getters }, { maxUse, expiresAt }) {
const { data } = await generateInviteToken(maxUse, expiresAt, getters.authHost, getters.token)
commit('SET_NEW_TOKEN', { token: data.token, maxUse: data.max_use, expiresAt: data.expires_at })
try {
const { data } = await generateInviteToken(maxUse, expiresAt, getters.authHost, getters.token)
commit('SET_NEW_TOKEN', { token: data.token, maxUse: data.max_use, expiresAt: data.expires_at })
} catch (_e) {
return
}
dispatch('FetchInviteTokens')
},
async InviteUserViaEmail({ commit, dispatch, getters }, { email, name }) {
await inviteViaEmail(email, name, getters.authHost, getters.token)
try {
await inviteViaEmail(email, name, getters.authHost, getters.token)
} catch (_e) {
return
}
Message({
message: i18n.t('invites.emailSent'),
type: 'success',
duration: 5 * 1000
})
},
RemoveNewToken({ commit }) {
commit('SET_NEW_TOKEN', {})
},
async RevokeToken({ commit, dispatch, getters }, token) {
await revokeToken(token, getters.authHost, getters.token)
try {
await revokeToken(token, getters.authHost, getters.token)
} catch (_e) {
return
}
dispatch('FetchInviteTokens')
}
}

View file

@ -0,0 +1,28 @@
import { fetchPeers } from '@/api/peers'
const peers = {
state: {
fetchedPeers: [],
loading: true
},
mutations: {
SET_PEERS: (state, peers) => {
state.fetchedPeers = peers
},
SET_LOADING: (state, status) => {
state.loading = status
}
},
actions: {
async FetchPeers({ commit, getters }) {
const peers = await fetchPeers(getters.authHost, getters.token)
commit('SET_PEERS', peers.data)
commit('SET_LOADING', false)
}
}
}
export default peers

View file

@ -28,15 +28,27 @@ const relays = {
commit('SET_RELAYS', response.data.relays)
commit('SET_LOADING', false)
},
async AddRelay({ commit, getters }, relay) {
async AddRelay({ commit, dispatch, getters }, relay) {
commit('ADD_RELAY', relay)
await addRelay(relay, getters.authHost, getters.token)
try {
await addRelay(relay, getters.authHost, getters.token)
} catch (_e) {
return
} finally {
dispatch('FetchRelays')
}
},
async DeleteRelay({ commit, getters }, relay) {
async DeleteRelay({ commit, dispatch, getters }, relay) {
commit('DELETE_RELAY', relay)
await deleteRelay(relay, getters.authHost, getters.token)
try {
await deleteRelay(relay, getters.authHost, getters.token)
} catch (_e) {
return
} finally {
dispatch('FetchRelays')
}
}
}
}

View file

@ -1,10 +1,13 @@
import { changeState, changeStatusScope, deleteStatus, fetchReports, filterReports } from '@/api/reports'
import { changeState, fetchReports, fetchGroupedReports, createNote, deleteNote } from '@/api/reports'
const reports = {
state: {
fetchedReports: [],
idOfLastReport: '',
page_limit: 5,
fetchedGroupedReports: [],
totalReportsCount: 0,
currentPage: 1,
pageSize: 50,
groupReports: false,
stateFilter: '',
loading: true
},
@ -15,63 +18,104 @@ const reports = {
SET_LOADING: (state, status) => {
state.loading = status
},
SET_PAGE: (state, page) => {
state.currentPage = page
},
SET_REPORTS: (state, reports) => {
state.fetchedReports = reports
},
SET_GROUPED_REPORTS: (state, reports) => {
state.fetchedGroupedReports = reports
},
SET_REPORTS_COUNT: (state, total) => {
state.totalReportsCount = total
},
SET_REPORTS_FILTER: (state, filter) => {
state.stateFilter = filter
},
SET_REPORTS_GROUPING: (state) => {
state.groupReports = !state.groupReports
}
},
actions: {
async ChangeReportState({ commit, getters, state }, { reportState, reportId }) {
const { data } = await changeState(reportState, reportId, getters.authHost, getters.token)
const updatedReports = state.fetchedReports.map(report => report.id === reportId ? data : report)
commit('SET_REPORTS', updatedReports)
},
async ChangeStatusScope({ commit, getters, state }, { statusId, isSensitive, visibility, reportId }) {
const { data } = await changeStatusScope(statusId, isSensitive, visibility, getters.authHost, getters.token)
async ChangeReportState({ commit, getters, state }, reportsData) {
changeState(reportsData, getters.authHost, getters.token)
const updatedReports = state.fetchedReports.map(report => {
if (report.id === reportId) {
const statuses = report.statuses.map(status => status.id === statusId ? data : status)
return { ...report, statuses }
} else {
return report
}
const updatedReportsIds = reportsData.map(({ id }) => id)
return updatedReportsIds.includes(report.id) ? { ...report, state: reportsData[0].state } : report
})
const updatedGroupedReports = state.fetchedGroupedReports.map(group => {
const updatedReportsIds = reportsData.map(({ id }) => id)
const updatedReports = group.reports.map(report => updatedReportsIds.includes(report.id) ? { ...report, state: reportsData[0].state } : report)
return { ...group, reports: updatedReports }
})
commit('SET_REPORTS', updatedReports)
commit('SET_GROUPED_REPORTS', updatedGroupedReports)
},
ClearFetchedReports({ commit }) {
commit('SET_REPORTS', [])
commit('SET_LAST_REPORT_ID', '')
},
async DeleteStatus({ commit, getters, state }, { statusId, reportId }) {
deleteStatus(statusId, getters.authHost, getters.token)
const updatedReports = state.fetchedReports.map(report => {
if (report.id === reportId) {
const statuses = report.statuses.filter(status => status.id !== statusId)
return { ...report, statuses }
} else {
return report
}
})
commit('SET_REPORTS', updatedReports)
},
async FetchReports({ commit, getters, state }) {
async FetchReports({ commit, getters, state }, page) {
commit('SET_LOADING', true)
const { data } = await fetchReports(state.stateFilter, page, state.pageSize, getters.authHost, getters.token)
const response = state.stateFilter.length === 0
? await fetchReports(state.page_limit, state.idOfLastReport, getters.authHost, getters.token)
: await filterReports(state.stateFilter, state.page_limit, state.idOfLastReport, getters.authHost, getters.token)
commit('SET_REPORTS', data.reports)
commit('SET_REPORTS_COUNT', data.total)
commit('SET_PAGE', page)
commit('SET_LOADING', false)
},
async FetchGroupedReports({ commit, getters }) {
commit('SET_LOADING', true)
const { data } = await fetchGroupedReports(getters.authHost, getters.token)
const reports = state.fetchedReports.concat(response.data.reports)
const id = reports.length > 0 ? reports[reports.length - 1].id : state.idOfLastReport
commit('SET_REPORTS', reports)
commit('SET_LAST_REPORT_ID', id)
commit('SET_GROUPED_REPORTS', data.reports)
commit('SET_LOADING', false)
},
SetFilter({ commit }, filter) {
commit('SET_REPORTS_FILTER', filter)
},
ToggleReportsGrouping({ commit }) {
commit('SET_REPORTS_GROUPING')
},
CreateReportNote({ commit, getters, state, rootState }, { content, reportID }) {
createNote(content, reportID, getters.authHost, getters.token)
const optimisticNote = {
user: {
avatar: rootState.user.avatar,
display_name: rootState.user.name,
url: `${rootState.user.authHost}/${rootState.user.name}`,
acct: rootState.user.name
},
content: content,
created_at: new Date().getTime()
}
const updatedReports = state.fetchedReports.map(report => {
if (report.id === reportID) {
report.notes = [...report.notes, optimisticNote]
}
return report
})
commit('SET_REPORTS', updatedReports)
},
DeleteReportNote({ commit, getters, state }, { noteID, reportID }) {
deleteNote(noteID, reportID, getters.authHost, getters.token)
const updatedReports = state.fetchedReports.map(report => {
if (report.id === reportID) {
report.notes = report.notes.filter(note => note.id !== noteID)
}
return report
})
commit('SET_REPORTS', updatedReports)
}
}
}

View file

@ -0,0 +1,57 @@
import { changeStatusScope, deleteStatus, fetchStatusesByInstance } from '@/api/status'
const status = {
state: {
fetchedStatuses: [],
loading: false
},
mutations: {
SET_STATUSES: (state, statuses) => {
state.fetchedStatuses = statuses
},
PUSH_STATUSES: (state, statuses) => {
state.fetchedStatuses = [...state.fetchedStatuses, ...statuses]
},
SET_LOADING: (state, status) => {
state.loading = status
}
},
actions: {
async ChangeStatusScope({ dispatch, getters }, { statusId, isSensitive, visibility, reportCurrentPage, userId, godmode }) {
await changeStatusScope(statusId, isSensitive, visibility, getters.authHost, getters.token)
if (reportCurrentPage !== 0) { // called from Reports
dispatch('FetchReports', reportCurrentPage)
} else if (userId.length > 0) { // called from User profile
dispatch('FetchUserStatuses', { userId, godmode })
} else { // called from GroupedReports
dispatch('FetchGroupedReports')
}
},
async DeleteStatus({ dispatch, getters }, { statusId, reportCurrentPage, userId, godmode }) {
await deleteStatus(statusId, getters.authHost, getters.token)
if (reportCurrentPage !== 0) { // called from Reports
dispatch('FetchReports', reportCurrentPage)
} else if (userId.length > 0) { // called from User profile
dispatch('FetchUserStatuses', { userId, godmode })
} else { // called from GroupedReports
dispatch('FetchGroupedReports')
}
},
async FetchStatusesByInstance({ commit, getters }, { instance, page, pageSize }) {
commit('SET_LOADING', true)
const statuses = await fetchStatusesByInstance(instance, getters.authHost, getters.token, pageSize, page)
commit('SET_STATUSES', statuses.data)
commit('SET_LOADING', false)
},
async FetchStatusesPageByInstance({ commit, getters }, { instance, page, pageSize }) {
commit('SET_LOADING', true)
const statuses = await fetchStatusesByInstance(instance, getters.authHost, getters.token, pageSize, page)
commit('PUSH_STATUSES', statuses.data)
commit('SET_LOADING', false)
}
}
}
export default status

View file

@ -2,33 +2,42 @@ import { fetchUser, fetchUserStatuses } from '@/api/users'
const userProfile = {
state: {
statuses: [],
statusesLoading: true,
user: {},
loading: true,
statuses: []
userProfileLoading: true
},
mutations: {
SET_STATUSES: (state, statuses) => {
state.statuses = statuses
},
SET_STATUSES_LOADING: (state, status) => {
state.statusesLoading = status
},
SET_USER: (state, user) => {
state.user = user
},
SET_LOADING: (state, status) => {
state.loading = status
},
SET_STATUSES: (state, statuses) => {
state.statuses = statuses
SET_USER_PROFILE_LOADING: (state, status) => {
state.userProfileLoading = status
}
},
actions: {
async FetchData({ commit, getters }, { id, godmode }) {
commit('SET_LOADING', true)
const [userResponse, statusesResponse] = await Promise.all([
fetchUser(id, getters.authHost, getters.token),
fetchUserStatuses(id, getters.authHost, godmode, getters.token)
])
async FetchUserProfile({ commit, dispatch, getters }, { userId, godmode }) {
commit('SET_USER_PROFILE_LOADING', true)
const userResponse = await fetchUser(userId, getters.authHost, getters.token)
commit('SET_USER', userResponse.data)
commit('SET_STATUSES', statusesResponse.data)
commit('SET_LOADING', false)
commit('SET_USER_PROFILE_LOADING', false)
dispatch('FetchUserStatuses', { userId, godmode })
},
async FetchUserStatuses({ commit, getters }, { userId, godmode }) {
commit('SET_STATUSES_LOADING', true)
const statuses = await fetchUserStatuses(userId, getters.authHost, godmode, getters.token)
commit('SET_STATUSES', statuses.data)
commit('SET_STATUSES_LOADING', false)
}
}
}

View file

@ -1,3 +1,5 @@
import { Message } from 'element-ui'
import i18n from '@/lang'
import {
activateUsers,
addRight,
@ -10,7 +12,9 @@ import {
searchUsers,
tagUser,
untagUser,
requirePasswordReset
requirePasswordReset,
confirmUserEmail,
resendConfirmationEmail
} from '@/api/users'
const users = {
@ -43,6 +47,10 @@ const users = {
return acc.filter(u => u.id !== user.id)
}, state.fetchedUsers)
if (state.fetchedUsers.length === 0) {
return
}
state.fetchedUsers = [...usersWithoutSwapped, ...users].sort((a, b) =>
a.nickname.localeCompare(b.nickname)
)
@ -78,8 +86,14 @@ const users = {
commit('SWAP_USERS', updatedUsers)
const usersNicknames = users.map(user => user.nickname)
await activateUsers(usersNicknames, getters.authHost, getters.token)
dispatch('SearchUsers', { query: state.searchQuery, page: state.currentPage })
try {
await activateUsers(usersNicknames, getters.authHost, getters.token)
} catch (_e) {
return
} finally {
dispatch('SearchUsers', { query: state.searchQuery, page: state.currentPage })
}
dispatch('SuccessMessage')
},
async AddRight({ commit, dispatch, getters, state }, { users, right }) {
const updatedUsers = users.map(user => {
@ -88,8 +102,14 @@ const users = {
commit('SWAP_USERS', updatedUsers)
const usersNicknames = users.map(user => user.nickname)
await addRight(usersNicknames, right, getters.authHost, getters.token)
dispatch('SearchUsers', { query: state.searchQuery, page: state.currentPage })
try {
await addRight(usersNicknames, right, getters.authHost, getters.token)
} catch (_e) {
return
} finally {
dispatch('SearchUsers', { query: state.searchQuery, page: state.currentPage })
}
dispatch('SuccessMessage')
},
async AddTag({ commit, dispatch, getters, state }, { users, tag }) {
const updatedUsers = users.map(user => {
@ -98,16 +118,28 @@ const users = {
commit('SWAP_USERS', updatedUsers)
const nicknames = users.map(user => user.nickname)
await tagUser(nicknames, [tag], getters.authHost, getters.token)
dispatch('SearchUsers', { query: state.searchQuery, page: state.currentPage })
try {
await tagUser(nicknames, [tag], getters.authHost, getters.token)
} catch (_e) {
return
} finally {
dispatch('SearchUsers', { query: state.searchQuery, page: state.currentPage })
}
dispatch('SuccessMessage')
},
async ClearFilters({ commit, dispatch, state }) {
commit('CLEAR_USERS_FILTERS')
dispatch('SearchUsers', { query: state.searchQuery, page: 1 })
},
async CreateNewAccount({ dispatch, getters, state }, { nickname, email, password }) {
await createNewAccount(nickname, email, password, getters.authHost, getters.token)
dispatch('SearchUsers', { query: state.searchQuery, page: state.currentPage })
try {
await createNewAccount(nickname, email, password, getters.authHost, getters.token)
} catch (_e) {
return
} finally {
dispatch('SearchUsers', { query: state.searchQuery, page: state.currentPage })
}
dispatch('SuccessMessage')
},
async DeactivateUsers({ commit, dispatch, getters, state }, users) {
const updatedUsers = users.map(user => {
@ -116,8 +148,39 @@ const users = {
commit('SWAP_USERS', updatedUsers)
const usersNicknames = users.map(user => user.nickname)
await deactivateUsers(usersNicknames, getters.authHost, getters.token)
dispatch('SearchUsers', { query: state.searchQuery, page: state.currentPage })
try {
await deactivateUsers(usersNicknames, getters.authHost, getters.token)
} catch (_e) {
return
} finally {
dispatch('SearchUsers', { query: state.searchQuery, page: state.currentPage })
}
dispatch('SuccessMessage')
},
async ConfirmUsersEmail({ commit, dispatch, getters, state }, users) {
const updatedUsers = users.map(user => {
return { ...user, confirmation_pending: false }
})
commit('SWAP_USERS', updatedUsers)
const usersNicknames = users.map(user => user.nickname)
try {
await confirmUserEmail(usersNicknames, getters.authHost, getters.token)
} catch (_e) {
return
} finally {
dispatch('SearchUsers', { query: state.searchQuery, page: state.currentPage })
}
dispatch('SuccessMessage')
},
async ResendConfirmationEmail({ dispatch, getters }, users) {
const usersNicknames = users.map(user => user.nickname)
try {
await resendConfirmationEmail(usersNicknames, getters.authHost, getters.token)
} catch (_e) {
return
}
dispatch('SuccessMessage')
},
async DeleteRight({ commit, dispatch, getters, state }, { users, right }) {
const updatedUsers = users.map(user => {
@ -126,19 +189,26 @@ const users = {
commit('SWAP_USERS', updatedUsers)
const usersNicknames = users.map(user => user.nickname)
await deleteRight(usersNicknames, right, getters.authHost, getters.token)
dispatch('SearchUsers', { query: state.searchQuery, page: state.currentPage })
try {
await deleteRight(usersNicknames, right, getters.authHost, getters.token)
} catch (_e) {
return
} finally {
dispatch('SearchUsers', { query: state.searchQuery, page: state.currentPage })
}
dispatch('SuccessMessage')
},
async DeleteUsers({ commit, getters, state }, users) {
async DeleteUsers({ commit, dispatch, getters, state }, users) {
const usersNicknames = users.map(user => user.nickname)
try {
await deleteUsers(usersNicknames, getters.authHost, getters.token)
} catch (_e) {
return
}
const deletedUsersIds = users.map(deletedUser => deletedUser.id)
const updatedUsers = state.fetchedUsers.filter(user => !deletedUsersIds.includes(user.id))
commit('SET_USERS', updatedUsers)
const usersNicknames = users.map(user => user.nickname)
await deleteUsers(usersNicknames, getters.authHost, getters.token)
},
async RequirePasswordReset({ getters }, user) {
await requirePasswordReset(user.nickname, getters.authHost, getters.token)
dispatch('SuccessMessage')
},
async FetchUsers({ commit, dispatch, getters, state }, { page }) {
commit('SET_LOADING', true)
@ -161,8 +231,22 @@ const users = {
commit('SWAP_USERS', updatedUsers)
const nicknames = users.map(user => user.nickname)
await untagUser(nicknames, [tag], getters.authHost, getters.token)
dispatch('SearchUsers', { query: state.searchQuery, page: state.currentPage })
try {
await untagUser(nicknames, [tag], getters.authHost, getters.token)
} catch (_e) {
return
} finally {
dispatch('SearchUsers', { query: state.searchQuery, page: state.currentPage })
}
dispatch('SuccessMessage')
},
async RequirePasswordReset({ dispatch, getters }, user) {
try {
await requirePasswordReset(user.nickname, getters.authHost, getters.token)
} catch (_e) {
return
}
dispatch('SuccessMessage')
},
async SearchUsers({ commit, dispatch, state, getters }, { query, page }) {
if (query.length === 0) {
@ -178,6 +262,12 @@ const users = {
loadUsers(commit, page, response.data)
}
},
SuccessMessage() {
Message.success({
message: i18n.t('users.completed'),
duration: 5 * 1000
})
},
async ToggleUsersFilter({ commit, dispatch, state }, filters) {
const defaultFilters = {
local: false,

View file

@ -11,6 +11,8 @@ body {
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif;
background: #FFF;
color: #000;
}
label {

View file

@ -5,7 +5,7 @@
</template>
<script>
import Chart from '@/components/Charts/keyboard'
import Chart from '@/components/element-ui/Charts/keyboard'
export default {
name: 'KeyboardChart',

View file

@ -5,7 +5,7 @@
</template>
<script>
import Chart from '@/components/Charts/lineMarker'
import Chart from '@/components/element-ui/Charts/lineMarker'
export default {
name: 'LineChart',

View file

@ -5,7 +5,7 @@
</template>
<script>
import Chart from '@/components/Charts/mixChart'
import Chart from '@/components/element-ui/Charts/mixChart'
export default {
name: 'MixChart',

View file

@ -28,8 +28,8 @@
<script>
import { mapGetters } from 'vuex'
import PanThumb from '@/components/PanThumb'
import Mallki from '@/components/TextHoverEffect/Mallki'
import PanThumb from '@/components/element-ui/PanThumb'
import Mallki from '@/components/element-ui/TextHoverEffect/Mallki'
export default {
components: { PanThumb, Mallki },

View file

@ -43,7 +43,7 @@
</template>
<script>
import GithubCorner from '@/components/GithubCorner'
import GithubCorner from '@/components/element-ui/GithubCorner'
import PanelGroup from './components/PanelGroup'
import LineChart from './components/LineChart'
import RaddarChart from './components/RaddarChart'

View file

@ -18,8 +18,8 @@
<script>
import { mapGetters } from 'vuex'
import PanThumb from '@/components/PanThumb'
import GithubCorner from '@/components/GithubCorner'
import PanThumb from '@/components/element-ui/PanThumb'
import GithubCorner from '@/components/element-ui/GithubCorner'
export default {
name: 'DashboardEditor',

View file

@ -7,7 +7,7 @@
</div>
</template>
<script>
import DropdownMenu from '@/components/Share/dropdownMenu'
import DropdownMenu from '@/components/element-ui/Share/dropdownMenu'
export default {
name: 'Documentation',

View file

@ -192,17 +192,8 @@ export default {
async inviteUserViaEmail() {
this.$refs['inviteUserForm'].validate(async(valid) => {
if (valid) {
try {
await this.$store.dispatch('InviteUserViaEmail', this.$data.inviteUserForm)
} catch (_e) {
return
} finally {
this.closeDialogWindow()
}
this.$message({
type: 'success',
message: this.$t('invites.emailSent')
})
await this.$store.dispatch('InviteUserViaEmail', this.$data.inviteUserForm)
this.closeDialogWindow()
} else {
this.$message({
type: 'error',

View file

@ -18,7 +18,7 @@
<script>
import { mapGetters } from 'vuex'
import Hamburger from '@/components/Hamburger'
import Hamburger from '@/components/element-ui/Hamburger'
export default {
components: {

View file

@ -26,7 +26,7 @@
</template>
<script>
import ScrollPane from '@/components/ScrollPane'
import ScrollPane from '@/components/element-ui/ScrollPane'
import { generateTitle } from '@/utils/i18n'
import path from 'path'

View file

@ -41,6 +41,7 @@
<el-button :loading="loading" class="login-button" type="primary" @click.native.prevent="handleLogin">
{{ $t('login.logIn') }}
</el-button>
<!-- Note: PleromaFE login feature relies on admin scope presence in PleromaFE token (older versions of PleromaFE don't support it) -->
<el-button v-if="pleromaFEToken" :loading="loadingPleromaFE" class="login-button" type="primary" @click.native.prevent="handlePleromaFELogin">
{{ $t('login.logInViaPleromaFE') }}
</el-button>
@ -49,7 +50,7 @@
</template>
<script>
import SvgIcon from '@/components/SvgIcon'
import SvgIcon from '@/components/element-ui/SvgIcon'
import localforage from 'localforage'
import _ from 'lodash'
import i18n from '@/lang'

View file

@ -0,0 +1,143 @@
<template>
<el-timeline class="timeline">
<el-timeline-item
v-for="groupedReport in groupedReports"
:key="groupedReport.id"
:timestamp="parseTimestamp(groupedReport.date)"
placement="top"
class="timeline-item-container">
<el-card class="grouped-report">
<div class="header-container">
<div>
<h3 class="report-title">{{ $t('reports.reportsOn') }} {{ groupedReport.account.display_name }}</h3>
</div>
<div>
<el-dropdown trigger="click">
<el-button plain size="small" icon="el-icon-edit">{{ $t('reports.changeAllReports') }}<i class="el-icon-arrow-down el-icon--right"/></el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item @click.native="changeAllReports('resolved', groupedReport.reports)">{{ $t('reports.resolveAll') }}</el-dropdown-item>
<el-dropdown-item @click.native="changeAllReports('open', groupedReport.reports)">{{ $t('reports.reopenAll') }}</el-dropdown-item>
<el-dropdown-item @click.native="changeAllReports('closed', groupedReport.reports)">{{ $t('reports.closeAll') }}</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<moderate-user-dropdown :account="groupedReport.account"/>
</div>
</div>
<div>
<div class="line"/>
<span class="report-row-key">{{ $t('reports.account') }}:</span>
<img
:src="groupedReport.account.avatar"
alt="avatar"
class="avatar-img">
<a :href="groupedReport.account.url" target="_blank">
<span>{{ groupedReport.account.nickname }}</span>
</a>
</div>
<div>
<div class="line"/>
<span class="report-row-key">{{ $t('reports.actors') }}:</span>
<span v-for="(actor, index) in groupedReport.actors" :key="actor.id">
<a :href="actor.url" target="_blank">
{{ actor.acct }}<span v-if="index < groupedReport.actors.length - 1">, </span>
</a>
</span>
</div>
<div v-if="groupedReport.status">
<div class="line"/>
<span class="report-row-key">{{ $t('reports.reportedStatus') }}:</span>
<status :status="groupedReport.status" class="reported-status"/>
</div>
<div v-if="groupedReport.reports">
<el-collapse>
<el-collapse-item :title="$t('reports.reports')">
<report-card :reports="groupedReport.reports"/>
</el-collapse-item>
</el-collapse>
</div>
</el-card>
</el-timeline-item>
</el-timeline>
</template>
<script>
import moment from 'moment'
import ModerateUserDropdown from './ModerateUserDropdown'
import ReportCard from './ReportCard'
import Status from '@/components/Status'
export default {
name: 'Report',
components: { ModerateUserDropdown, ReportCard, Status },
props: {
groupedReports: {
type: Array,
required: true
}
},
methods: {
changeAllReports(reportState, groupOfReports) {
const reportsData = groupOfReports.map(report => {
return { id: report.id, state: reportState }
})
this.$store.dispatch('ChangeReportState', reportsData)
},
parseTimestamp(timestamp) {
return moment(timestamp).format('L HH:mm')
}
}
}
</script>
<style rel='stylesheet/scss' lang='scss'>
a {
text-decoration: underline;
}
.avatar-img {
vertical-align: bottom;
width: 15px;
height: 15px;
margin-left: 5px;
}
.el-card__body {
padding: 17px;
}
.el-card__header {
background-color: #FAFAFA;
padding: 10px 20px;
}
.el-icon-arrow-right {
margin-right: 6px;
}
.header-container {
display: flex;
justify-content: space-between;
align-items: baseline;
height: 40px;
}
.line {
width: 100%;
height: 0;
border: 0.5px solid #EBEEF5;
margin: 15px 0 15px;
}
.report-title {
margin: 0;
}
.report-row-key {
font-size: 14px;
font-weight: 500;
}
.reported-status {
margin-top: 15px;
}
@media
only screen and (max-width: 760px),
(min-device-width: 768px) and (max-device-width: 1024px) {
.header-container {
display: flex;
flex-direction: column;
height: 80px;
}
}
</style>

View file

@ -0,0 +1,86 @@
<template>
<el-dropdown trigger="click">
<el-button plain size="small" icon="el-icon-files">{{ $t('reports.moderateUser') }}
<i class="el-icon-arrow-down el-icon--right"/>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item
v-if="showDeactivatedButton(account)"
@click.native="handleDeactivation(account)">
{{ account.deactivated ? $t('users.activateAccount') : $t('users.deactivateAccount') }}
</el-dropdown-item>
<el-dropdown-item
v-if="showDeactivatedButton(account.id)"
@click.native="handleDeletion(account.id)">
{{ $t('users.deleteAccount') }}
</el-dropdown-item>
<el-dropdown-item
:divided="true"
:class="{ 'active-tag': account.tags.includes('force_nsfw') }"
@click.native="toggleTag(account, 'force_nsfw')">
{{ $t('users.forceNsfw') }}
<i v-if="account.tags.includes('force_nsfw')" class="el-icon-check"/>
</el-dropdown-item>
<el-dropdown-item
:class="{ 'active-tag': account.tags.includes('strip_media') }"
@click.native="toggleTag(account, 'strip_media')">
{{ $t('users.stripMedia') }}
<i v-if="account.tags.includes('strip_media')" class="el-icon-check"/>
</el-dropdown-item>
<el-dropdown-item
:class="{ 'active-tag': account.tags.includes('force_unlisted') }"
@click.native="toggleTag(account, 'force_unlisted')">
{{ $t('users.forceUnlisted') }}
<i v-if="account.tags.includes('force_unlisted')" class="el-icon-check"/>
</el-dropdown-item>
<el-dropdown-item
:class="{ 'active-tag': account.tags.includes('sandbox') }"
@click.native="toggleTag(account, 'sandbox')">
{{ $t('users.sandbox') }}
<i v-if="account.tags.includes('sandbox')" class="el-icon-check"/>
</el-dropdown-item>
<el-dropdown-item
v-if="account.local"
:class="{ 'active-tag': account.tags.includes('disable_remote_subscription') }"
@click.native="toggleTag(account, 'disable_remote_subscription')">
{{ $t('users.disableRemoteSubscription') }}
<i v-if="account.tags.includes('disable_remote_subscription')" class="el-icon-check"/>
</el-dropdown-item>
<el-dropdown-item
v-if="account.local"
:class="{ 'active-tag': account.tags.includes('disable_any_subscription') }"
@click.native="toggleTag(account, 'disable_any_subscription')">
{{ $t('users.disableAnySubscription') }}
<i v-if="account.tags.includes('disable_any_subscription')" class="el-icon-check"/>
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
<script>
export default {
name: 'ModerateUserDropdown',
props: {
account: {
type: Object,
required: true
}
},
methods: {
handleDeactivation({ nickname }) {
this.$store.dispatch('ToggleUserActivation', nickname)
},
handleDeletion(user) {
this.$store.dispatch('DeleteUser', user)
},
showDeactivatedButton(id) {
return this.$store.state.user.id !== id
},
toggleTag(user, tag) {
user.tags.includes(tag)
? this.$store.dispatch('RemoveTag', { users: [user], tag })
: this.$store.dispatch('AddTag', { users: [user], tag })
}
}
}
</script>

View file

@ -0,0 +1,119 @@
<template>
<el-card class="note-card">
<div slot="header">
<div class="note-header">
<div class="note-actor-container">
<div class="note-actor">
<img :src="note.user.avatar" class="note-avatar-img">
<h3 class="note-actor-name">{{ note.user.display_name }}</h3>
</div>
<a :href="note.user.url" target="_blank">
@{{ note.user.acct }}
</a>
</div>
<div>
<el-popconfirm
title="Are you sure to delete this?"
confirm-button-text="Yes"
cancel-button-text="No"
@onConfirm="handleNoteDeletion(note.id, report.id)">
<el-button slot="reference" size="mini">
{{ $t('reports.deleteNote') }}
</el-button>
</el-popconfirm>
</div>
</div>
</div>
<div class="note-body">
<span class="note-content" v-html="note.content"/>
{{ parseTimestamp(note.created_at) }}
</div>
</el-card>
</template>
<script>
import moment from 'moment'
export default {
name: 'NoteCard',
props: {
report: {
type: Object,
required: true
},
note: {
type: Object,
required: true
}
},
methods: {
parseTimestamp(timestamp) {
return moment(timestamp).format('YYYY-MM-DD HH:mm')
},
handleNoteDeletion(noteID, reportID) {
this.$store.dispatch('DeleteReportNote', { noteID, reportID })
}
}
}
</script>
<style rel='stylesheet/scss' lang='scss'>
a {
text-decoration: underline;
}
.el-icon-arrow-right {
margin-right: 6px;
}
.note-header {
display: flex;
justify-content: space-between;
align-items: baseline;
height: 40px;
}
.note-actor {
display: flex;
align-items: center;
}
.note-actor-name {
margin: 0;
height: 22px;
}
.note-avatar-img {
width: 15px;
height: 15px;
margin-right: 5px;
}
.note-body {
display: flex;
flex-direction: column;
}
.note-card {
margin-bottom: 15px;
}
.note-content {
font-size: 15px;
}
.note-header {
display: flex;
justify-content: space-between;
}
@media
only screen and (max-width: 760px),
(min-device-width: 768px) and (max-device-width: 1024px) {
.el-card__header {
padding: 10px 17px;
}
.note-header {
display: flex;
flex-direction: column;
height: 80px;
}
.note-actor-container {
margin-bottom: 5px;
}
.note-header {
display: flex;
flex-direction: column;
}
}
</style>

View file

@ -0,0 +1,287 @@
<template>
<div>
<el-timeline class="timeline">
<el-timeline-item
v-for="report in reports"
:timestamp="parseTimestamp(report.created_at)"
:key="report.id"
placement="top"
class="timeline-item-container">
<el-card>
<div class="header-container">
<div>
<h3 class="report-title">{{ $t('reports.reportOn') }} {{ report.account.display_name }}</h3>
<h5 class="id">{{ $t('reports.id') }}: {{ report.id }}</h5>
</div>
<div>
<el-tag :type="getStateType(report.state)" size="large">{{ capitalizeFirstLetter(report.state) }}</el-tag>
<el-dropdown trigger="click">
<el-button plain size="small" icon="el-icon-edit">{{ $t('reports.changeState') }}<i class="el-icon-arrow-down el-icon--right"/></el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item v-if="report.state !== 'resolved'" @click.native="changeReportState('resolved', report.id)">{{ $t('reports.resolve') }}</el-dropdown-item>
<el-dropdown-item v-if="report.state !== 'open'" @click.native="changeReportState('open', report.id)">{{ $t('reports.reopen') }}</el-dropdown-item>
<el-dropdown-item v-if="report.state !== 'closed'" @click.native="changeReportState('closed', report.id)">{{ $t('reports.close') }}</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<moderate-user-dropdown :account="report.account"/>
</div>
</div>
<div>
<div class="line"/>
<span class="report-row-key">{{ $t('reports.account') }}:</span>
<img
:src="report.account.avatar"
alt="avatar"
class="avatar-img">
<a :href="report.account.url" target="_blank" class="account">
<span>{{ report.account.acct }}</span>
</a>
</div>
<div v-if="report.content.length > 0">
<div class="line"/>
<span class="report-row-key">{{ $t('reports.content') }}:
<span>{{ report.content }}</span>
</span>
</div>
<div>
<div class="line"/>
<span class="report-row-key">{{ $t('reports.actor') }}:</span>
<img
:src="report.actor.avatar"
alt="avatar"
class="avatar-img">
<a :href="report.actor.url" target="_blank" class="account">
<span>{{ report.actor.acct }}</span>
</a>
</div>
<div v-if="report.statuses.length > 0" class="statuses">
<el-collapse>
<el-collapse-item :title="getStatusesTitle(report.statuses)">
<div v-for="status in report.statuses" :key="status.id">
<status :status="status" :page="currentPage"/>
</div>
</el-collapse-item>
</el-collapse>
</div>
<div class="report-notes">
<el-collapse>
<el-collapse-item :title="getNotesTitle(report.notes)">
<note-card v-for="(note, index) in report.notes" :key="index" :note="note" :report="report"/>
</el-collapse-item>
</el-collapse>
<div class="report-note-form">
<el-input
v-model="notes[report.id]"
:placeholder="$t('reports.leaveNote')"
type="textarea"
rows="3"/>
<div class="report-post-note">
<el-button @click="handleNewNote(report.id)">{{ $t('reports.postNote') }}</el-button>
</div>
</div>
</div>
</el-card>
</el-timeline-item>
</el-timeline>
<div v-if="!loading" class="reports-pagination">
<el-pagination
:total="totalReportsCount"
:current-page="currentPage"
:page-size="pageSize"
background
layout="prev, pager, next"
@current-change="handlePageChange"
/>
</div>
</div>
</template>
<script>
import moment from 'moment'
import NoteCard from './NoteCard'
import Status from '@/components/Status'
import ModerateUserDropdown from './ModerateUserDropdown'
export default {
name: 'Report',
components: { Status, ModerateUserDropdown, NoteCard },
props: {
reports: {
type: Array,
required: true
}
},
data() {
return {
notes: {}
}
},
computed: {
loading() {
return this.$store.state.reports.loading
},
pageSize() {
return this.$store.state.reports.pageSize
},
totalReportsCount() {
return this.$store.state.reports.totalReportsCount
},
currentPage() {
return this.$store.state.reports.currentPage
}
},
methods: {
changeReportState(state, id) {
this.$store.dispatch('ChangeReportState', [{ state, id }])
},
capitalizeFirstLetter(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
},
getStateType(state) {
switch (state) {
case 'closed':
return 'info'
case 'resolved':
return 'success'
default:
return 'primary'
}
},
getStatusesTitle(statuses) {
return `Reported statuses: ${statuses.length} item(s)`
},
getNotesTitle(notes = []) {
return `Notes: ${notes.length} item(s)`
},
handlePageChange(page) {
this.$store.dispatch('FetchReports', page)
},
parseTimestamp(timestamp) {
return moment(timestamp).format('L HH:mm')
},
handleNewNote(reportID) {
this.$store.dispatch('CreateReportNote', { content: this.notes[reportID], reportID })
this.notes[reportID] = ''
}
}
}
</script>
<style rel='stylesheet/scss' lang='scss'>
.account {
text-decoration: underline;
}
.avatar-img {
vertical-align: bottom;
width: 15px;
height: 15px;
margin-left: 5px;
}
.el-card__body {
padding: 17px;
}
.el-card__header {
background-color: #FAFAFA;
padding: 10px 20px;
}
.el-collapse {
border-bottom: none;
}
.el-collapse-item__header {
height: 46px;
font-size: 14px;
}
.el-collapse-item__content {
padding-bottom: 7px;
}
.el-icon-arrow-right {
margin-right: 6px;
}
.el-icon-close {
padding: 10px 5px 10px 10px;
cursor: pointer;
}
h4 {
margin: 0;
height: 17px;
}
.header-container {
display: flex;
justify-content: space-between;
align-items: baseline;
height: 40px;
}
.id {
color: gray;
margin-top: 6px;
}
.line {
width: 100%;
height: 0;
border: 0.5px solid #EBEEF5;
margin: 15px 0 15px;
}
.new-note {
p {
font-size: 14px;
font-weight: 500;
height: 17px;
margin: 13px 0 7px;
}
}
.note {
box-shadow: 0 2px 5px 0 rgba(0,0,0,.1);
margin-bottom: 10px;
}
.no-notes {
font-style: italic;
color: gray;
}
.report-row-key {
font-size: 14px;
font-weight: 500;
}
.report-row-key {
font-size: 14px;
}
.report-title {
margin: 0;
}
.report-note-form {
margin: 15px 0 0 0;
}
.report-post-note {
margin: 5px 0 0 0;
text-align: right;
}
.reports-pagination {
margin: 25px 0;
text-align: center;
}
.statuses {
margin-top: 15px;
}
.submit-button {
display: block;
margin: 7px 0 17px auto;
}
.timestamp {
margin: 0;
font-style: italic;
color: gray;
}
@media
only screen and (max-width: 760px),
(min-device-width: 768px) and (max-device-width: 1024px) {
.timeline-item-container {
.header-container {
display: flex;
flex-direction: column;
height: 80px;
}
.id {
margin: 6px 0 0 0;
}
}
}
</style>

View file

@ -0,0 +1,130 @@
<template>
<div>
<el-card v-for="report in reports" :key="report.id" class="report-card">
<div slot="header">
<div class="report-header">
<div class="report-actor-container">
<div class="report-actor">
<img :src="report.actor.avatar" class="report-avatar-img">
<h3 class="report-actor-name">{{ report.actor.display_name }}</h3>
</div>
<a :href="report.actor.url" target="_blank">
@{{ report.actor.acct }}
</a>
</div>
<div>
<el-tag :type="getStateType(report.state)" size="large">{{ capitalizeFirstLetter(report.state) }}</el-tag>
<el-dropdown trigger="click">
<el-button plain size="small" icon="el-icon-edit">{{ $t('reports.changeState') }}<i class="el-icon-arrow-down el-icon--right"/></el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item v-if="report.state !== 'resolved'" @click.native="changeReportState('resolved', report.id)">{{ $t('reports.resolve') }}</el-dropdown-item>
<el-dropdown-item v-if="report.state !== 'open'" @click.native="changeReportState('open', report.id)">{{ $t('reports.reopen') }}</el-dropdown-item>
<el-dropdown-item v-if="report.state !== 'closed'" @click.native="changeReportState('closed', report.id)">{{ $t('reports.close') }}</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
</div>
<div class="report-body">
<span class="report-content" v-html="report.content"/>
{{ parseTimestamp(report.created_at) }}
</div>
</el-card>
</div>
</template>
<script>
import moment from 'moment'
export default {
name: 'Statuses',
props: {
reports: {
type: Array,
required: true
}
},
methods: {
capitalizeFirstLetter(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
},
changeReportState(state, id) {
this.$store.dispatch('ChangeReportState', [{ state, id }])
},
getStateType(state) {
switch (state) {
case 'closed':
return 'info'
case 'resolved':
return 'success'
default:
return 'primary'
}
},
parseTimestamp(timestamp) {
return moment(timestamp).format('YYYY-MM-DD HH:mm')
}
}
}
</script>
<style rel='stylesheet/scss' lang='scss'>
a {
text-decoration: underline;
}
.el-icon-arrow-right {
margin-right: 6px;
}
.report-header {
display: flex;
justify-content: space-between;
align-items: baseline;
height: 40px;
}
.report-actor {
display: flex;
align-items: center;
}
.report-actor-name {
margin: 0;
height: 22px;
}
.report-avatar-img {
width: 15px;
height: 15px;
margin-right: 5px;
}
.report-body {
display: flex;
flex-direction: column;
}
.report-card {
margin-bottom: 15px;
}
.report-content {
font-size: 15px;
}
.report-header {
display: flex;
justify-content: space-between;
}
@media
only screen and (max-width: 760px),
(min-device-width: 768px) and (max-device-width: 1024px) {
.el-card__header {
padding: 10px 17px;
}
.report-header {
display: flex;
flex-direction: column;
height: 80px;
}
.report-actor-container {
margin-bottom: 5px;
}
.report-header {
display: flex;
flex-direction: column;
}
}
</style>

View file

@ -44,7 +44,7 @@ export default {
toggleFilters() {
this.$store.dispatch('SetFilter', this.$data.filter)
this.$store.dispatch('ClearFetchedReports')
this.$store.dispatch('FetchReports')
this.$store.dispatch('FetchReports', 1)
}
}
}

View file

@ -1,176 +0,0 @@
<template>
<el-collapse-item :title="getStatusesTitle(report.statuses)">
<el-card v-for="status in report.statuses" :key="status.id" class="status-card">
<div slot="header">
<div class="status-header">
<div class="status-account-container">
<div class="status-account">
<img :src="status.account.avatar" class="status-avatar-img">
<h3 class="status-account-name">{{ status.account.display_name }}</h3>
</div>
<a :href="status.account.url" target="_blank" class="account">
@{{ status.account.acct }}
</a>
</div>
<div class="status-actions">
<el-tag v-if="status.sensitive" type="warning" size="large">{{ $t('reports.sensitive') }}</el-tag>
<el-tag size="large">{{ capitalizeFirstLetter(status.visibility) }}</el-tag>
<el-dropdown trigger="click">
<el-button plain size="small" icon="el-icon-edit" class="status-actions-button">
{{ $t('reports.changeScope') }}<i class="el-icon-arrow-down el-icon--right"/>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item
v-if="!status.sensitive"
@click.native="changeStatus(status.id, true, status.visibility, report.id)">
{{ $t('reports.addSensitive') }}
</el-dropdown-item>
<el-dropdown-item
v-if="status.sensitive"
@click.native="changeStatus(status.id, false, status.visibility, report.id)">
{{ $t('reports.removeSensitive') }}
</el-dropdown-item>
<el-dropdown-item
v-if="status.visibility !== 'public'"
@click.native="changeStatus(status.id, status.sensitive, 'public', report.id)">
{{ $t('reports.public') }}
</el-dropdown-item>
<el-dropdown-item
v-if="status.visibility !== 'private'"
@click.native="changeStatus(status.id, status.sensitive, 'private', report.id)">
{{ $t('reports.private') }}
</el-dropdown-item>
<el-dropdown-item
v-if="status.visibility !== 'unlisted'"
@click.native="changeStatus(status.id, status.sensitive, 'unlisted', report.id)">
{{ $t('reports.unlisted') }}
</el-dropdown-item>
<el-dropdown-item
@click.native="deleteStatus(status.id, report.id)">
{{ $t('reports.deleteStatus') }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
</div>
<div class="status-body">
<span class="status-content" v-html="status.content"/>
<a :href="status.url" target="_blank" class="account">
{{ parseTimestamp(status.created_at) }}
</a>
</div>
</el-card>
</el-collapse-item>
</template>
<script>
import moment from 'moment'
export default {
name: 'Statuses',
props: {
report: {
type: Object,
required: true
}
},
methods: {
capitalizeFirstLetter(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
},
changeStatus(statusId, isSensitive, visibility, reportId) {
this.$store.dispatch('ChangeStatusScope', { statusId, isSensitive, visibility, reportId })
},
deleteStatus(statusId, reportId) {
this.$confirm('Are you sure you want to delete this status?', 'Warning', {
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
type: 'warning'
}).then(() => {
this.$store.dispatch('DeleteStatus', { statusId, reportId })
this.$message({
type: 'success',
message: 'Delete completed'
})
}).catch(() => {
this.$message({
type: 'info',
message: 'Delete canceled'
})
})
},
getStatusesTitle(statuses) {
return `Reported statuses: ${statuses.length} item(s)`
},
parseTimestamp(timestamp) {
return moment(timestamp).format('YYYY-MM-DD HH:mm')
}
}
}
</script>
<style rel='stylesheet/scss' lang='scss'>
.account {
text-decoration: underline;
}
.status-account {
display: flex;
align-items: center;
}
.status-avatar-img {
width: 15px;
height: 15px;
margin-right: 5px;
}
.status-account-name {
margin: 0;
height: 22px;
}
.status-body {
display: flex;
flex-direction: column;
}
.status-content {
font-size: 15px;
}
.status-card {
margin-bottom: 15px;
}
.status-header {
display: flex;
justify-content: space-between;
}
@media
only screen and (max-width: 760px),
(min-device-width: 768px) and (max-device-width: 1024px) {
.el-message {
min-width: 80%;
}
.el-message-box {
width: 80%;
}
.status-card {
.el-card__header {
padding: 10px 17px
}
.el-tag {
margin: 3px 4px 3px 0;
}
.status-account-container {
margin-bottom: 5px;
}
.status-actions-button {
margin: 3px 0 3px;
}
.status-actions {
display: flex;
flex-wrap: wrap;
}
.status-header {
display: flex;
flex-direction: column;
}
}
}
</style>

View file

@ -1,271 +0,0 @@
<template>
<el-timeline-item :timestamp="parseTimestamp(report.created_at)" placement="top" class="timeline-item-container">
<el-card>
<div class="header-container">
<div>
<h3 class="report-title">Report on {{ report.account.display_name }}</h3>
<h5 class="id">ID: {{ report.id }}</h5>
</div>
<div>
<el-tag :type="getStateType(report.state)" size="large">{{ capitalizeFirstLetter(report.state) }}</el-tag>
<el-dropdown trigger="click">
<el-button plain size="small" icon="el-icon-edit">{{ $t('reports.changeState') }}<i class="el-icon-arrow-down el-icon--right"/></el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item v-if="report.state !== 'resolved'" @click.native="changeReportState('resolved', report.id)">{{ $t('reports.resolve') }}</el-dropdown-item>
<el-dropdown-item v-if="report.state !== 'open'" @click.native="changeReportState('open', report.id)">{{ $t('reports.reopen') }}</el-dropdown-item>
<el-dropdown-item v-if="report.state !== 'closed'" @click.native="changeReportState('closed', report.id)">{{ $t('reports.close') }}</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<el-dropdown trigger="click">
<el-button plain size="small" icon="el-icon-files">{{ $t('reports.moderateUser') }}<i class="el-icon-arrow-down el-icon--right"/></el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item
v-if="showDeactivatedButton(report.account)"
@click.native="toggleActivation(report.account)">
{{ report.account.deactivated ? $t('users.activateAccount') : $t('users.deactivateAccount') }}
</el-dropdown-item>
<el-dropdown-item
v-if="showDeactivatedButton(report.account.id)"
@click.native="handleDeletion(report.account.id)">
{{ $t('users.deleteAccount') }}
</el-dropdown-item>
<el-dropdown-item
:divided="true"
:class="{ 'active-tag': report.account.tags.includes('force_nsfw') }"
@click.native="toggleTag(report.account, 'force_nsfw')">
{{ $t('users.forceNsfw') }}
<i v-if="report.account.tags.includes('force_nsfw')" class="el-icon-check"/>
</el-dropdown-item>
<el-dropdown-item
:class="{ 'active-tag': report.account.tags.includes('strip_media') }"
@click.native="toggleTag(report.account, 'strip_media')">
{{ $t('users.stripMedia') }}
<i v-if="report.account.tags.includes('strip_media')" class="el-icon-check"/>
</el-dropdown-item>
<el-dropdown-item
:class="{ 'active-tag': report.account.tags.includes('force_unlisted') }"
@click.native="toggleTag(report.account, 'force_unlisted')">
{{ $t('users.forceUnlisted') }}
<i v-if="report.account.tags.includes('force_unlisted')" class="el-icon-check"/>
</el-dropdown-item>
<el-dropdown-item
:class="{ 'active-tag': report.account.tags.includes('sandbox') }"
@click.native="toggleTag(report.account, 'sandbox')">
{{ $t('users.sandbox') }}
<i v-if="report.account.tags.includes('sandbox')" class="el-icon-check"/>
</el-dropdown-item>
<el-dropdown-item
v-if="report.account.local"
:class="{ 'active-tag': report.account.tags.includes('disable_remote_subscription') }"
@click.native="toggleTag(report.account, 'disable_remote_subscription')">
{{ $t('users.disableRemoteSubscription') }}
<i v-if="report.account.tags.includes('disable_remote_subscription')" class="el-icon-check"/>
</el-dropdown-item>
<el-dropdown-item
v-if="report.account.local"
:class="{ 'active-tag': report.account.tags.includes('disable_any_subscription') }"
@click.native="toggleTag(report.account, 'disable_any_subscription')">
{{ $t('users.disableAnySubscription') }}
<i v-if="report.account.tags.includes('disable_any_subscription')" class="el-icon-check"/>
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
<div>
<div class="line"/>
<span class="report-row-key">Account:</span>
<img
:src="report.account.avatar"
alt="avatar"
class="avatar-img">
<a :href="report.account.url" target="_blank" class="account">
<span class="report-row-value">{{ report.account.acct }}</span>
</a>
</div>
<div v-if="report.content.length > 0">
<div class="line"/>
<span class="report-row-key">Content:
<span class="report-row-value">{{ report.content }}</span>
</span>
</div>
<div>
<div class="line"/>
<span class="report-row-key">Actor:</span>
<img
:src="report.actor.avatar"
alt="avatar"
class="avatar-img">
<a :href="report.actor.url" target="_blank" class="account">
<span class="report-row-value">{{ report.actor.acct }}</span>
</a>
</div>
<div v-if="report.statuses.length > 0" class="statuses">
<el-collapse>
<statuses :report="report"/>
</el-collapse>
</div>
</el-card>
</el-timeline-item>
</template>
<script>
import moment from 'moment'
import Statuses from './Statuses'
export default {
name: 'TimelineItem',
components: { Statuses },
props: {
report: {
type: Object,
required: true
}
},
methods: {
changeReportState(reportState, reportId) {
this.$store.dispatch('ChangeReportState', { reportState, reportId })
},
capitalizeFirstLetter(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
},
getStateType(state) {
switch (state) {
case 'closed':
return 'info'
case 'resolved':
return 'success'
default:
return 'primary'
}
},
handleDeletion(user) {
this.$store.dispatch('DeleteUsers', [user])
},
parseTimestamp(timestamp) {
return moment(timestamp).format('L HH:mm')
},
showDeactivatedButton(id) {
return this.$store.state.user.id !== id
},
toggleActivation(user) {
user.deactivated
? this.$store.dispatch('ActivateUsers', [user])
: this.$store.dispatch('DeactivateUsers', [user])
},
toggleTag(user, tag) {
user.tags.includes(tag)
? this.$store.dispatch('RemoveTag', { users: [user], tag })
: this.$store.dispatch('AddTag', { users: [user], tag })
}
}
}
</script>
<style rel='stylesheet/scss' lang='scss'>
.account {
text-decoration: underline;
}
.avatar-img {
vertical-align: bottom;
width: 15px;
height: 15px;
margin-left: 5px;
}
.el-card__body {
padding: 17px;
}
.el-card__header {
background-color: #FAFAFA;
padding: 10px 20px;
}
.el-collapse {
border-bottom: none;
}
.el-collapse-item__header {
height: 46px;
font-size: 14px;
}
.el-collapse-item__content {
padding-bottom: 7px;
}
.el-icon-arrow-right {
margin-right: 6px;
}
.el-icon-close {
padding: 10px 5px 10px 10px;
cursor: pointer;
}
h4 {
margin: 0;
height: 17px;
}
.header-container {
display: flex;
justify-content: space-between;
align-items: baseline;
height: 40px;
}
.id {
color: gray;
margin-top: 6px;
}
.line {
width: 100%;
height: 0;
border: 0.5px solid #EBEEF5;
margin: 15px 0 15px;
}
.new-note {
p {
font-size: 14px;
font-weight: 500;
height: 17px;
margin: 13px 0 7px;
}
}
.note {
box-shadow: 0 2px 5px 0 rgba(0,0,0,.1);
margin-bottom: 10px;
}
.no-notes {
font-style: italic;
color: gray;
}
.report-row-key {
font-size: 14px;
font-weight: 500;
}
.report-row-key {
font-size: 14px;
}
.report-title {
margin: 0;
}
.statuses {
margin-top: 15px;
}
.submit-button {
display: block;
margin: 7px 0 17px auto;
}
.timestamp {
margin: 0;
font-style: italic;
color: gray;
}
@media
only screen and (max-width: 760px),
(min-device-width: 768px) and (max-device-width: 1024px) {
.timeline-item-container {
.header-container {
display: flex;
flex-direction: column;
height: 80px;
}
.id {
margin: 6px 0 0 0;
}
}
}
</style>

View file

@ -1,13 +1,22 @@
<template>
<div class="reports-container">
<h1>{{ $t('reports.reports') }}</h1>
<h1 v-if="groupReports">
{{ $t('reports.groupedReports') }}
<span class="report-count">({{ normalizedReportsCount }})</span>
</h1>
<h1 v-else>
{{ $t('reports.reports') }}
<span class="report-count">({{ normalizedReportsCount }})</span>
</h1>
<div class="filter-container">
<reports-filter/>
<reports-filter v-if="!groupReports"/>
<el-checkbox v-model="groupReports" class="group-reports-checkbox">
Group reports by statuses
</el-checkbox>
</div>
<div class="block">
<el-timeline class="timeline">
<timeline-item v-loading="loading" v-for="report in reports" :report="report" :key="report.id"/>
</el-timeline>
<grouped-report v-loading="loading" v-if="groupReports" :grouped-reports="groupedReports"/>
<report v-loading="loading" v-else :reports="reports"/>
<div v-if="reports.length === 0" class="no-reports-message">
<p>There are no reports to display</p>
</div>
@ -16,34 +25,44 @@
</template>
<script>
import TimelineItem from './components/TimelineItem'
import GroupedReport from './components/GroupedReport'
import numeral from 'numeral'
import Report from './components/Report'
import ReportsFilter from './components/ReportsFilter'
export default {
components: { TimelineItem, ReportsFilter },
components: { GroupedReport, Report, ReportsFilter },
computed: {
groupedReports() {
return this.$store.state.reports.fetchedGroupedReports
},
groupReports: {
get() {
return this.$store.state.reports.groupReports
},
set() {
this.toggleReportsGrouping()
}
},
loading() {
return this.$store.state.users.loading
return this.$store.state.reports.loading
},
normalizedReportsCount() {
return this.groupReports
? numeral(this.$store.state.reports.fetchedGroupedReports.length).format('0a')
: numeral(this.$store.state.reports.totalReportsCount).format('0a')
},
reports() {
return this.$store.state.reports.fetchedReports
}
},
mounted() {
this.$store.dispatch('FetchReports')
},
created() {
window.addEventListener('scroll', this.handleScroll)
},
destroyed() {
window.removeEventListener('scroll', this.handleScroll)
this.$store.dispatch('FetchReports', 1)
this.$store.dispatch('FetchGroupedReports')
},
methods: {
handleScroll(reports) {
const bottomOfWindow = document.documentElement.scrollHeight - document.documentElement.scrollTop === document.documentElement.clientHeight
if (bottomOfWindow) {
this.$store.dispatch('FetchReports')
}
toggleReportsGrouping() {
this.$store.dispatch('ToggleReportsGrouping')
}
}
}
@ -56,16 +75,24 @@ export default {
padding: 0px;
}
.filter-container {
display: flex;
flex-direction: column;
margin: 22px 15px 22px 15px;
padding-bottom: 0
}
.group-reports-checkbox {
margin-top: 10px;
}
h1 {
margin: 22px 0 0 15px;
}
.no-reports-message {
color: gray;
margin-left: 19px
}
.report-count {
color: gray;
font-size: 28px;
}
}
@media
@ -78,9 +105,9 @@ only screen and (max-width: 760px),
.filter-container {
margin: 0 10px
}
.timeline {
margin: 20px 20px 20px 18px
}
}
#app > div > div.main-container > section > div > div.block > ul {
margin: 45px 45px 5px 19px;
}
}
</style>

View file

@ -10,8 +10,8 @@
</template>
<script>
import i18n from '@/lang'
import { mapGetters } from 'vuex'
import i18n from '@/lang'
import Setting from './Setting'
export default {

View file

@ -8,8 +8,8 @@
</template>
<script>
import i18n from '@/lang'
import { mapGetters } from 'vuex'
import i18n from '@/lang'
import Setting from './Setting'
export default {

View file

@ -26,8 +26,8 @@
</template>
<script>
import i18n from '@/lang'
import { mapGetters } from 'vuex'
import i18n from '@/lang'
import Setting from './Setting'
export default {

View file

@ -39,8 +39,8 @@
</template>
<script>
import i18n from '@/lang'
import { mapGetters } from 'vuex'
import i18n from '@/lang'
import Setting from './Setting'
export default {

View file

@ -16,8 +16,8 @@
</template>
<script>
import i18n from '@/lang'
import { mapGetters } from 'vuex'
import i18n from '@/lang'
import Setting from './Setting'
export default {

View file

@ -17,8 +17,8 @@
</template>
<script>
import i18n from '@/lang'
import { mapGetters } from 'vuex'
import i18n from '@/lang'
import Setting from './Setting'
export default {

View file

@ -8,8 +8,8 @@
</template>
<script>
import i18n from '@/lang'
import { mapGetters } from 'vuex'
import i18n from '@/lang'
import Setting from './Setting'
export default {

View file

@ -14,8 +14,8 @@
</template>
<script>
import i18n from '@/lang'
import { mapGetters } from 'vuex'
import i18n from '@/lang'
import Setting from './Setting'
export default {

Some files were not shown because too many files have changed in this diff Show more