variable columns for users list, more columns possible, badge display for statuses

pull/3023/head
Rigel Kent 2020-07-15 11:17:03 +02:00 committed by Rigel Kent
parent 654a188f80
commit bc99dfe54e
17 changed files with 230 additions and 60 deletions

View File

@ -41,8 +41,12 @@
</a>
</td>
<td *ngIf="follow.state === 'accepted'" i18n>Accepted</td>
<td *ngIf="follow.state === 'pending'" i18n>Pending</td>
<td *ngIf="follow.state === 'accepted'">
<span class="badge badge-green" i18n>Accepted</span>
</td>
<td *ngIf="follow.state === 'pending'">
<span class="badge badge-yellow" i18n>Pending</span>
</td>
<td>{{ follow.score }}</td>
<td>{{ follow.createdAt | date: 'short' }}</td>

View File

@ -45,8 +45,12 @@
</a>
</td>
<td *ngIf="follow.state === 'accepted'" i18n>Accepted</td>
<td *ngIf="follow.state === 'pending'" i18n>Pending</td>
<td *ngIf="follow.state === 'accepted'">
<span class="badge badge-green" i18n>Accepted</span>
</td>
<td *ngIf="follow.state === 'pending'">
<span class="badge badge-yellow" i18n>Pending</span>
</td>
<td>{{ follow.createdAt | date: 'short' }}</td>
<td>

View File

@ -4,3 +4,7 @@
flex-grow: 0;
margin-right: 30px;
}
.badge {
@include table-badge;
}

View File

@ -26,10 +26,10 @@
<ng-template pTemplate="header">
<tr>
<th style="width: 40px"></th>
<th class="job-id" i18n>ID</th>
<th class="job-type" i18n>Type</th>
<th class="job-date" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
<th class="job-state" i18n>State</th>
<th style="width: 100%" class="job-id" i18n>ID</th>
<th style="width: 200px" class="job-type" i18n>Type</th>
<th style="width: 150px" class="job-date" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
<th style="width: 150px" class="job-state" i18n>State</th>
</tr>
</ng-template>
@ -43,7 +43,7 @@
<td class="job-id" [title]="job.id">{{ job.id }}</td>
<td class="job-type">{{ job.type }}</td>
<td class="job-date">{{ job.createdAt }}</td>
<td class="job-date">{{ job.createdAt | date: 'short' }}</td>
<td class="job-state" *ngIf="job.state === 'delayed'" class="text-muted"><span class="glyphicon glyphicon-repeat"></span> <span i18n>Delayed</span></td>
<td class="job-state" *ngIf="job.state === 'waiting'" class="text-warning"><span class="glyphicon glyphicon-hourglass"></span> <span i18n>Will start soon...</span></td>
<td class="job-state" *ngIf="job.state === 'active'" class="text-warning"><span class="glyphicon glyphicon-cog"></span> <span i18n>Running...</span></td>

View File

