#238 [admin] optimise the list of users, pagination and all - server-side pagination
This commit is contained in:
parent
b9a9524f1d
commit
ea8a81d2eb
|
@ -0,0 +1,161 @@
|
|||
<template>
|
||||
<section class="table-responsive">
|
||||
<table :class="['table table-striped table-hover', fixed ? 'table-fixed-' + columns : '']">
|
||||
<thead ref="thead">
|
||||
<tr>
|
||||
<td :colspan="columns">
|
||||
<nav v-if="pages > 1">
|
||||
<ul class="pagination pagination-sm justify-content-center mb-0">
|
||||
<li v-for="p in pagesRange" :class="['page-item', p.page === page ? 'active' : '', p.enabled ? '' : 'disabled']">
|
||||
<a v-if="p.enabled" class="page-link" href="#" @click.prevent="page = p.page">
|
||||
{{p.text}}
|
||||
</a>
|
||||
<span v-else class="page-link">
|
||||
{{p.text}}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="count && rowsCount">
|
||||
<td :colspan="columns">
|
||||
<T>table.count</T>:
|
||||
<strong>{{ rowsCount }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<slot name="header"></slot>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="dataPage === undefined">
|
||||
<td :colspan="columns" class="text-center p-4">
|
||||
<Spinner size="5rem"/>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-else>
|
||||
<template v-if="rowsCount">
|
||||
<tr v-for="el in dataPage" :key="el[rowKey]" :class="{'marked': marked ? marked(el) : false}">
|
||||
<slot name="row" v-bind:el="el"></slot>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-else>
|
||||
<tr>
|
||||
<td :colspan="columns" class="text-center">
|
||||
<slot name="empty">
|
||||
<Icon v="search"/>
|
||||
<T>table.empty</T>
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td :colspan="columns + 1">
|
||||
<nav v-if="pages > 1">
|
||||
<ul class="pagination pagination-sm justify-content-center">
|
||||
<li v-for="p in pagesRange" :class="['page-item', p.page === page ? 'active' : '', p.enabled ? '' : 'disabled']">
|
||||
<a v-if="p.enabled" class="page-link" href="#" @click.prevent="page = p.page">
|
||||
{{p.text}}
|
||||
</a>
|
||||
<span v-else class="page-link">
|
||||
{{p.text}}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
endpoint: { required: true },
|
||||
query: { 'default': () => {return {}}},
|
||||
columns: { required: true },
|
||||
perPage: { 'default': 100 },
|
||||
rowKey: { 'default': 'id' },
|
||||
marked: {},
|
||||
fixed: { type: Boolean },
|
||||
count: { type: Boolean },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
page: 0,
|
||||
rowsCount: 0,
|
||||
dataPage: undefined,
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadData();
|
||||
},
|
||||
computed: {
|
||||
pages() {
|
||||
return Math.ceil(this.rowsCount / this.perPage);
|
||||
},
|
||||
pagesRange() {
|
||||
const vPages = [];
|
||||
vPages.push({page: 0, text: '«', enabled: this.page > 0});
|
||||
vPages.push({page: this.page - 1, text: '‹', enabled: this.page > 0});
|
||||
for (let i = 0; i < this.pages; i++) {
|
||||
if (i <= 4 || (this.page - 3 <= i && i <= this.page + 3) || i >= this.pages - 3) {
|
||||
vPages.push({page: i, text: i + 1, enabled: true});
|
||||
} else if (vPages[vPages.length - 1].text !== '…') {
|
||||
vPages.push({text: '…', enabled: false});
|
||||
}
|
||||
}
|
||||
vPages.push({page: this.page + 1, text: '›', enabled: this.page < this.pages - 1});
|
||||
vPages.push({page: this.pages - 1, text: '»', enabled: this.page < this.pages - 1});
|
||||
return vPages;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async loadData() {
|
||||
this.dataPage = undefined;
|
||||
const res = await this.$axios.$get(this.endpoint, {params: {
|
||||
...this.query,
|
||||
limit: this.perPage,
|
||||
offset: this.page * this.perPage,
|
||||
}});
|
||||
this.dataPage = res.data;
|
||||
this.rowsCount = res.count;
|
||||
},
|
||||
reset() {
|
||||
this.page = 0;
|
||||
},
|
||||
focus() {
|
||||
setTimeout(_ => {
|
||||
this.$refs.thead.scrollIntoView();
|
||||
}, 300);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
async endpoint() {
|
||||
this.page = 0;
|
||||
await this.loadData();
|
||||
},
|
||||
async query() {
|
||||
this.page = 0;
|
||||
await this.loadData();
|
||||
},
|
||||
async page() {
|
||||
await this.loadData();
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "assets/variables";
|
||||
|
||||
.marked {
|
||||
border-inline-start: 3px solid $primary;
|
||||
}
|
||||
</style>
|
164
routes/admin.vue
164
routes/admin.vue
|
@ -9,76 +9,79 @@
|
|||
<p>Stats counted: {{$datetime(stats.calculatedAt)}}</p>
|
||||
|
||||
<section v-if="$isGranted('users')">
|
||||
<details class="border mb-3" @click="loadUsers">
|
||||
<details class="border mb-3" open>
|
||||
<summary class="bg-light p-3">
|
||||
<Icon v="users"/>
|
||||
Users
|
||||
({{stats.users.overall}} overall, {{stats.users.admins}} admins, {{visibleUsers.length}} visible)
|
||||
({{stats.users.overall}} overall, {{stats.users.admins}} admins)
|
||||
</summary>
|
||||
<div class="border-top">
|
||||
<Loading :value="users" size="5rem">
|
||||
<div class="input-group mt-4">
|
||||
<input class="form-control" v-model="userFilter" :placeholder="$t('crud.filterLong')"/>
|
||||
<button :class="['btn', adminsFilter ? 'btn-secondary' : 'btn-outline-secondary']"
|
||||
@click="adminsFilter = !adminsFilter"
|
||||
>
|
||||
Only admins
|
||||
</button>
|
||||
<button :class="['btn', localeFilter ? 'btn-secondary' : 'btn-outline-secondary']"
|
||||
@click="localeFilter = !localeFilter"
|
||||
>
|
||||
Only this version
|
||||
</button>
|
||||
</div>
|
||||
<Table :data="visibleUsers" :columns="4">
|
||||
<template v-slot:header>
|
||||
<th class="text-nowrap">
|
||||
<T>admin.user.user</T>
|
||||
</th>
|
||||
<th class="text-nowrap">
|
||||
<T>admin.user.email</T>
|
||||
</th>
|
||||
<th class="text-nowrap">
|
||||
<T>admin.user.roles</T>
|
||||
</th>
|
||||
<th class="text-nowrap">
|
||||
<T>admin.user.profiles</T>
|
||||
</th>
|
||||
</template>
|
||||
<div class="input-group mt-4">
|
||||
<input class="form-control" v-model="userFilter" :placeholder="$t('crud.filterLong')"/>
|
||||
<button :class="['btn', adminsFilter ? 'btn-secondary' : 'btn-outline-secondary']"
|
||||
@click="adminsFilter = !adminsFilter"
|
||||
>
|
||||
Only admins
|
||||
</button>
|
||||
<button :class="['btn', localeFilter ? 'btn-secondary' : 'btn-outline-secondary']"
|
||||
@click="localeFilter = !localeFilter"
|
||||
>
|
||||
Only this version
|
||||
</button>
|
||||
</div>
|
||||
<ServerTable
|
||||
endpoint="/admin/users"
|
||||
:query="{filter: userFilterDelayed || undefined, localeFilter: localeFilter || undefined, adminsFilter: adminsFilter || undefined}"
|
||||
:columns="4"
|
||||
count
|
||||
>
|
||||
<template v-slot:header>
|
||||
<th class="text-nowrap">
|
||||
<T>admin.user.user</T>
|
||||
</th>
|
||||
<th class="text-nowrap">
|
||||
<T>admin.user.email</T>
|
||||
</th>
|
||||
<th class="text-nowrap">
|
||||
<T>admin.user.roles</T>
|
||||
</th>
|
||||
<th class="text-nowrap">
|
||||
<T>admin.user.profiles</T>
|
||||
</th>
|
||||
</template>
|
||||
|
||||
<template v-slot:row="s">
|
||||
<td>
|
||||
<a :href="'https://pronouns.page/@ + s.el.username'">@{{s.el.username}}</a>
|
||||
</td>
|
||||
<td>
|
||||
<p>
|
||||
<a :href="`mailto:${s.el.email}`" target="_blank" rel="noopener">
|
||||
{{s.el.email}}
|
||||
</a>
|
||||
</p>
|
||||
<!--
|
||||
<ul v-if="s.el.socialConnections.length" class="list-inline">
|
||||
<li v-for="conn in s.el.socialConnections" class="list-inline-item">
|
||||
<Icon :v="socialProviders[conn].icon || conn" set="b"/>
|
||||
</li>
|
||||
</ul>
|
||||
-->
|
||||
</td>
|
||||
<td>
|
||||
<Roles :user="s.el"/>
|
||||
</td>
|
||||
<td>
|
||||
<ul class="list-unstyled">
|
||||
<li v-for="locale in s.el.profiles" v-if="locales[locale]">
|
||||
<LocaleLink :link="`/@${s.el.username}`" :locale="locale">
|
||||
{{ locales[locale].name }}
|
||||
</LocaleLink>
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
</template>
|
||||
</Table>
|
||||
</Loading>
|
||||
<template v-slot:row="s">
|
||||
<td>
|
||||
<a :href="'https://pronouns.page/@ + s.el.username'">@{{s.el.username}}</a>
|
||||
</td>
|
||||
<td>
|
||||
<p>
|
||||
<a :href="`mailto:${s.el.email}`" target="_blank" rel="noopener">
|
||||
{{s.el.email}}
|
||||
</a>
|
||||
</p>
|
||||
<!--
|
||||
<ul v-if="s.el.socialConnections.length" class="list-inline">
|
||||
<li v-for="conn in s.el.socialConnections" class="list-inline-item">
|
||||
<Icon :v="socialProviders[conn].icon || conn" set="b"/>
|
||||
</li>
|
||||
</ul>
|
||||
-->
|
||||
</td>
|
||||
<td>
|
||||
<Roles :user="s.el"/>
|
||||
</td>
|
||||
<td>
|
||||
<ul class="list-unstyled">
|
||||
<li v-for="locale in s.el.profiles" v-if="locales[locale]">
|
||||
<LocaleLink :link="`/@${s.el.username}`" :locale="locale">
|
||||
{{ locales[locale].name }}
|
||||
</LocaleLink>
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
</template>
|
||||
</ServerTable>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
@ -194,7 +197,7 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import {head, isGranted} from "../src/helpers";
|
||||
import {head} from "../src/helpers";
|
||||
import {socialProviders} from "../src/data";
|
||||
|
||||
export default {
|
||||
|
@ -207,7 +210,6 @@
|
|||
localeFilter: true,
|
||||
adminsFilter: false,
|
||||
users: undefined,
|
||||
visibleUsers: [],
|
||||
}
|
||||
},
|
||||
async asyncData({ app, store }) {
|
||||
|
@ -227,14 +229,6 @@
|
|||
};
|
||||
},
|
||||
methods: {
|
||||
async loadUsers() {
|
||||
if (this.users === undefined) {
|
||||
this.users = (await this.$axios.$get(`/admin/users`)).map(u => {
|
||||
u.profiles = u.profiles ? u.profiles.split(',') : [];
|
||||
return u;
|
||||
});
|
||||
}
|
||||
},
|
||||
async handleReport(id) {
|
||||
await this.$confirm('Are you sure you want to mark this report as handled?', 'success');
|
||||
await this.$post(`/admin/reports/handle/${id}`);
|
||||
|
@ -243,16 +237,6 @@
|
|||
return r;
|
||||
});
|
||||
},
|
||||
calcVisibleUsers() {
|
||||
if (this.users === undefined) {
|
||||
return [];
|
||||
}
|
||||
return this.users.filter(u =>
|
||||
u.username.toLowerCase().includes(this.userFilterDelayed.toLowerCase())
|
||||
&& (!this.adminsFilter || u.roles !== '')
|
||||
&& (!this.localeFilter || u.profiles.includes(this.config.locale))
|
||||
);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
profilesByLocale() {
|
||||
|
@ -273,18 +257,6 @@
|
|||
this.userFilterDelayed = this.userFilter;
|
||||
}, 500);
|
||||
},
|
||||
userFilterDelayed() {
|
||||
this.visibleUsers = this.calcVisibleUsers();
|
||||
},
|
||||
localeFilter() {
|
||||
this.visibleUsers = this.calcVisibleUsers();
|
||||
},
|
||||
adminsFilter() {
|
||||
this.visibleUsers = this.calcVisibleUsers();
|
||||
},
|
||||
users() {
|
||||
this.visibleUsers = this.calcVisibleUsers();
|
||||
},
|
||||
},
|
||||
head() {
|
||||
return head({
|
||||
|
|
|
@ -73,15 +73,48 @@ router.get('/admin/users', handleErrorAsync(async (req, res) => {
|
|||
return res.status(401).json({error: 'Unauthorised'});
|
||||
}
|
||||
|
||||
const users = await req.db.all(SQL`
|
||||
const conditions = [];
|
||||
|
||||
let sql = SQL`
|
||||
SELECT u.id, u.username, u.email, u.roles, u.avatarSource, group_concat(p.locale) AS profiles
|
||||
FROM users u
|
||||
LEFT JOIN profiles p ON p.userId = u.id
|
||||
`
|
||||
|
||||
if (req.query.filter) {
|
||||
conditions.push(SQL`u.username LIKE ${'%' + req.query.filter + '%'}`);
|
||||
}
|
||||
if (req.query.localeFilter) {
|
||||
conditions.push(SQL`p.locale=${global.config.locale}`);
|
||||
}
|
||||
if (req.query.adminsFilter) {
|
||||
conditions.push(SQL`u.roles != ''`);
|
||||
}
|
||||
|
||||
let conditionsSql = SQL``;
|
||||
if (conditions.length) {
|
||||
let i = 0;
|
||||
for (let condition of conditions) {
|
||||
conditionsSql = conditionsSql.append(i++ ? SQL` AND ` : SQL` WHERE `).append(condition);
|
||||
}
|
||||
}
|
||||
|
||||
sql = sql.append(conditionsSql).append(SQL`
|
||||
GROUP BY u.id
|
||||
ORDER BY u.id DESC
|
||||
LIMIT ${parseInt(req.query.limit || 100)}
|
||||
OFFSET ${parseInt(req.query.offset || 0)}
|
||||
`);
|
||||
|
||||
return res.json(users);
|
||||
const countSql = SQL`SELECT COUNT(*) AS c FROM (SELECT u.id FROM users u LEFT JOIN profiles p ON p.userId = u.id`.append(conditionsSql).append(` GROUP BY u.id)`);
|
||||
|
||||
return res.json({
|
||||
count: (await req.db.get(countSql)).c,
|
||||
data: (await req.db.all(sql)).map(u => {
|
||||
u.profiles = u.profiles ? u.profiles.split(',') : [];
|
||||
return u;
|
||||
}),
|
||||
});
|
||||
}));
|
||||
|
||||
router.get('/admin/stats', handleErrorAsync(async (req, res) => {
|
||||
|
|
Reference in New Issue