input {
@include peertube-input-text(250px);
+ flex-grow: 1;
}
}
<ng-template pTemplate="caption">
<div class="caption">
<div class="ml-auto">
- <input
- type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
- (keyup)="onSearch($event)"
- >
+ <div class="input-group">
+ <div class="input-group-prepend c-hand" ngbDropdown placement="bottom-left auto" container="body">
+ <div class="input-group-text" ngbDropdownToggle>
+ <span class="caret" aria-haspopup="menu" role="button"></span>
+ </div>
+
+ <div role="menu" ngbDropdownMenu>
+ <h6 class="dropdown-header" i18n>Filter reports</h6>
+
+ <!-- TODO:
+ <div class="dropdown-item" i18n>Reports opened by admins</div>
+ <div class="dropdown-item" i18n>Reports on videos with multiple reports</div>
+ <div class="dropdown-item" i18n>Unassigned reports</div>
+ -->
+ <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'state:pending' }" class="dropdown-item" i18n>Unsolved reports</a>
+ <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'state:accepted' }" class="dropdown-item" i18n>Accepted reports</a>
+ <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'state:rejected' }" class="dropdown-item" i18n>Refused reports</a>
+ <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'is:blocked' }" class="dropdown-item" i18n>Reports with blocked videos</a>
+ <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'is:deleted' }" class="dropdown-item" i18n>Reports with deleted videos</a>
+ </div>
+ </div>
+ <input
+ type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
+ (keyup)="onSearch($event)"
+ >
+ </div>
</div>
</div>
</ng-template>
<td class="action-cell">
<my-action-dropdown
- [ngClass]="{ 'show': expanded }" placement="bottom-right auto" container="body"
+ [ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body"
i18n-label label="Actions" [actions]="videoAbuseActions" [entry]="videoAbuse"
></my-action-dropdown>
</td>
<div class="d-flex">
<span class="col-3 moderation-expanded-label" i18n>Reporter</span>
<span class="col-9 moderation-expanded-text">
- <div class="chip">
+ <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reporter:"' + videoAbuse.reporterAccount.displayName + '"' }" class="chip">
<img
class="avatar"
[src]="videoAbuse.reporterAccount.avatar.path"
<div>
<span class="text-muted">{{ createByString(videoAbuse.reporterAccount) }}</span>
</div>
- </div>
- <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': videoAbuse.reporterAccount.displayName }" class="ml-auto text-muted video-details-links" i18n>
+ </a>
+ <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:"' + videoAbuse.reporterAccount.displayName + '"' }" class="ml-auto text-muted video-details-links" i18n>
{videoAbuse.countReportsForReporter, plural, =1 {1 report} other {{{ videoAbuse.countReportsForReporter }} reports}}<span class="ml-1Â glyphicon glyphicon-flag"></span>
</a>
</span>
<span class="text-muted">{{ videoAbuse.video.channel.ownerAccount ? createByString(videoAbuse.video.channel.ownerAccount) : '' }}</span>
</div>
</div>
- <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': videoAbuse.video.channel.ownerAccount.displayName }" class="ml-auto text-muted video-details-links" i18n>
+ <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:"' +videoAbuse.video.channel.ownerAccount.displayName + '"' }" class="ml-auto text-muted video-details-links" i18n>
{videoAbuse.countReportsForReportee, plural, =1 {1 report} other {{{ videoAbuse.countReportsForReportee }} reports}}<span class="ml-1Â glyphicon glyphicon-flag"></span>
</a>
</span>
.video-abuse-states .glyphicon-comment {
margin-left: 0.5rem;
}
+
+.input-group {
+ @include peertube-input-group(300px);
+
+ .dropdown-toggle::after {
+ margin-left: 0;
+ }
+}
margin-top: 10px;
}
-
-.input-group-append {
- height: 30px;
-}
@include peertube-input-group(fit-content);
}
-.input-group-append {
- height: 30px;
-}
-
input {
&[type=text] {
@include peertube-input-text(340px);
@include peertube-input-group(400px);
}
-.input-group-append {
- height: 30px;
-}
-
input:not([type=submit]) {
@include peertube-input-text(400px);
}
}
- .dropdown-header {
- padding-left: 1rem;
- }
-
::ng-deep form {
padding: 0.25rem 1rem;
}
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2);
font-size: 15px;
+ .dropdown-header {
+ padding-left: 1rem;
+ }
+
.dropdown-item {
padding: 3px 15px;
}
}
-.input-group > .form-control {
- flex: initial;
+.input-group {
+ & > .form-control {
+ flex: initial;
+ }
+
+ .input-group-prepend,
+ .input-group-append {
+ height: 30px;
+ }
+
+ .input-group-prepend + input {
+ border-top-left-radius: 0 !important;
+ border-bottom-left-radius: 0 !important;
+ }
}
height: 40px;
display: flex;
align-items: center;
+
+ .input-group-text {
+ background-color: transparent;
+ }
}
}
}
}
+interface QueryStringFilterPrefixes {
+ [key: string]: string | { prefix: string, handler: Function, multiple?: boolean }
+}
+
+function parseQueryStringFilter (q: string, prefixes: QueryStringFilterPrefixes) {
+ const tokens = q // tokenize only if we have a querystring
+ ? [].concat.apply([], q.split('"').map((v, i) => i % 2 ? v : v.split(' '))).filter(Boolean)
+ : []
+
+ // TODO: when Typescript supports Object.fromEntries, replace with the Object method
+ function fromEntries<T> (entries: [keyof T, T[keyof T]][]): T {
+ return entries.reduce(
+ (acc, [ key, value ]) => ({ ...acc, [key]: value }),
+ {} as T
+ )
+ }
+
+ const objectMap = (obj, fn) => fromEntries(
+ Object.entries(obj).map(
+ ([ k, v ], i) => [ k, fn(v, k, i) ]
+ )
+ )
+
+ return {
+ // search is the querystring minus defined filters
+ search: tokens.filter(e => !Object.values(prefixes).some(p => {
+ if (typeof p === "string") {
+ return e.startsWith(p)
+ } else {
+ return e.startsWith(p.prefix)
+ }
+ })).join(' '),
+ // filters defined in prefixes are added under their own name
+ ...objectMap(prefixes, v => {
+ if (typeof v === "string") {
+ return tokens.filter(e => e.startsWith(v)).map(e => e.slice(v.length))
+ } else {
+ const _tokens = tokens.filter(e => e.startsWith(v.prefix)).map(e => e.slice(v.prefix.length)).map(v.handler)
+ return !v.multiple
+ ? _tokens.length > 0
+ ? _tokens[0]
+ : ''
+ : _tokens
+ }
+ })
+ }
+}
+
// ---------------------------------------------------------------------------
export {
getFollowsSort,
buildDirectionAndField,
createSafeIn,
- searchAttribute
+ searchAttribute,
+ parseQueryStringFilter
}
// ---------------------------------------------------------------------------
isVideoAbuseStateValid
} from '../../helpers/custom-validators/video-abuses'
import { AccountModel } from '../account/account'
-import { buildBlockedAccountSQL, getSort, throwIfNotValid, searchAttribute } from '../utils'
+import { buildBlockedAccountSQL, getSort, throwIfNotValid, searchAttribute, parseQueryStringFilter } from '../utils'
import { VideoModel } from './video'
import { VideoAbuseState, VideoDetails } from '../../../shared'
import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants'
@Scopes(() => ({
[ScopeNames.FOR_API]: (options: {
+ // search
search?: string
searchReporter?: string
+ searchReportee?: string
searchVideo?: string
searchVideoChannel?: string
+ // filters
+ id?: number
+ state?: VideoAbuseState
+ is?: any
+ // accountIds
serverAccountId: number
userAccountId: number
}) => {
})
}
+ if (options.id) {
+ where = Object.assign(where, {
+ id: options.id
+ })
+ }
+
+ if (options.state) {
+ where = Object.assign(where, {
+ state: options.state
+ })
+ }
+
+ if (options.is) {
+ where = Object.assign(where, {
+ ...options.is
+ })
+ }
+
return {
attributes: {
include: [
},
{
model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }),
- where: searchAttribute(options.searchVideoChannel, 'name')
+ where: searchAttribute(options.searchVideoChannel, 'name'),
+ include: [
+ {
+ model: AccountModel,
+ where: searchAttribute(options.searchReportee, 'name')
+ }
+ ]
},
{
attributes: [ 'id', 'reason', 'unfederated' ],
}
const filters = {
- search,
+ ...parseQueryStringFilter(search, {
+ id: {
+ prefix: '#',
+ handler: v => v
+ },
+ state: {
+ prefix: 'state:',
+ handler: v => {
+ if (v === "accepted") return VideoAbuseState.ACCEPTED
+ if (v === "pending") return VideoAbuseState.PENDING
+ if (v === "rejected") return VideoAbuseState.REJECTED
+ return undefined
+ }
+ },
+ is: {
+ prefix: 'is:',
+ handler: v => {
+ if (v === "deleted") return { deletedVideo: { [Op.not]: null } }
+ return undefined
+ }
+ },
+ searchReporter: {
+ prefix: 'reporter:',
+ handler: v => v
+ },
+ searchReportee: {
+ prefix: 'reportee:',
+ handler: v => v
+ }
+ }),
serverAccountId,
userAccountId
}