@ -50,19 +50,41 @@
<p-tableHeaderCheckbox></p-tableHeaderCheckbox>
</th>
<th style="width: 40px"></th>
<th pResizableColumn i18n pSortableColumn="username">Username <p-sortIcon field="username"></p-sortIcon></th>
<th i18n>Email</th>
<th style="width: 140px;" i18n pSortableColumn="videoQuotaUsed">Video quota <p-sortIcon field="videoQuotaUsed"></p-sortIcon></th>
<th style="width: 120px;" i18n>Role</th>
<th style="width: 140px;" pResizableColumn i18n>Auth plugin</th>
<th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
<th style="width: 60px;"></th>
<th *ngIf="getColumn('username')" pResizableColumn i18n pSortableColumn="username">{{ getColumn('username').label }} <p-sortIcon field="username"></p-sortIcon></th>
<th *ngIf="getColumn('email')" i18n>{{ getColumn('email').label }}</th>
<th *ngIf="getColumn('quota')" style="width: 160px;" i18n pSortableColumn="videoQuotaUsed">{{ getColumn('quota').label }} <p-sortIcon field="videoQuotaUsed"></p-sortIcon></th>
<th *ngIf="getColumn('quotaDaily')" style="width: 160px;" i18n>{{ getColumn('quotaDaily').label }}</th>
<th *ngIf="getColumn('role')" style="width: 120px;" i18n pSortableColumn="role">{{ getColumn('role').label }} <p-sortIcon field="role"></p-sortIcon></th>
<th *ngIf="getColumn('pluginAuth')" style="width: 140px;" pResizableColumn i18n>{{ getColumn('pluginAuth').label }}</th>
<th *ngIf="getColumn('createdAt')" style="width: 150px;" i18n pSortableColumn="createdAt">{{ getColumn('createdAt').label }} <p-sortIcon field="createdAt"></p-sortIcon></th>
<th *ngIf="getColumn('lastLoginDate')" style="width: 150px;" i18n pSortableColumn="lastLoginDate">{{ getColumn('lastLoginDate').label }} <p-sortIcon field="lastLoginDate"></p-sortIcon></th>
<th style="width: 60px;">
<div class="c-hand" ngbDropdown placement="bottom-right auto" container="body" autoClose="outside">
<my-global-icon iconName="columns" ngbDropdownToggle></my-global-icon>
<div role="menu" class="dropdown-menu" ngbDropdownMenu>
<div class="dropdown-header" i18n>Table parameters</div>
<div ngbDropdownItem class="dropdown-item">
<p-multiSelect
[options]="columns" [showToggleAll]="true" [(ngModel)]="selectedColumns" optionLabel="label"
emptyFilterMessage="No matching column found" i18n-emptyFilterMessage [filter]="false"
selectedItemsLabel="{0} columns displayed" i18n-emptyFilterMessage [showHeader]="false"
[maxSelectedLabels]="4"
></p-multiSelect>
</div>
<div ngbDropdownItem class="dropdown-item">
<my-peertube-checkbox inputName="highlightBannedUsers" [(ngModel)]="highlightBannedUsers"
i18n-labelText labelText="Highlight banned users"></my-peertube-checkbox>
</div>
</div>
</div>
</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-expanded="expanded" let-user>
<tr [pSelectableRow]="user" [ngClass]="{ banned: user.blocked }">
<tr [pSelectableRow]="user" [ngClass]="{ banned: highlightBannedUsers && user.blocked }">
<td>
<p-tableCheckbox [value]="user"></p-tableCheckbox>
</td>
@ -73,7 +95,7 @@
</span>
</td>
<td>
<td *ngIf="getColumn('username')">
<a i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer" [routerLink]="[ '/accounts/' + user.username ]">
<div class="chip two-lines">
<img
@ -83,17 +105,16 @@
alt="Avatar"
>
<div>
<span class="user-table-primary-text">
<span *ngIf="user.blocked" i18n-title title="The user was banned" class="glyphicon glyphicon-ban-circle"></span>
{{ user.account.displayName }}
</span>
<span class="user-table-primary-text">{{ user.account.displayName }}</span>
<span class="text-muted">{{ user.username }}</span>
</div>
</div>
</a>
</td>
<td *ngIf="!requiresEmailVerification || user.blocked; else emailWithVerificationStatus" [title]="user.email">{{ user.email }}</td>
<td *ngIf="!requiresEmailVerification || user.blocked; else emailWithVerificationStatus" [title]="user.email">
<a class="table-email" [href]="'mailto:' + user.email">{{ user.email }}</a>
</td>
<ng-template #emailWithVerificationStatus>
<td *ngIf="user.emailVerified === false; else emailVerifiedNotFalse" i18n-title title="User's email must be verified to login">
@ -106,14 +127,38 @@
</ng-template>
</ng-template>
<td>{{ user.videoQuotaUsed }} / {{ user.videoQuota }}</td>
<td>{{ user.roleLabel }}</td>
<td *ngIf="getColumn('quota')">
<div class="progress" i18n-title title="Total video quota">
<div class="progress-bar" role="progressbar" [style]="{ width: getUserVideoQuotaPercentage(user) + '%' }"
[attr.aria-valuenow]="user.rawVideoQuotaUsed" aria-valuemin="0" [attr.aria-valuemax]="user.rawVideoQuota">
</div>
<span>{{ user.videoQuotaUsed }}</span>
<span>{{ user.videoQuota }}</span>
</div>
</td>
<td>
<td *ngIf="getColumn('quotaDaily')">
<div class="progress" i18n-title title="Total daily video quota">
<div class="progress-bar secondary" role="progressbar" [style]="{ width: getUserVideoQuotaDailyPercentage(user) + '%' }"
[attr.aria-valuenow]="user.rawVideoQuotaUsedDaily" aria-valuemin="0" [attr.aria-valuemax]="user.rawVideoQuotaDaily">
</div>
<span>{{ user.videoQuotaUsedDaily }}</span>
<span>{{ user.videoQuotaDaily }}</span>
</div>
</td>
<td *ngIf="getColumn('role')">
<span *ngIf="user.blocked" class="badge badge-banned" i18n-title title="The user was banned">{{ user.roleLabel }}</span>
<span *ngIf="!user.blocked" class="badge" [ngClass]="getRoleClass(user.role)">{{ user.roleLabel }}</span>
</td>
<td *ngIf="getColumn('pluginAuth')">
<ng-container *ngIf="user.pluginAuth">{{ user.pluginAuth }}</ng-container>
</td>
<td [title]="user.createdAt">{{ user.createdAt | date: 'short' }}</td>
<td *ngIf="getColumn('createdAt')" [title]="user.createdAt">{{ user.createdAt | date: 'short' }}</td>
<td *ngIf="getColumn('lastLoginDate')" [title]="user.lastLoginDate">{{ user.lastLoginDate | date: 'short' }}</td>
<td class="action-cell">
<my-user-moderation-dropdown

View File

@ -9,6 +9,11 @@ tr.banned > td {
background-color: lighten($color: $red, $amount: 40) !important;
}
.table-email {
@include disable-default-a-behaviour;
color: pvar(--mainForegroundColor);
}
.banned-info {
font-style: italic;
}
@ -36,10 +41,24 @@ p-tableCheckbox {
top: -2.5px;
}
my-global-icon {
width: 18px;
}
.chip {
@include chip;
}
.badge {
@include table-badge;
}
.progress {
@include progressbar;
width: auto;
max-width: 100%;
}
.input-group {
@include peertube-input-group(300px);
input {

View File

@ -4,7 +4,7 @@ import { AuthService, ConfirmService, Notifier, RestPagination, RestTable, Serve
import { Actor, DropdownAction } from '@app/shared/shared-main'
import { UserBanModalComponent } from '@app/shared/shared-moderation'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { ServerConfig, User } from '@shared/models'
import { ServerConfig, User, UserRole } from '@shared/models'
import { Params, Router, ActivatedRoute } from '@angular/router'
@Component({
@ -19,9 +19,12 @@ export class UserListComponent extends RestTable implements OnInit {
totalRecords = 0
sort: SortMeta = { field: 'createdAt', order: 1 }
pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
highlightBannedUsers = false
selectedUsers: User[] = []
bulkUserActions: DropdownAction<User[]>[][] = []
columns: { key: string, label: string }[]
_selectedColumns: { key: string, label: string }[]
private serverConfig: ServerConfig
@ -46,6 +49,14 @@ export class UserListComponent extends RestTable implements OnInit {
return this.serverConfig.signup.requiresEmailVerification
}
get selectedColumns () {
return this._selectedColumns
}
set selectedColumns (val) {
this._selectedColumns = val
}
ngOnInit () {
this.serverConfig = this.serverService.getTmpConfig()
this.serverService.getConfig()
@ -92,12 +103,47 @@ export class UserListComponent extends RestTable implements OnInit {
}
]
]
this.columns = [
{ key: 'username', label: 'Username' },
{ key: 'email', label: 'Email' },
{ key: 'quota', label: 'Video quota' },
{ key: 'role', label: 'Role' },
{ key: 'createdAt', label: 'Created' }
]
this.selectedColumns = [...this.columns]
this.columns.push({ key: 'quotaDaily', label: 'Daily quota' })
this.columns.push({ key: 'pluginAuth', label: 'Auth plugin' })
this.columns.push({ key: 'lastLoginDate', label: 'Last login' })
}
getIdentifier () {
return 'UserListComponent'
}
getRoleClass (role: UserRole) {
switch (role) {
case UserRole.ADMINISTRATOR:
return 'badge-purple'
case UserRole.MODERATOR:
return 'badge-blue'
default:
return 'badge-yellow'
}
}
getColumn (key: string) {
return this.selectedColumns.find((col: any) => col.key === key)
}
getUserVideoQuotaPercentage (user: User & { rawVideoQuota: number, rawVideoQuotaUsed: number}) {
return user.rawVideoQuotaUsed * 100 / user.rawVideoQuota
}
getUserVideoQuotaDailyPercentage (user: User & { rawVideoQuotaDaily: number, rawVideoQuotaUsedDaily: number}) {
return user.rawVideoQuotaUsedDaily * 100 / user.rawVideoQuotaDaily
}
openBanUserModal (users: User[]) {
for (const user of users) {
if (user.username === 'root') {

View File

@ -6,7 +6,7 @@
</a>
<div class="peertube-select-container peertube-select-button ml-2">
<select [(ngModel)]="notificationSortType" (ngModelChange)="onNotificationSortTypeChanged()" class="form-control">
<select [(ngModel)]="notificationSortType" class="form-control">
<option value="undefined" disabled>Sort by</option>
<option value="created" i18n>Newest first</option>
<option value="unread-created" i18n>Unread first</option>

View File

@ -17,6 +17,4 @@ export class MyAccountNotificationsComponent {
hasUnreadNotifications () {
return this.userNotification.notifications.filter(n => n.read === false).length !== 0
}
onNotificationSortTypeChanged () {}
}

View File

@ -374,13 +374,23 @@ export class UserService {
private formatUser (user: UserServerModel) {
let videoQuota
if (user.videoQuota === -1) {
videoQuota = this.i18n('Unlimited')
videoQuota = '∞'
} else {
videoQuota = this.bytesPipe.transform(user.videoQuota, 0)
}
const videoQuotaUsed = this.bytesPipe.transform(user.videoQuotaUsed, 0)
let videoQuotaDaily
let videoQuotaUsedDaily
if (user.videoQuotaDaily === -1) {
videoQuotaDaily = '∞'
videoQuotaUsedDaily = this.bytesPipe.transform(0, 0)
} else {
videoQuotaDaily = this.bytesPipe.transform(user.videoQuotaDaily, 0)
videoQuotaUsedDaily = this.bytesPipe.transform(user.videoQuotaUsedDaily || 0, 0)
}
const roleLabels: { [ id in UserRole ]: string } = {
[UserRole.USER]: this.i18n('User'),
[UserRole.ADMINISTRATOR]: this.i18n('Administrator'),
@ -390,7 +400,13 @@ export class UserService {
return Object.assign(user, {
roleLabel: roleLabels[user.role],
videoQuota,
videoQuotaUsed
videoQuotaUsed,
rawVideoQuota: user.videoQuota,
rawVideoQuotaUsed: user.videoQuotaUsed,
videoQuotaDaily,
videoQuotaUsedDaily,
rawVideoQuotaDaily: user.videoQuotaDaily,
rawVideoQuotaUsedDaily: user.videoQuotaUsedDaily
})
}
}

View File

@ -64,7 +64,8 @@ const icons = {
'go': require('!!raw-loader?!../../../assets/images/feather/arrow-up-right.svg').default,
'cross': require('!!raw-loader?!../../../assets/images/feather/x.svg').default,
'tick': require('!!raw-loader?!../../../assets/images/feather/check.svg').default,
'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default
'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default,
'columns': require('!!raw-loader?!../../../assets/images/feather/columns.svg').default
}
export type GlobalIconName = keyof typeof icons

View File

@ -43,7 +43,8 @@ export class VideoChannelService {
listAccountVideoChannels (
account: Account,
componentPagination?: ComponentPaginationLight,
withStats = false
withStats = false,
search?: string
): Observable<ResultList<VideoChannel>> {
const pagination = componentPagination
? this.restService.componentPaginationToRestPagination(componentPagination)
@ -53,6 +54,10 @@ export class VideoChannelService {
params = this.restService.addRestGetParams(params, pagination)
params = params.set('withStats', withStats + '')
if (search) {
params = params.set('search', search)
}
const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-channels'
return this.authHttp.get<ResultList<VideoChannelServer>>(url, { params })
.pipe(

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-columns"><path d="M12 3h7a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-7m0-18H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h7m0-18v18"></path></svg>

After

Width:  |  Height:  |  Size: 326 B

View File

@ -332,9 +332,7 @@
select {
padding: 0 35px 0 12px;
width: calc(100% + 2px);
position: relative;
left: 1px;
border: 1px solid #C6C6C6;
background: transparent none;
appearance: none;
@ -692,7 +690,21 @@
overflow: hidden;
font-size: 0.75rem;
border-radius: 0.25rem;
color: gray;
isolation: isolate;
position: relative;
span {
position: absolute;
color: rgb(92, 92, 92);
top: -1px;
&:nth-of-type(1) {
left: .2rem;
}
&:nth-of-type(2) {
right: .2rem;
}
}
.progress-bar {
color: pvar(--mainBackgroundColor);
@ -703,25 +715,11 @@
text-align: center;
white-space: nowrap;
transition: width 0.6s ease;
isolation: isolate;
&:after {
content: attr(valuenow-formatted);
position: absolute;
margin-left: .2rem;
mix-blend-mode: screen;
color: gray;
}
&.secondary {
background-color: pvar(--secondaryColor);
}
}
.progress-bar + span {
position: relative;
top: -1px;
}
}
@mixin breadcrumb {

View File

@ -16,7 +16,7 @@ import {
videoPlaylistsSortValidator
} from '../../middlewares'
import { VideoChannelModel } from '../../models/video/video-channel'
import { videoChannelsNameWithHostValidator, videosSortValidator } from '../../middlewares/validators'
import { videoChannelsNameWithHostValidator, videosSortValidator, videoChannelsOwnSearchValidator } from '../../middlewares/validators'
import { sendUpdateActor } from '../../lib/activitypub/send'
import { VideoChannelCreate, VideoChannelUpdate } from '../../../shared'
import { createLocalVideoChannel, federateAllVideosOfChannel } from '../../lib/video-channel'
@ -48,6 +48,7 @@ videoChannelRouter.get('/',
videoChannelsSortValidator,
setDefaultSort,
setDefaultPagination,
videoChannelsOwnSearchValidator,
asyncMiddleware(listVideoChannels)
)
@ -114,7 +115,13 @@ export {
async function listVideoChannels (req: express.Request, res: express.Response) {
const serverActor = await getServerActor()
const resultList = await VideoChannelModel.listForApi(serverActor.id, req.query.start, req.query.count, req.query.sort)
const resultList = await VideoChannelModel.listForApi({
actorId: serverActor.id,
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
search: req.query.search
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}

View File

@ -41,9 +41,22 @@ const videoChannelsSearchValidator = [
}
]
const videoChannelsOwnSearchValidator = [
query('search').optional().not().isEmpty().withMessage('Should have a valid search'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking video channels search query', { parameters: req.query })
if (areValidationErrors(req, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
export {
videosSearchValidator,
videoChannelsSearchValidator,
videosSearchValidator
videoChannelsOwnSearchValidator
}

View File

@ -54,6 +54,7 @@ export enum ScopeNames {
type AvailableForListOptions = {
actorId: number
search?: string
}
type AvailableWithStatsOptions = {
@ -309,15 +310,23 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
return VideoChannelModel.count(query)
}
static listForApi (actorId: number, start: number, count: number, sort: string) {
static listForApi (parameters: {
actorId: number
start: number
count: number
sort: string
search?: string
}) {
const { actorId, search } = parameters
const query = {
offset: start,
limit: count,
order: getSort(sort)
offset: parameters.start,
limit: parameters.count,
order: getSort(parameters.sort)
}
const scopes = {
method: [ ScopeNames.FOR_API, { actorId } as AvailableForListOptions ]
method: [ ScopeNames.FOR_API, { actorId, search } as AvailableForListOptions ]
}
return VideoChannelModel
.scope(scopes)