mirror of
https://github.com/Terncode/pixel.horse.git
synced 2026-09-24 21:55:52 +02:00
Archive commit
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
div(*ngIf="account")
|
||||
.row
|
||||
.col-md-12
|
||||
h2
|
||||
| {{account.name}}
|
||||
small.text-muted.ml-1
|
||||
em {{account.name | translit}}
|
||||
|
||||
.row
|
||||
.col-md-4
|
||||
.mb-2
|
||||
account-info([account]="account" popoverPlacement="right")
|
||||
.mb-2
|
||||
code.text-muted {{account._id}}
|
||||
.mb-2
|
||||
div
|
||||
span.text-muted created:
|
||||
from-now.ml-1([time]="account.createdAt")
|
||||
span.text-muted.ml-1 ({{account.createdAt | date}})
|
||||
div
|
||||
span.text-muted last visit:
|
||||
from-now.ml-1([time]="account.lastVisit")
|
||||
span.text-muted.ml-1 ({{account.lastVisit | date}})
|
||||
div
|
||||
span.text-muted last browser:
|
||||
ua-info.ml-1([userAgent]="accountObject?.lastUserAgent")
|
||||
div
|
||||
span.text-muted age:
|
||||
span([class.text-strike]="account.birthyear")
|
||||
span.ml-1 {{age}}
|
||||
span.text-muted.ml-1 ({{account.birthdate | date:'yyyy-MM-dd'}})
|
||||
span(*ngIf="account.birthyear")
|
||||
span.ml-1 {{forceAge}}
|
||||
span.text-muted.ml-1 ({{account.birthyear}})
|
||||
.d-inline-block.dropdown(dropdown)
|
||||
a.icon-button.ml-1(dropdownToggle)
|
||||
fa-icon([icon]="cogIcon")
|
||||
.dropdown-menu(*dropdownMenu)
|
||||
button.dropdown-item((click)="setAge(-1)")
|
||||
| unset
|
||||
button.dropdown-item(*ngFor="let age of ages" (click)="setAge(age)")
|
||||
| set to #[b {{age}}]yo
|
||||
|
||||
.d-flex.justify-content-between.align-items-start
|
||||
h5 Counters
|
||||
div
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="refreshDetails()" title="Refresh account details")
|
||||
fa-icon([icon]="syncIcon" [fixedWidth]="true")
|
||||
.mb-2
|
||||
div(*ngFor="let counter of counters")
|
||||
span.text-muted {{counter.key}}:
|
||||
span.ml-1 {{counter.value}}
|
||||
|
||||
.d-flex.justify-content-between.align-items-start
|
||||
h5 Status
|
||||
div
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="clearSessions()" title="Sign out user" [disabled]="clearingSessions")
|
||||
fa-icon([icon]="clearingSessions ? spinnerIcon : signOutIcon" [fixedWidth]="true" [spin]="clearingSessions")
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="showAccountData()" title="Fetch account data (in console)")
|
||||
fa-icon([icon]="consoleIcon" [fixedWidth]="true")
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="accountStatus.refresh()" title="Refresh status")
|
||||
fa-icon([icon]="syncIcon" [fixedWidth]="true")
|
||||
|
||||
.mb-2
|
||||
account-status(#accountStatus [account]="account" [verbose]="true")
|
||||
|
||||
h5 Roles
|
||||
.mb-2
|
||||
.btn-group.btn-group-sm
|
||||
button.btn(*ngFor="let r of roles" [btnHighlight]="hasRole(r)" (click)="toggleRole(r)" [disabled]="!canToggleRole(r)")
|
||||
| {{r}}
|
||||
button.btn.btn-sm.btn-danger.ml-1((click)="remove()" [disabled]="!canRemove" title="Delete account")
|
||||
fa-icon([icon]="trashIcon" [fixedWidth]="true")
|
||||
|
||||
.d-flex.justify-content-between.align-items-start
|
||||
h5
|
||||
| Notes
|
||||
a.text-muted.ml-1([href]="translateUrl(account.note)" target="_blank" title="Translate note")
|
||||
fa-icon([icon]="langIcon")
|
||||
.text-muted
|
||||
from-now([time]="account.noteUpdated")
|
||||
.well.pre-line.text-truncate
|
||||
| {{account.note}}
|
||||
|
||||
.d-flex.justify-content-between.align-items-start
|
||||
h5 Merges #[span.text-muted ({{merges?.length || 0}})]
|
||||
div
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="refreshDetails()" title="Refresh account details")
|
||||
fa-icon([icon]="syncIcon" [fixedWidth]="true")
|
||||
|
||||
table.table.table-sm(*ngIf="merges?.length")
|
||||
thead
|
||||
tr
|
||||
th id
|
||||
th name
|
||||
th date
|
||||
tbody
|
||||
tr(*ngFor="let m of merges" [class.text-slashed]="m.split")
|
||||
td
|
||||
ng-template(#mergeTooltip)
|
||||
div {{getMergeTooltip(m)}}
|
||||
div([tooltip]="mergeTooltip" placement="right" containerClass="tooltip-merge")
|
||||
| {{m.id}}
|
||||
td {{m.name}}
|
||||
td
|
||||
.d-flex
|
||||
.flex-grow-1
|
||||
time-field([time]="m.date")
|
||||
.dropdown.float-right(dropdown)
|
||||
button.btn.btn-xs.btn-default.dropdown-toggle(
|
||||
dropdownToggle [tooltip]="(m.reason || '[null reason]') + (m.split ? ' (split)' : '')")
|
||||
fa-icon([icon]="cogIcon" [fixedWidth]="true")
|
||||
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
|
||||
button.dropdown-item((click)="printMerge(m)")
|
||||
| Print merge info
|
||||
button.dropdown-item((click)="printMerge2(m)")
|
||||
| Print usable merge info
|
||||
button.dropdown-item((click)="showMergeInNewTab(m)")
|
||||
| Show merge info
|
||||
button.dropdown-item((click)="showMergeInNewTab2(m)")
|
||||
| Show usable merge info
|
||||
.dropdown-divider
|
||||
button.dropdown-item((click)="unmerge(m._id, m.data.merge, m.data.account)")
|
||||
| Split off #[b merged]
|
||||
button.dropdown-item((click)="unmerge(m._id, m.data.account, m.data.merge)")
|
||||
| Split off #[b account]
|
||||
|
||||
h5 Support #[span.text-muted ({{support?.length || 0}})]
|
||||
table.table.table-sm(*ngIf="support?.length")
|
||||
thead
|
||||
tr
|
||||
th message
|
||||
th date
|
||||
tbody
|
||||
tr(*ngFor="let l of support")
|
||||
td
|
||||
fa-icon.mr-1([icon]="l.icon" [ngClass]="l.class")
|
||||
| {{l.message}}
|
||||
td
|
||||
time-field.mr-2([time]="l.date")
|
||||
span.text-muted ({{l.date | date:'MMM d'}})
|
||||
|
||||
h5 Ban log #[span.text-muted ({{banLog?.length || 0}})]
|
||||
table.table.table-sm(*ngIf="banLog?.length")
|
||||
thead
|
||||
tr
|
||||
th message
|
||||
th date
|
||||
tbody
|
||||
tr(*ngFor="let l of banLog")
|
||||
td {{l.message}}
|
||||
td: time-field([time]="l.date")
|
||||
|
||||
.col-md-5
|
||||
//- auths
|
||||
.d-flex.justify-content-between.align-items-start
|
||||
h5 Auths #[span.text-muted ({{auths.length}})]
|
||||
.dropdown(dropdown)
|
||||
button.btn.btn-xs.btn-default.dropdown-toggle(dropdownToggle)
|
||||
fa-icon([icon]="cogIcon" [fixedWidth]="true")
|
||||
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
|
||||
a.dropdown-item((click)="fetchAuths()")
|
||||
| Fetch auths data
|
||||
a.dropdown-item((click)="printAuthList()")
|
||||
| Print auths list
|
||||
a.dropdown-item((click)="printAuthData()")
|
||||
| Print auths data
|
||||
|
||||
.mb-2
|
||||
auth-info-edit(*ngFor="let a of auths | slice:0:authLimit" [authId]="a" [duplicates]="duplicates")
|
||||
|
||||
a.text-muted((click)="authLimit = 99999" *ngIf="authLimit < auths.length")
|
||||
| more ...
|
||||
|
||||
.well.mt-2.mb-1.py-2(*ngIf="authData" style="font-size: 12px;")
|
||||
button.close((click)="clearAuthData()" style="color: #ddd; margin-right: -5px; margin-top: -5px;") ×
|
||||
button.close((click)="copyAuthData()" style="color: #ddd; margin-right: 6px; margin-top: -5px;")
|
||||
fa-icon([icon]="copyIcon" size="xs")
|
||||
.text-muted Auths for {{authDataAccount}}
|
||||
.pre-line {{authData}}
|
||||
|
||||
//- origins
|
||||
.d-flex.justify-content-between
|
||||
h5 Origins #[span.text-muted ({{origins.length}})]
|
||||
div
|
||||
button.btn.btn-xs.btn-default((click)="clearOrigins(true, true)" title="Remove all origins older than 14 days used by only one account")
|
||||
fa-icon.mr-1([icon]="trashIcon")
|
||||
| old single
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="clearOrigins(true, false)" title="Remove all origins older than 14 days")
|
||||
fa-icon.mr-1([icon]="trashIcon")
|
||||
| old
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="clearOrigins(false, true)" title="Remove all origins used by only one account")
|
||||
fa-icon.mr-1([icon]="trashIcon")
|
||||
| single
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="clearOrigins(false, false)" title="Remove all origins")
|
||||
fa-icon.mr-1([icon]="trashIcon")
|
||||
| all
|
||||
.dropdown.d-inline-block.ml-1(dropdown)
|
||||
button.btn.btn-xs.btn-default.dropdown-toggle(dropdownToggle title="Remove all origins in region")
|
||||
fa-icon.mr-1([icon]="trashIcon")
|
||||
| region
|
||||
.dropdown-menu(*dropdownMenu)
|
||||
a.dropdown-item(*ngFor="let region of originRegions" (click)="clearOriginsInRegion(region)")
|
||||
| {{region}}
|
||||
.mb-2
|
||||
.d-flex.align-items-center.mb-1(*ngFor="let o of origins")
|
||||
origin-info-remote.flex-grow-1([originIP]="o.ip")
|
||||
small.text-muted
|
||||
from-now.mr-1([time]="o.last")
|
||||
button.btn.btn-xs.btn-default((click)="removeOrigin(o.ip)" title="Remove origin")
|
||||
fa-icon([icon]="trashIcon")
|
||||
|
||||
hr
|
||||
|
||||
//- duplicates
|
||||
.d-flex.justify-content-between.align-items-start
|
||||
h5
|
||||
| Duplicates #[span.text-muted ({{duplicates?.length}})]
|
||||
fa-icon.text-muted.ml-1(*ngIf="loadingDuplicates" [icon]="spinnerIcon" [fixedWidth]="true" [spin]="true")
|
||||
div
|
||||
button.btn.btn-xs.btn-default((click)="clearOriginsFromDuplicates()" title="Remove all origins older than 14 days from all duplicates")
|
||||
fa-icon.mr-1([icon]="trashIcon")
|
||||
| old
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="refresh()" title="Refresh duplicates")
|
||||
fa-icon([icon]="syncIcon" [fixedWidth]="true")
|
||||
|
||||
.mb-2
|
||||
.text-muted(*ngIf="!duplicates || !duplicates.length")
|
||||
| - no duplicates -
|
||||
|
||||
.mb-1.d-flex.align-items-center(*ngFor="let d of duplicates | slice:0:duplicatesLimit")
|
||||
ng-template(#duplicatePonies)
|
||||
.text-left(*ngIf="d.ponies")
|
||||
div(*ngFor="let p of d.ponies")
|
||||
| {{p}}
|
||||
|
||||
span.badge.badge-none(title="Duplicate origins" [class.deleted]="d.origins === 0")
|
||||
| {{d.origins}}
|
||||
span.badge.badge-none.ml-1(
|
||||
title="Duplicate ponies" [class.deleted]="!d.ponies || !d.ponies.length"
|
||||
[tooltip]="duplicatePonies" [isDisabled]="!d.ponies || !d.ponies.length")
|
||||
| {{d.ponies ? d.ponies.length : '-'}}
|
||||
|
||||
fa-icon.ml-1.text-alert(
|
||||
*ngIf="d.note" [icon]="duplicateNoteIcon" title="Duplicate in note")
|
||||
fa-icon.ml-1.text-muted(
|
||||
*ngIf="d.browserId" [icon]="duplicateBrowserIdIcon" title="Duplicate browser ID")
|
||||
fa-icon.ml-1.text-muted(
|
||||
*ngIf="d.userAgent" [icon]="duplicateBrowserIcon" [tooltip]="d.userAgent" title="Duplicate browser")
|
||||
fa-icon.ml-1(
|
||||
*ngIf="d.emails" [icon]="duplicateEmailIcon" title="Duplicate emails: {{d.emails}}"
|
||||
[ngClass]="d.indenticalEmail ? 'text-alert' : 'text-muted'")
|
||||
fa-icon.ml-1.text-alert(*ngIf="d.birthdate" [icon]="duplicateBirthdateIcon" title="Duplicate birthdate")
|
||||
fa-icon.ml-1.text-muted(*ngIf="d.name" [icon]="duplicateNameIcon" title="Duplicate name")
|
||||
|
||||
account-info-remote.ml-1(#info [accountId]="d.account" [extendedAuths]="true")
|
||||
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="merge(d.account)" title="Merge account")
|
||||
fa-icon([icon]="mergeIcon" size="lg")
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="chatLog.add(info.account)" title="Add to chat log")
|
||||
fa-icon([icon]="commentIcon" [fixedWidth]="true")
|
||||
|
||||
a.text-muted(*ngIf="duplicates && duplicates.length > duplicatesLimit" (click)="duplicatesLimit = 9999999")
|
||||
| more ...
|
||||
a.text-muted(*ngIf="duplicates && duplicatesLimit === 9999999" (click)="duplicatesLimit = 10")
|
||||
| less ...
|
||||
|
||||
hr
|
||||
|
||||
//- around
|
||||
.d-flex.justify-content-between.align-items-start
|
||||
h5 Around #[span.text-muted ({{around ? around.length : '-'}})]
|
||||
button.btn.btn-xs.btn-default((click)="refreshAround()" title="Refresh around")
|
||||
fa-icon([icon]="syncIcon" [fixedWidth]="true")
|
||||
|
||||
.mb-2
|
||||
.mb-1.d-flex.align-items-center(*ngFor="let a of around")
|
||||
span.badge.badge-none(title="Distance in tiles from user") {{a.distance.toFixed(1)}}
|
||||
|
||||
fa-icon.ml-1.text-alert(*ngIf="a.party" [icon]="partyIcon" title="In party with user")
|
||||
|
||||
account-info-remote.ml-1(#info [accountId]="a.account" [extendedAuths]="true" [showDuplicates]="true")
|
||||
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="merge(a.account)" title="Merge account")
|
||||
fa-icon([icon]="mergeIcon" size="lg")
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="chatLog.add(info.account)" title="Add to chat log")
|
||||
fa-icon([icon]="commentIcon" [fixedWidth]="true")
|
||||
|
||||
hr
|
||||
|
||||
//- friends
|
||||
.d-flex.justify-content-between.align-items-start
|
||||
h5 Friends #[span.text-muted ({{friends ? friends.length : '?'}})]
|
||||
button.btn.btn-xs.btn-default((click)="fetchFriends()" title="Fetch friends list")
|
||||
fa-icon([icon]="syncIcon" [fixedWidth]="true")
|
||||
|
||||
.mb-2
|
||||
.mb-1.d-flex.align-items-center(*ngFor="let a of friends | slice:0:friendsLimit")
|
||||
account-info-remote([accountId]="a")
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="removeFriend(a)" title="Remove friend")
|
||||
fa-icon([icon]="trashIcon")
|
||||
a.text-muted(*ngIf="friends && friends.length > friendsLimit" (click)="friendsLimit = 9999999")
|
||||
| more ...
|
||||
|
||||
//- supporter invites
|
||||
//- h5 Supporter invites #[span.text-muted ({{invites?.length || 0}})]
|
||||
//- div(*ngIf="invites?.length")
|
||||
.mb-1.d-flex.align-items-center(*ngFor="let i of invites")
|
||||
.text-muted.mr-2 {{i.type}}
|
||||
fa-icon.mr-2([icon]="checkIcon" [ngClass]="i.active ? 'text-success' : 'text-danger'")
|
||||
account-info-remote.ml-1([accountId]="i.target" [extendedAuths]="true")
|
||||
small.text-muted.flex-grow-1.text-right
|
||||
time-field([time]="i.createdAt")
|
||||
|
||||
hr
|
||||
|
||||
//- force hides
|
||||
div(*ngIf="account.hides && account.hides.length")
|
||||
.d-flex.justify-content-between.align-items-start
|
||||
h5 Force hidden #[span.text-muted ({{account.hides.length}})]
|
||||
|
||||
.mb-2
|
||||
.mb-1.d-flex.align-items-center(*ngFor="let a of account.hides")
|
||||
account-info-remote([accountId]="a")
|
||||
|
||||
hr
|
||||
|
||||
//- hides
|
||||
.d-flex.justify-content-between.align-items-start
|
||||
h5 Hidden by #[span.text-muted ({{hiddenBy ? hiddenBy.length : '?'}})]
|
||||
button.btn.btn-xs.btn-default((click)="fetchHidden()" title="Fetch hidden list")
|
||||
fa-icon([icon]="syncIcon" [fixedWidth]="true")
|
||||
|
||||
.mb-2
|
||||
.mb-1.d-flex.align-items-center(*ngFor="let a of hiddenBy | slice:0:hiddenByLimit")
|
||||
account-info-remote([accountId]="a")
|
||||
a.text-muted(*ngIf="hiddenBy && hiddenBy.length > hiddenByLimit" (click)="hiddenByLimit = 9999999")
|
||||
| more ...
|
||||
|
||||
h5 Hides users #[span.text-muted ({{hidden ? hidden.length : '?'}})]
|
||||
.mb-2
|
||||
.mb-1.d-flex.align-items-center(*ngFor="let a of hidden | slice:0:hiddenLimit")
|
||||
account-info-remote([accountId]="a")
|
||||
a.text-muted(*ngIf="hidden && hidden.length > hiddenLimit" (click)="hiddenLimit = 9999999")
|
||||
| more ...
|
||||
|
||||
//- hides (perma)
|
||||
.d-flex.justify-content-between.align-items-start
|
||||
h5 Perma hidden by #[span.text-muted ({{permaHiddenBy ? permaHiddenBy.length : '?'}})]
|
||||
|
||||
.mb-2
|
||||
.mb-1.d-flex.align-items-center(*ngFor="let a of permaHiddenBy | slice:0:permaHiddenByLimit")
|
||||
account-info-remote([accountId]="a")
|
||||
a.text-muted(*ngIf="permaHiddenBy && permaHiddenBy.length > permaHiddenByLimit" (click)="permaHiddenByLimit = 9999999")
|
||||
| more ...
|
||||
|
||||
h5 Perma hides users #[span.text-muted ({{permaHidden ? permaHidden.length : '?'}})]
|
||||
.mb-2
|
||||
.mb-1.d-flex.align-items-center(*ngFor="let a of permaHidden | slice:0:permaHiddenLimit")
|
||||
account-info-remote([accountId]="a")
|
||||
a.text-muted(*ngIf="permaHidden && permaHidden.length > permaHiddenLimit" (click)="permaHiddenLimit = 9999999")
|
||||
| more ...
|
||||
|
||||
hr
|
||||
|
||||
//- ignores
|
||||
.d-flex.justify-content-between.align-items-start
|
||||
h5 Ignored by #[span.text-muted ({{ignoredBy ? ignoredBy.length : account.ignoresCount}})]
|
||||
button.btn.btn-xs.btn-default((click)="fetchIgnores()" title="Fetch ignores list")
|
||||
fa-icon([icon]="syncIcon" [fixedWidth]="true")
|
||||
|
||||
.mb-2
|
||||
.mb-1.d-flex.align-items-center(*ngFor="let a of ignoredBy | slice:0:ignoredByLimit")
|
||||
account-info-remote([accountId]="a")
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="removeIgnore(a._id, account._id)" title="Remove ignore")
|
||||
fa-icon([icon]="trashIcon")
|
||||
a.text-muted(*ngIf="ignoredBy && ignoredBy.length > ignoredByLimit" (click)="ignoredByLimit = 9999999")
|
||||
| more ...
|
||||
|
||||
h5 Ignores users #[span.text-muted ({{ignores ? ignores.length : '?'}})]
|
||||
.mb-2
|
||||
.mb-1.d-flex.align-items-center(*ngFor="let a of ignores | slice:0:ignoresLimit")
|
||||
account-info-remote([accountId]="a")
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="removeIgnore(account._id, a._id)" title="Remove ignore")
|
||||
fa-icon([icon]="trashIcon")
|
||||
a.text-muted(*ngIf="ignores && ignores.length > ignoresLimit" (click)="ignoresLimit = 9999999")
|
||||
| more ...
|
||||
|
||||
.col-md-3
|
||||
//- emails
|
||||
h5.d-flex.justify-content-between
|
||||
div Emails #[span.text-muted ({{account.emails?.length || 0}})]
|
||||
.dropdown(dropdown)
|
||||
button.btn.btn-xs.btn-default.dropdown-toggle(dropdownToggle)
|
||||
fa-icon([icon]="cogIcon" [fixedWidth]="true")
|
||||
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
|
||||
a.dropdown-item((click)="addEmail()")
|
||||
| Add email
|
||||
.mb-2
|
||||
.text-muted(*ngIf="!account.emails?.length")
|
||||
| - no emails -
|
||||
.d-flex.justify-content-between.items-align-center.mb-1(*ngFor="let e of account.emails | orderBy | slice:0:emailLimit")
|
||||
div {{e}}
|
||||
button.btn.btn-xs.btn-default((click)="removeEmail(e)" title="Remove email")
|
||||
fa-icon([icon]="trashIcon")
|
||||
|
||||
a.text-muted((click)="emailLimit = 99999" *ngIf="emailLimit < (account.emails?.length || 0)")
|
||||
| more ...
|
||||
|
||||
//- ponies
|
||||
h5.d-flex.justify-content-between
|
||||
div Ponies #[span.text-muted ({{account.characterCount}})]
|
||||
.dropdown(dropdown)
|
||||
button.btn.btn-xs.btn-default.dropdown-toggle(dropdownToggle)
|
||||
fa-icon([icon]="cogIcon" [fixedWidth]="true")
|
||||
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
|
||||
a.dropdown-item((click)="getPoniesCreators()")
|
||||
| Fetch creator info
|
||||
a.dropdown-item((click)="restoringPonies = true")
|
||||
| Restore ponies
|
||||
a.dropdown-item((click)="removePoniesAboveLimit()")
|
||||
| Remove ponies above limit
|
||||
a.dropdown-item.text-danger((click)="removeAllPonies()")
|
||||
| Remove all ponies
|
||||
.mb-2(*ngIf="restoringPonies")
|
||||
textarea.form-control.mb-1([(ngModel)]="poniesToRestore" placeholder="paste removed pony logs here")
|
||||
textarea.form-control.mb-1([(ngModel)]="poniesToRestoreFilter" placeholder="paste pony ids to use here (optional)")
|
||||
.text-right
|
||||
button.btn.btn-xs.btn-default((click)="poniesToRestore = ''; restoringPonies = false") cancel
|
||||
button.btn.btn-xs.btn-success.ml-1((click)="restorePonies()" [disabled]="!poniesToRestore") restore
|
||||
hr
|
||||
.mb-2
|
||||
pony-list-remote(
|
||||
[accountId]="id" [expanded]="true" [deletable]="true" [highlight]="highlighCharacter" [duplicates]="duplicates")
|
||||
|
||||
.row
|
||||
.col-md-12
|
||||
h5 Events
|
||||
events-table([events]="events" (showChat)="chatLog.show(account)")
|
||||
|
||||
.row
|
||||
.col-md-12
|
||||
admin-chat-log(#chatLog [canClose]="false" [account]="account")
|
||||
button.btn.btn-sm.btn-default((click)="printJSON()") raw data
|
||||
|
||||
.well.pre(*ngIf="rawData")
|
||||
| {{rawData}}
|
||||
@@ -0,0 +1,17 @@
|
||||
@import 'partials/variables';
|
||||
|
||||
.spoiler-text {
|
||||
background: $body-color;
|
||||
border-radius: 3px;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.long-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { remove, uniq } from 'lodash';
|
||||
import {
|
||||
Account, Event, Auth, ROLES, Character, MergeInfo, MergeAccountData, SupporterInvite, accountFlags,
|
||||
OriginInfoBase, DuplicateResult, AroundEntry, LogEntry
|
||||
} from '../../../common/adminInterfaces';
|
||||
import { compareByName, createSupporterChanges, SupporterChange, getTranslationUrl, getAge } from '../../../common/adminUtils';
|
||||
import { hasRole } from '../../../common/accountUtils';
|
||||
import { AdminModel } from '../../services/adminModel';
|
||||
import {
|
||||
faCog, faSync, faLanguage, faTrash, faGlobe, faTerminal, faSpinner,
|
||||
faIdBadge, faEnvelope, faFont, faCompressArrowsAlt, faComment, faSignOutAlt, faUsers, faCheckCircle,
|
||||
faDatabase, faCopy, faCalendar, faExclamationCircle,
|
||||
} from '../../../client/icons';
|
||||
import { flagsToString, includes, flatten, removeItem } from '../../../common/utils';
|
||||
import { Subscription } from '../../../common/interfaces';
|
||||
import { showTextInNewTab } from '../../../client/htmlUtils';
|
||||
|
||||
const defaultLimit = 15;
|
||||
const defaultDuplicatesLimit = 10;
|
||||
const year = (new Date()).getFullYear();
|
||||
|
||||
@Component({
|
||||
selector: 'admin-account-details',
|
||||
templateUrl: 'admin-account-details.pug',
|
||||
styleUrls: ['admin-account-details.scss'],
|
||||
})
|
||||
export class AdminAccountDetails implements OnInit, OnDestroy {
|
||||
readonly cogIcon = faCog;
|
||||
readonly syncIcon = faSync;
|
||||
readonly langIcon = faLanguage;
|
||||
readonly trashIcon = faTrash;
|
||||
readonly signOutIcon = faSignOutAlt;
|
||||
readonly duplicateNoteIcon = faExclamationCircle;
|
||||
readonly duplicateBrowserIcon = faGlobe;
|
||||
readonly duplicateBrowserIdIcon = faIdBadge;
|
||||
readonly duplicateEmailIcon = faEnvelope;
|
||||
readonly duplicateNameIcon = faFont;
|
||||
readonly duplicateBirthdateIcon = faCalendar;
|
||||
readonly mergeIcon = faCompressArrowsAlt;
|
||||
readonly commentIcon = faComment;
|
||||
readonly partyIcon = faUsers;
|
||||
readonly checkIcon = faCheckCircle;
|
||||
readonly consoleIcon = faTerminal;
|
||||
readonly spinnerIcon = faSpinner;
|
||||
readonly dataIcon = faDatabase;
|
||||
readonly copyIcon = faCopy;
|
||||
readonly roles = ROLES;
|
||||
ages = [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18];
|
||||
account?: Account;
|
||||
duplicates?: DuplicateResult[];
|
||||
ponyNames?: string[];
|
||||
ignores?: string[];
|
||||
ignoredBy?: string[];
|
||||
hidden?: string[];
|
||||
hiddenBy?: string[];
|
||||
permaHidden?: string[];
|
||||
permaHiddenBy?: string[];
|
||||
friends?: string[];
|
||||
events?: Event[];
|
||||
merges?: MergeInfo[];
|
||||
banLog?: LogEntry[];
|
||||
support?: SupporterChange[];
|
||||
rawData?: string;
|
||||
invites?: SupporterInvite[];
|
||||
auths: string[] = [];
|
||||
origins: OriginInfoBase[] = [];
|
||||
counters: { key: string; value: any; }[] = [];
|
||||
authLimit = defaultLimit;
|
||||
emailLimit = defaultLimit;
|
||||
loadingDuplicates = false;
|
||||
accountObject?: Account;
|
||||
clearingSessions = false;
|
||||
authData?: string;
|
||||
authDataAccount?: string;
|
||||
restoringPonies = false;
|
||||
poniesToRestore = '';
|
||||
poniesToRestoreFilter = '';
|
||||
friendsLimit = defaultLimit;
|
||||
hiddenByLimit = defaultLimit;
|
||||
hiddenLimit = defaultLimit;
|
||||
permaHiddenByLimit = defaultLimit;
|
||||
permaHiddenLimit = defaultLimit;
|
||||
private id?: string;
|
||||
private aroundMap = new Map<string, AroundEntry[]>();
|
||||
private duplicatesLimits = new Map<string, number>();
|
||||
private ignoresLimits = new Map<string, number>();
|
||||
private ignoredByLimits = new Map<string, number>();
|
||||
private authsSubscription?: Subscription;
|
||||
private accountSubscription?: Subscription;
|
||||
private originsSubscription?: Subscription;
|
||||
constructor(private route: ActivatedRoute, private model: AdminModel) {
|
||||
}
|
||||
get canRemove() {
|
||||
return hasRole(this.model.account, 'superadmin');
|
||||
}
|
||||
get around() {
|
||||
return this.aroundMap.get(this.id || '');
|
||||
}
|
||||
get duplicatesLimit() {
|
||||
return (this.id && this.duplicatesLimits.has(this.id)) ? this.duplicatesLimits.get(this.id)! : defaultDuplicatesLimit;
|
||||
}
|
||||
set duplicatesLimit(value) {
|
||||
if (this.id) {
|
||||
this.duplicatesLimits.set(this.id, value);
|
||||
}
|
||||
}
|
||||
get ignoresLimit() {
|
||||
return (this.id && this.ignoresLimits.has(this.id)) ? this.ignoresLimits.get(this.id)! : defaultLimit;
|
||||
}
|
||||
set ignoresLimit(value) {
|
||||
if (this.id) {
|
||||
this.ignoresLimits.set(this.id, value);
|
||||
}
|
||||
}
|
||||
get ignoredByLimit() {
|
||||
return (this.id && this.ignoredByLimits.has(this.id)) ? this.ignoredByLimits.get(this.id)! : defaultLimit;
|
||||
}
|
||||
set ignoredByLimit(value) {
|
||||
if (this.id) {
|
||||
this.ignoredByLimits.set(this.id, value);
|
||||
}
|
||||
}
|
||||
get originRegions() {
|
||||
return uniq(this.origins.map(o => o.country));
|
||||
}
|
||||
get age() {
|
||||
return (this.account && this.account.birthdate) ? getAge(this.account.birthdate) : '-';
|
||||
}
|
||||
get forceAge() {
|
||||
return (this.account && this.account.birthyear) ? (year - this.account.birthyear) : '-';
|
||||
}
|
||||
ngOnInit() {
|
||||
this.route.params.subscribe(({ id }) => {
|
||||
this.id = id;
|
||||
this.update();
|
||||
});
|
||||
|
||||
this.model.updated = () => this.update();
|
||||
this.update();
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.model.updated = () => { };
|
||||
this.authsSubscription && this.authsSubscription.unsubscribe();
|
||||
this.accountSubscription && this.accountSubscription.unsubscribe();
|
||||
this.originsSubscription && this.originsSubscription.unsubscribe();
|
||||
}
|
||||
refresh() {
|
||||
const account = this.account;
|
||||
|
||||
this.ponyNames = [];
|
||||
this.banLog = undefined;
|
||||
this.merges = undefined;
|
||||
this.support = undefined;
|
||||
this.invites = undefined;
|
||||
this.accountObject = undefined;
|
||||
this.loadingDuplicates = false;
|
||||
|
||||
this.authsSubscription && this.authsSubscription.unsubscribe();
|
||||
this.authsSubscription = undefined;
|
||||
|
||||
this.originsSubscription && this.originsSubscription.unsubscribe();
|
||||
this.originsSubscription = undefined;
|
||||
|
||||
this.auths = [];
|
||||
this.origins = [];
|
||||
this.counters = [];
|
||||
|
||||
this.friends = undefined;
|
||||
this.hidden = undefined;
|
||||
this.hiddenBy = undefined;
|
||||
this.permaHidden = undefined;
|
||||
this.permaHiddenBy = undefined;
|
||||
|
||||
if (account) {
|
||||
account.ignoredByLimit = account.ignoredByLimit || 10;
|
||||
account.ignoresLimit = account.ignoresLimit || 10;
|
||||
account.duplicatesLimit = account.duplicatesLimit || 10;
|
||||
|
||||
this.loadingDuplicates = true;
|
||||
this.model.getAllDuplicates(account._id)
|
||||
.then(duplicates => {
|
||||
if (duplicates) {
|
||||
this.ponyNames = uniq(flatten(duplicates.map(d => (d.ponies || []).map(x => x.toLowerCase()))));
|
||||
this.duplicates = duplicates;
|
||||
this.loadingDuplicates = false;
|
||||
}
|
||||
});
|
||||
|
||||
this.model.getAccount(account._id)
|
||||
.then(account => this.accountObject = account);
|
||||
|
||||
this.refreshDetails();
|
||||
|
||||
this.authsSubscription = this.model.accountAuths
|
||||
.subscribe(account._id, auths => this.auths = auths || []);
|
||||
|
||||
this.originsSubscription = this.model.accountOrigins
|
||||
.subscribe(account._id, origins => this.origins = origins || []);
|
||||
} else {
|
||||
this.duplicates = [];
|
||||
this.ignores = [];
|
||||
this.ignoredBy = [];
|
||||
}
|
||||
}
|
||||
refreshAround() {
|
||||
const account = this.account;
|
||||
|
||||
if (account) {
|
||||
this.model.getAccountAround(account._id)
|
||||
.then(accounts => {
|
||||
if (accounts) {
|
||||
this.aroundMap.set(account._id, accounts);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
refreshDetails() {
|
||||
function createCounter(key: string, value: any) {
|
||||
if (key === 'toys') {
|
||||
value = (value >>> 0).toString(2).padStart(32, '0');
|
||||
}
|
||||
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
if (this.account) {
|
||||
this.model.getDetailsForAccount(this.account)
|
||||
.then(details => {
|
||||
if (details) {
|
||||
this.banLog = details.banLog;
|
||||
this.merges = details.merges;
|
||||
this.support = createSupporterChanges(details.supporterLog);
|
||||
this.invites = [
|
||||
...details.invitesSent!.map(i => ({ ...i, type: 'sent' })),
|
||||
...details.invitesReceived!.map(i => ({ ...i, type: 'recv' })),
|
||||
];
|
||||
const state = details.state as any;
|
||||
this.counters = Object.keys(state).map(key => createCounter(key, state[key]));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
fetchIgnores() {
|
||||
if (this.id) {
|
||||
this.model.getIgnoresAndIgnoredBy(this.id)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
this.ignores = result.ignores;
|
||||
this.ignoredBy = result.ignoredBy;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
fetchHidden() {
|
||||
if (this.id) {
|
||||
this.model.getAccountHidden(this.id)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
this.hidden = result.hidden;
|
||||
this.hiddenBy = result.hiddenBy;
|
||||
this.permaHidden = result.permaHidden;
|
||||
this.permaHiddenBy = result.permaHiddenBy;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
fetchFriends() {
|
||||
if (this.id) {
|
||||
this.model.getAccountFriends(this.id)
|
||||
.then(result => this.friends = result);
|
||||
}
|
||||
}
|
||||
removeFriend(friendId: string) {
|
||||
if (this.id && confirm('Are you sure ?')) {
|
||||
this.model.removeFriend(this.id, friendId)
|
||||
.then(() => this.friends && removeItem(this.friends, friendId));
|
||||
}
|
||||
}
|
||||
canToggleRole(role: string) {
|
||||
return role !== 'superadmin' && hasRole(this.model.account, 'superadmin');
|
||||
}
|
||||
hasRole(role: string) {
|
||||
return hasRole(this.account, role);
|
||||
}
|
||||
toggleRole(role: string) {
|
||||
if (this.id) {
|
||||
this.model.setRole(this.id, role, !this.hasRole(role));
|
||||
}
|
||||
}
|
||||
showAccountData() {
|
||||
if (this.id) {
|
||||
this.model.getAccount(this.id)
|
||||
.then(account => {
|
||||
(window as any).$data = account;
|
||||
console.log(account);
|
||||
console.log('accessible in $data');
|
||||
});
|
||||
}
|
||||
}
|
||||
printAuthList() {
|
||||
this.forAuths(auths => {
|
||||
const authList = auths.map((a, i) => `${i + 1}. [${a.provider}] ${a.name || '<no name>'}`).join('\n');
|
||||
this.authData = authList;
|
||||
this.authDataAccount = this.account ? `${this.account.name} [${this.account._id}]` : '';
|
||||
console.log(authList);
|
||||
});
|
||||
}
|
||||
printAuthData() {
|
||||
this.forAuths(auths => {
|
||||
const authData = auths.map((a, i) => `${i + 1}. [${a._id}] [${a.provider}] ${a.name || '<no name>'} (${a.emails})`).join('\n');
|
||||
this.authData = authData;
|
||||
this.authDataAccount = this.account ? `${this.account.name} [${this.account._id}]` : '';
|
||||
console.log(authData);
|
||||
});
|
||||
}
|
||||
copyAuthData() {
|
||||
if ('clipboard' in navigator) {
|
||||
(navigator as any).clipboard.writeText(this.authData);
|
||||
}
|
||||
}
|
||||
clearAuthData() {
|
||||
this.authData = undefined;
|
||||
this.authDataAccount = undefined;
|
||||
}
|
||||
fetchAuths() {
|
||||
this.forAuths(auths => {
|
||||
(window as any).$auths = auths;
|
||||
console.log(auths);
|
||||
});
|
||||
}
|
||||
private forAuths(callback: (auths: Auth[]) => void) {
|
||||
if (this.account) {
|
||||
this.model.getAuthsForAccount(this.account._id)
|
||||
.then(auths => callback(auths || []));
|
||||
}
|
||||
}
|
||||
printJSON() {
|
||||
if (this.id) {
|
||||
this.model.getAccount(this.id)
|
||||
.then(account => this.rawData = JSON.stringify(account, undefined, 2));
|
||||
}
|
||||
}
|
||||
clearOrigins(old: boolean, singles: boolean) {
|
||||
if (this.id) {
|
||||
this.model.clearOriginsForAccount(this.id, { old, singles })
|
||||
.then(() => this.refresh());
|
||||
}
|
||||
}
|
||||
clearOriginsInRegion(country: string) {
|
||||
if (this.id) {
|
||||
this.model.clearOriginsForAccount(this.id, { country })
|
||||
.then(() => this.refresh());
|
||||
}
|
||||
}
|
||||
clearOriginsFromDuplicates() {
|
||||
if (this.account) {
|
||||
const duplicates = (this.duplicates || []).slice(0, this.account.duplicatesLimit || 0);
|
||||
const accounts = duplicates.map(d => d.account);
|
||||
this.model.clearOriginsForAccounts(accounts, { old: true })
|
||||
.then(() => this.refresh());
|
||||
}
|
||||
}
|
||||
removeOrigin(ip: string) {
|
||||
if (this.id) {
|
||||
this.model.removeOriginsForAccount(this.id, [ip])
|
||||
.then(() => this.refresh());
|
||||
}
|
||||
}
|
||||
merge(accountId: string) {
|
||||
if (this.account && confirm('Are you sure?')) {
|
||||
this.model.mergeAccounts(this.account._id, accountId)
|
||||
.then(() => remove(this.duplicates || [], d => d.account === accountId))
|
||||
.then(() => this.refresh());
|
||||
}
|
||||
}
|
||||
remove() {
|
||||
if (this.account && confirm('Are you sure?')) {
|
||||
this.model.removeAccount(this.account._id);
|
||||
}
|
||||
}
|
||||
translateUrl(text: string) {
|
||||
return getTranslationUrl(text);
|
||||
}
|
||||
getPoniesCreators() {
|
||||
if (this.account) {
|
||||
this.model.getPoniesCreators(this.account._id)
|
||||
.then(items => {
|
||||
if (items) {
|
||||
items.sort(compareByName);
|
||||
(window as any).$ponies = items;
|
||||
console.log(items.map(i => `[${i._id}] "${i.name}" ${i.creator}`).join('\n'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
removePoniesAboveLimit() {
|
||||
if (this.account && confirm('Are you sure?')) {
|
||||
this.model.removePoniesAboveLimit(this.account._id);
|
||||
}
|
||||
}
|
||||
removeAllPonies() {
|
||||
if (this.account && confirm('Are you sure?')) {
|
||||
this.model.removeAllPonies(this.account._id);
|
||||
}
|
||||
}
|
||||
restorePonies() {
|
||||
if (this.account && this.poniesToRestore) {
|
||||
let ids: string[] | undefined = undefined;
|
||||
|
||||
if (this.poniesToRestoreFilter) {
|
||||
const matches = Array.from(this.poniesToRestoreFilter.match(/\[[a-f0-9]{24}\]/g) || [])
|
||||
.map(id => id.substr(1, 24));
|
||||
|
||||
if (matches.length) {
|
||||
ids = matches;
|
||||
}
|
||||
}
|
||||
|
||||
this.model.restorePonies(this.account._id, this.poniesToRestore, ids);
|
||||
this.poniesToRestore = '';
|
||||
this.restoringPonies = false;
|
||||
}
|
||||
}
|
||||
removeEmail(email: string) {
|
||||
if (this.account && confirm('Are you sure?')) {
|
||||
this.model.removeEmail(this.account._id, email);
|
||||
}
|
||||
}
|
||||
removeIgnore(account: string, ignoredAccount: string) {
|
||||
this.model.removeIgnore(account, ignoredAccount)
|
||||
.then(() => this.fetchIgnores());
|
||||
}
|
||||
highlighCharacter = (char: Character | undefined): boolean => {
|
||||
return !!char && includes(this.ponyNames, char.name.toLowerCase());
|
||||
}
|
||||
clearSessions() {
|
||||
if (this.account) {
|
||||
this.clearingSessions = true;
|
||||
this.model.clearSessions(this.account._id)
|
||||
.finally(() => this.clearingSessions = false);
|
||||
}
|
||||
}
|
||||
getMergeTooltip(merge: MergeInfo) {
|
||||
function mergeInfo(title: string, account: MergeAccountData) {
|
||||
return [
|
||||
title,
|
||||
`\tname: ${account.name}`,
|
||||
`\tnote: ${account.note || ''}`,
|
||||
`\tflags: ${flagsToString(account.flags, accountFlags)}`,
|
||||
`\tage: ${account.birthdate ? getAge(account.birthdate) : '-'}`,
|
||||
`\tfriends: ${account.friends ? account.friends.length : 0}`,
|
||||
`\tcounters:`,
|
||||
...Object.keys(account.counters || {}).sort().map(key => `\t\t${key}: ${(account.counters as any)[key]}`),
|
||||
`\tstate:`,
|
||||
...(account.state ? JSON.stringify(account.state, null, 2).split(/\n/).map(x => `\t\t${x}`) : []),
|
||||
`\temails:`,
|
||||
...account.emails.map(e => `\t\t${e}`),
|
||||
`\tauths:`,
|
||||
...account.auths.map(a => `\t\t[${a.id}] ${a.name}`),
|
||||
`\tcharacters: ${account.characters.length}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (merge.data) {
|
||||
return `${mergeInfo('ACCOUNT', merge.data.account)}\n\n${mergeInfo('MERGED', merge.data.merge)}`;
|
||||
} else {
|
||||
return '<empty>';
|
||||
}
|
||||
}
|
||||
printMerge(merge: MergeInfo) {
|
||||
console.log(this.mergeInfo(merge));
|
||||
}
|
||||
printMerge2(merge: MergeInfo) {
|
||||
console.log(this.mergeInfo2(merge));
|
||||
}
|
||||
mergeInfo(merge: MergeInfo) {
|
||||
function mergeInfo(title: string, account: MergeAccountData) {
|
||||
return [
|
||||
title,
|
||||
`\tname: ${account.name}`,
|
||||
`\tnote: ${account.note || ''}`,
|
||||
`\tflags: ${flagsToString(account.flags, accountFlags)}`,
|
||||
`\tage: ${account.birthdate ? getAge(account.birthdate) : '-'}`,
|
||||
`\tcounters:`,
|
||||
...Object.keys(account.counters || {}).sort().map(key => `\t\t${key}: ${(account.counters as any)[key]}`),
|
||||
`\tstate:`,
|
||||
...(account.state ? JSON.stringify(account.state, null, 2).split(/\n/).map(x => `\t\t${x}`) : []),
|
||||
`\temails:`,
|
||||
...account.emails.map(e => `\t\t${e}`),
|
||||
`\tauths:`,
|
||||
...account.auths.map(a => `\t\t[${a.id}] ${a.name}`),
|
||||
`\tcharacters:`,
|
||||
...account.characters.map(a => `\t\t[${a.id}] ${a.name}`),
|
||||
`\tfriends:`,
|
||||
...(account.friends || []).map(i => `\t\t[${i}]`),
|
||||
`\tignores:`,
|
||||
...(account.ignores || []).map(i => `\t\t[${i}]`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
return merge.data && `${mergeInfo('ACCOUNT', merge.data.account)}\n\n${mergeInfo('MERGED', merge.data.merge)}`;
|
||||
}
|
||||
private mergeInfo2(merge: MergeInfo) {
|
||||
function mergeInfo(title: string, account: MergeAccountData) {
|
||||
return [
|
||||
title,
|
||||
`\tname: ${JSON.stringify(account.name)}`,
|
||||
`\tnote: ${JSON.stringify(account.note)}`,
|
||||
`\tflags: ${JSON.stringify(account.flags)}`,
|
||||
`\tcounters: ${JSON.stringify(account.counters)}`,
|
||||
`\tstate: ${JSON.stringify(account.state)}`,
|
||||
`\temails: ${JSON.stringify(account.emails)}`,
|
||||
`\tauths: ${JSON.stringify(account.auths)}`,
|
||||
`\tcharacters: ${JSON.stringify(account.characters)}`,
|
||||
`\tfriends: ${JSON.stringify(account.friends)}`,
|
||||
`\tignores: ${JSON.stringify(account.ignores)}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
return merge.data && `${mergeInfo('ACCOUNT', merge.data.account)}\n\n${mergeInfo('MERGED', merge.data.merge)}`;
|
||||
}
|
||||
showMergeInNewTab(merge: MergeInfo) {
|
||||
showTextInNewTab(this.mergeInfo(merge) || '');
|
||||
}
|
||||
showMergeInNewTab2(merge: MergeInfo) {
|
||||
showTextInNewTab(this.mergeInfo2(merge) || '');
|
||||
}
|
||||
unmerge(mergeId: string | undefined, split: MergeAccountData, keep: MergeAccountData) {
|
||||
if (this.account) {
|
||||
this.model.unmergeAccounts(this.account._id, mergeId, split, keep)
|
||||
.then(() => this.refresh());
|
||||
}
|
||||
}
|
||||
setAge(age: number) {
|
||||
if (this.account) {
|
||||
this.model.setAge(this.account._id, age);
|
||||
}
|
||||
}
|
||||
addEmail() {
|
||||
if (this.account) {
|
||||
const email = prompt('enter email');
|
||||
|
||||
if (email && /@/.test(email)) {
|
||||
this.model.addEmail(this.account._id, email);
|
||||
}
|
||||
}
|
||||
}
|
||||
private update() {
|
||||
if (this.id) {
|
||||
this.accountSubscription && this.accountSubscription.unsubscribe();
|
||||
this.accountSubscription = this.id ? this.model.accounts.subscribe(this.id, a => this.setAccount(a)) : undefined;
|
||||
|
||||
this.events = this.model.events
|
||||
.filter(e => e.account === this.id)
|
||||
.slice(0, 10);
|
||||
}
|
||||
}
|
||||
private setAccount(account: Account | undefined) {
|
||||
const theSame = this.account === account || (this.account && account && this.account._id === account._id);
|
||||
this.account = account;
|
||||
|
||||
if (!theSame) {
|
||||
this.duplicates = [];
|
||||
this.ponyNames = [];
|
||||
this.ignores = [];
|
||||
this.ignoredBy = [];
|
||||
this.rawData = undefined;
|
||||
this.authLimit = defaultLimit;
|
||||
this.emailLimit = defaultLimit;
|
||||
this.duplicatesLimit = defaultDuplicatesLimit;
|
||||
this.refresh();
|
||||
this.friendsLimit = defaultLimit;
|
||||
this.hiddenByLimit = defaultLimit;
|
||||
this.hiddenLimit = defaultLimit;
|
||||
this.permaHiddenByLimit = defaultLimit;
|
||||
this.permaHiddenLimit = defaultLimit;
|
||||
(window as any).$account = account;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
ng-template(#searchTooltip)
|
||||
.text-left.help-tooltip
|
||||
div: em search by:
|
||||
div name:<account_name> #[em - account name]
|
||||
div note:<note_text> #[em - note contents]
|
||||
div email:<email_fragment> #[em - email fragment]
|
||||
div role:<role_name> #[em - role]
|
||||
div exact:<name> #[em - exact account name]
|
||||
div ignores:<count> #[em - amount of ignores (at least)]
|
||||
div ponies:<count> #[em - amount of ponies (at least)]
|
||||
div auths:<count> #[em - amount of auths (at least)]
|
||||
div old:<number_of_days> #[em - days since last visit]
|
||||
div disabled! #[em - list accounts with disabled auths]
|
||||
div locked! #[em - list accounts with locked auths]
|
||||
div spam/swering/timeouts/limits:<number>
|
||||
|
||||
ng-template(#duplicatesPopover)
|
||||
a.text-secondary.d-block(*ngFor="let e of duplicateEntries" (click)="search = e")
|
||||
| {{e}}
|
||||
|
||||
.d-block.d-sm-flex
|
||||
input.form-control.form-control-sm.admin-search(type="search" placeholder="search" [(ngModel)]="search")
|
||||
.btn-group.btn-group-sm.ml-1
|
||||
button.btn.btn-default((click)="refresh(true)")
|
||||
fa-icon([icon]="syncIcon" size="lg")
|
||||
button.btn(btnCheckbox [(ngModel)]="autoRefresh" [btnHighlight]="autoRefresh")
|
||||
| auto
|
||||
.btn-group.btn-group-sm.ml-1
|
||||
button.btn.btn-sm.btn-danger(
|
||||
*ngIf="duplicateEntries?.length" [popover]="duplicatesPopover" [outsideClick]="true"
|
||||
placement="bottom" containerClass="popover-wide")
|
||||
| Duplicates ({{duplicateEntries.length}})
|
||||
button.btn.btn-sm.btn-danger((click)="refreshDuplicates()" title="Refresh duplicates")
|
||||
fa-icon([icon]="syncIcon" size="lg")
|
||||
.btn-group.btn-group-sm.ml-1(btnRadioGroup [(ngModel)]="showOnly")
|
||||
button.btn(*ngFor="let f of filters" [btnRadio]="f" [btnHighlight]="showOnly === f") {{f}}
|
||||
button.btn.btn-sm.ml-1((click)="not = !not" [btnHighlight]="not") not
|
||||
button.btn.btn-sm.btn-default.ml-2((click)="createAccount()") create account
|
||||
|
||||
.d-block.d-sm-flex.justify-content-between.align-items-start
|
||||
.pl-1([tooltip]="searchTooltip" placement="bottom" containerClass="tooltip-pre" style="width: 280px;")
|
||||
small.text-muted
|
||||
fa-icon.mr-1([icon]="filterIcon")
|
||||
| filters
|
||||
.d-block.d-sm-flex.mt-2
|
||||
fa-icon.text-muted.mr-3(*ngIf="loading" [icon]="spinnerIcon" [spin]="true" size="2x" style="margin-top: 3px")
|
||||
strong.text-muted.mr-3(style="margin-top: 6px")
|
||||
| {{totalItems === 99999999 ? '' : totalItems}}
|
||||
pagination(
|
||||
[totalItems]="totalItems" [itemsPerPage]="itemsPerPage" [(ngModel)]="currentPage"
|
||||
[maxSize]="15" [directionLinks]="false" [boundaryLinks]="true")
|
||||
|
||||
table.table.table-fixed.table-striped.table-hover.table-sm
|
||||
thead
|
||||
tr
|
||||
th.col-time created
|
||||
th.col-time last visit
|
||||
th.col-account name
|
||||
th.col-email emails
|
||||
th.col-auth auths
|
||||
th.col-origin origins
|
||||
th ponies
|
||||
th(style="width: 40px;")
|
||||
tbody
|
||||
tr(*ngFor="let a of itemsOnPageIds")
|
||||
td.col-time
|
||||
time-field([time]="info.account?.createdAt")
|
||||
td.col-time
|
||||
time-field([time]="info.account?.lastVisit")
|
||||
td.col-account
|
||||
account-info-remote(#info [accountId]="a")
|
||||
td.col-email
|
||||
email-list([emails]="info.account?.emails")
|
||||
td.col-auth
|
||||
auth-list-remote([accountId]="a")
|
||||
td.col-origin
|
||||
origin-list-remote([accountId]="a")
|
||||
td
|
||||
pony-list-remote([accountId]="a")
|
||||
td(style="width: 40px;")
|
||||
.btn-group.btn-group-xs
|
||||
button.btn.btn-xs.btn-default(
|
||||
(click)="$event.shiftKey ? chatLog.add(info.account) : chatLog.show(info.account)" title="Show chat")
|
||||
fa-icon([icon]="commentIcon" [fixedWidth]="true")
|
||||
|
||||
admin-chat-log(#chatLog)
|
||||
|
||||
.admin-bottom-padding
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { debounce } from 'lodash';
|
||||
import { Account } from '../../../common/adminInterfaces';
|
||||
import { AdminModel } from '../../services/adminModel';
|
||||
import { faSync, faComment, faFilter, faEraser, faSpinner } from '../../../client/icons';
|
||||
|
||||
let autoRefresh = false;
|
||||
let showOnly = 'all';
|
||||
let search = '';
|
||||
let currentPage = 0;
|
||||
let not = false;
|
||||
|
||||
@Component({
|
||||
selector: 'admin-accounts',
|
||||
templateUrl: 'admin-accounts.pug',
|
||||
})
|
||||
export class AdminAccounts implements OnInit {
|
||||
readonly syncIcon = faSync;
|
||||
readonly filterIcon = faFilter;
|
||||
readonly commentIcon = faComment;
|
||||
readonly eraserIcon = faEraser;
|
||||
readonly spinnerIcon = faSpinner;
|
||||
readonly filters = [
|
||||
'all',
|
||||
'banned',
|
||||
'timed out',
|
||||
'with flags',
|
||||
'notes',
|
||||
'supporters',
|
||||
];
|
||||
totalItems = 99999999;
|
||||
itemsOnPageIds: string[] = [];
|
||||
itemsPerPage = 20;
|
||||
loading = false;
|
||||
private expanded = new Set<string>();
|
||||
constructor(public model: AdminModel, private router: Router) {
|
||||
}
|
||||
get showOnly() {
|
||||
return showOnly;
|
||||
}
|
||||
set showOnly(value) {
|
||||
if (showOnly !== value) {
|
||||
showOnly = value;
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
get not() {
|
||||
return not;
|
||||
}
|
||||
set not(value) {
|
||||
if (not !== value) {
|
||||
not = value;
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
get autoRefresh() {
|
||||
return autoRefresh;
|
||||
}
|
||||
set autoRefresh(value) {
|
||||
autoRefresh = value;
|
||||
}
|
||||
get currentPage() {
|
||||
return currentPage;
|
||||
}
|
||||
set currentPage(value) {
|
||||
if (currentPage !== value) {
|
||||
currentPage = value;
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
get search() {
|
||||
return search;
|
||||
}
|
||||
set search(value) {
|
||||
if (search !== value) {
|
||||
search = value;
|
||||
this.execSearch();
|
||||
}
|
||||
}
|
||||
private execSearch = debounce(() => this.refresh(), 500);
|
||||
get duplicateEntries() {
|
||||
return this.model.duplicateEntries;
|
||||
}
|
||||
refreshDuplicates() {
|
||||
this.model.checkDuplicateEntries(true);
|
||||
}
|
||||
limit(account: Account) {
|
||||
return this.expanded.has(account._id) ? 99999 : 2;
|
||||
}
|
||||
expand(account: Account) {
|
||||
this.expanded.add(account._id);
|
||||
}
|
||||
ngOnInit() {
|
||||
this.model.accountPromise
|
||||
.then(() => this.refresh());
|
||||
}
|
||||
refresh(force = false) {
|
||||
this.loading = true;
|
||||
this.model.findAccounts({
|
||||
search: this.search.trim(),
|
||||
not: this.not,
|
||||
showOnly: this.showOnly,
|
||||
page: this.currentPage - 1,
|
||||
itemsPerPage: this.itemsPerPage,
|
||||
force,
|
||||
}).then(result => {
|
||||
if (result && result.page === (this.currentPage - 1)) {
|
||||
this.totalItems = result.totalItems;
|
||||
this.itemsOnPageIds = result.accounts;
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
createAccount() {
|
||||
const name = prompt('enter new account name');
|
||||
|
||||
if (name) {
|
||||
this.model.createAccount(name)
|
||||
.then(id => this.router.navigate(['accounts', id]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
ng-template(#duplicatesPopover)
|
||||
div(*ngFor="let e of duplicateEntries")
|
||||
| {{e}}
|
||||
|
||||
.d-block.d-sm-flex.justify-content-between.align-items-center
|
||||
.form-inline
|
||||
input.form-control.form-control-sm.admin-search-inline.d-none.d-sm-inline-block(
|
||||
type="search" placeholder="search" [(ngModel)]="search" disabled)
|
||||
|
||||
button.btn.btn-sm.ml-1((click)="toggleNotifications()" [btnHighlight]="notifications" title="Toggle notifications")
|
||||
fa-icon([icon]="bellIcon" size="lg")
|
||||
button.btn.btn-sm.btn-default.ml-1((click)="cleanupDeleted()" title="Cleanup deleted events")
|
||||
fa-icon.mr-1([icon]="syncIcon" size="lg")
|
||||
| clear
|
||||
button.btn.btn-sm.btn-default.ml-1((click)="removeEvents(1000 * 60 * 10)" title="Delete all older than 10 minutes")
|
||||
fa-icon.mr-1([icon]="clockIcon" size="lg")
|
||||
| 10min
|
||||
button.btn.btn-sm.btn-default.ml-1((click)="removeEvents(1000 * 3)" title="Delete all events")
|
||||
fa-icon.mr-1([icon]="trashIcon" size="lg")
|
||||
| all
|
||||
button.btn.btn-sm.btn-default.ml-1((click)="showChat()" title="Show chatlog")
|
||||
fa-icon.mr-1([icon]="commentsIcon" size="lg")
|
||||
|
||||
.btn-group.ml-1
|
||||
button.btn.btn-sm.btn-danger(
|
||||
*ngIf="duplicateEntries?.length" [popover]="duplicatesPopover" placement="bottom" containerClass="popover-wide")
|
||||
| duplicates ({{duplicateEntries.length}})
|
||||
button.btn.btn-sm.btn-danger((click)="refreshDuplicates()" title="Refresh duplicates")
|
||||
fa-icon([icon]="syncIcon" size="lg")
|
||||
|
||||
span.text-muted.d-none.d-sm-inline
|
||||
span.ml-3(title="Used disk space" [class.text-danger]="isLowDiskSpace")
|
||||
fa-icon.mr-2([icon]="hddIcon")
|
||||
| {{status.diskSpace}}
|
||||
span.ml-3(title="Memory usage" [class.text-danger]="isLowMemory")
|
||||
fa-icon.mr-2([icon]="ramIcon")
|
||||
| {{status.memoryUsage}}
|
||||
span.ml-3(title="Certificate expires in" [class.text-danger]="isOldCertificate")
|
||||
fa-icon.mr-2([icon]="certificateIcon")
|
||||
from-now([time]="status.certificateExpiration")
|
||||
span.ml-3(title="Last Patreon update" [class.text-danger]="isOldPatreon")
|
||||
fa-icon.mr-2([icon]="patreonIcon")
|
||||
from-now([time]="status.lastPatreonUpdate")
|
||||
|
||||
pagination(
|
||||
[totalItems]="filtered.length" [itemsPerPage]="itemsPerPage" [maxSize]="15" [(ngModel)]="currentPage"
|
||||
[directionLinks]="false")
|
||||
|
||||
events-table(
|
||||
[events]="filteredOnPage" (showChat)="showChat($event)" (addChat)="addChat($event)"
|
||||
(removedEvent)="removedEvent($event)")
|
||||
|
||||
admin-chat-log(#chatLog)
|
||||
|
||||
.admin-bottom-padding
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Component, OnInit, ViewChild, OnDestroy } from '@angular/core';
|
||||
import { fromNow } from '../../../common/utils';
|
||||
import { Event, ChatEvent } from '../../../common/adminInterfaces';
|
||||
import { AdminModel } from '../../services/adminModel';
|
||||
import { BaseTable, BaseTableState } from '../base-table';
|
||||
import { AdminChatLog } from '../shared/admin-chat-log/admin-chat-log';
|
||||
import {
|
||||
faBell, faSync, faClock, faTrash, faComments, faHdd, faMicrochip, faCertificate, faClone, faPatreon
|
||||
} from '../../../client/icons';
|
||||
|
||||
let state: BaseTableState;
|
||||
|
||||
@Component({
|
||||
selector: 'admin-events',
|
||||
templateUrl: 'admin-events.pug',
|
||||
})
|
||||
export class AdminEvents extends BaseTable<Event> implements OnInit, OnDestroy {
|
||||
readonly bellIcon = faBell;
|
||||
readonly syncIcon = faSync;
|
||||
readonly clockIcon = faClock;
|
||||
readonly trashIcon = faTrash;
|
||||
readonly commentsIcon = faComments;
|
||||
readonly hddIcon = faHdd;
|
||||
readonly ramIcon = faMicrochip;
|
||||
readonly certificateIcon = faCertificate;
|
||||
readonly duplicateIcon = faClone;
|
||||
readonly patreonIcon = faPatreon;
|
||||
@ViewChild('chatLog', { static: true }) chatLog!: AdminChatLog;
|
||||
private chatEvent?: Event;
|
||||
constructor(private model: AdminModel) {
|
||||
super();
|
||||
}
|
||||
get status() {
|
||||
return this.model.state.status;
|
||||
}
|
||||
get isLowDiskSpace() {
|
||||
return this.model.isLowDiskSpace;
|
||||
}
|
||||
get isLowMemory() {
|
||||
return this.model.isLowMemory;
|
||||
}
|
||||
get isOldCertificate() {
|
||||
return this.model.isOldCertificate;
|
||||
}
|
||||
get isOldPatreon() {
|
||||
return this.model.isOldPatreon;
|
||||
}
|
||||
get items() {
|
||||
return this.model.events;
|
||||
}
|
||||
get duplicateEntries() {
|
||||
return this.model.duplicateEntries;
|
||||
}
|
||||
get notifications() {
|
||||
return this.model.notifications;
|
||||
}
|
||||
ngOnInit() {
|
||||
this.model.updated = () => this.updateItems();
|
||||
this.setState(state);
|
||||
this.updateItems();
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.model.updated = () => { };
|
||||
}
|
||||
cleanupDeleted() {
|
||||
this.model.cleanupDeletedEvents();
|
||||
}
|
||||
refreshDuplicates() {
|
||||
this.model.checkDuplicateEntries(true);
|
||||
}
|
||||
removeEvents(olderThan: number) {
|
||||
const date = fromNow(-olderThan);
|
||||
const oldEvents = this.items.filter(e => e.updatedAt.getTime() < date.getTime());
|
||||
return Promise.all(oldEvents.map(e => this.model.removeEvent(e._id).then(() => this.removedEvent(e))));
|
||||
}
|
||||
showChat(e?: ChatEvent) {
|
||||
this.chatEvent = e && e.event;
|
||||
this.chatLog.show(e && e.account);
|
||||
}
|
||||
addChat(e: ChatEvent) {
|
||||
if (e.account) {
|
||||
this.chatLog.add(e.account);
|
||||
}
|
||||
}
|
||||
removedEvent(e: Event) {
|
||||
if (this.chatEvent === e) {
|
||||
this.chatLog.close();
|
||||
}
|
||||
}
|
||||
toggleNotifications() {
|
||||
this.model.toggleNotifications();
|
||||
}
|
||||
protected onChange() {
|
||||
state = this.getState();
|
||||
}
|
||||
protected updatePage() {
|
||||
super.updatePage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
.row(style="margin-top: 100px;")
|
||||
.col-md-6
|
||||
h2 {{ip}} [{{info.origin?.country}}]
|
||||
|
||||
div(style="margin-bottom: 10px;")
|
||||
origin-info-remote(#info [originIP]="ip")
|
||||
|
||||
div
|
||||
a([href]="whoisHref" target="_blank" rel="noopener noreferrer")
|
||||
| show whois info
|
||||
|
||||
div
|
||||
span.text-muted created:
|
||||
from-now.ml-1([time]="info.origin?.createdAt")
|
||||
|
||||
div
|
||||
span.text-muted updated:
|
||||
from-now.ml-1([time]="info.origin?.updatedAt")
|
||||
|
||||
.col-md-6
|
||||
h3 Accounts #[span.text-muted.ml-1 ({{accounts.length}})]
|
||||
|
||||
div(*ngFor="let a of accounts | slice:0:50")
|
||||
account-info-remote([accountId]="a")
|
||||
|
||||
.row
|
||||
.col-md-12
|
||||
h3 Events
|
||||
events-table([events]="events" (showChat)="chatLog.show($event.account)")
|
||||
|
||||
.row
|
||||
.col-md-12
|
||||
admin-chat-log(#chatLog)
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Event } from '../../../common/adminInterfaces';
|
||||
import { AdminModel } from '../../services/adminModel';
|
||||
|
||||
@Component({
|
||||
selector: 'admin-origin-details',
|
||||
templateUrl: 'admin-origin-details.pug',
|
||||
})
|
||||
export class AdminOriginDetails implements OnInit {
|
||||
events?: Event[];
|
||||
accounts: string[] = [];
|
||||
ip?: string;
|
||||
constructor(private route: ActivatedRoute, private model: AdminModel) {
|
||||
}
|
||||
get whoisHref() {
|
||||
return `http://whois.urih.com/record/${this.ip}/`;
|
||||
}
|
||||
ngOnInit() {
|
||||
this.route.params.forEach(p => {
|
||||
this.ip = p['ip'];
|
||||
this.update();
|
||||
});
|
||||
}
|
||||
private update() {
|
||||
this.accounts = [];
|
||||
|
||||
if (this.model.connected && this.ip) {
|
||||
this.model.getAccountsByOrigin(this.ip)
|
||||
.then(accounts => this.accounts = accounts || []);
|
||||
this.events = this.model.events
|
||||
.filter(e => e.origin && e.origin.ip === this.ip)
|
||||
.slice(0, 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
.text-center.pt-5(*ngIf="!stats")
|
||||
fa-icon.text-muted([icon]="spinnerIcon" [fixedWidth]="true" [spin]="true" size="3x")
|
||||
|
||||
.row(*ngIf="stats")
|
||||
.col-md-6
|
||||
h3
|
||||
| Origins statistics
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="update()")
|
||||
fa-icon([icon]="syncIcon" [fixedWidth]="true")
|
||||
|
||||
.stat-field Total origin count: #[b {{stats.totalOrigins}}]
|
||||
.stat-field Total origin count (IP4 / IP6): #[b {{stats.totalOriginsIP4}} / {{stats.totalOriginsIP6}}]
|
||||
.stat-field.mt-3 Unique origins: #[b {{stats.uniqueOrigins}}]
|
||||
.stat-field Multiple account origins: #[b {{stats.duplicateOrigins}}]
|
||||
.stat-field Single account origins: #[b {{stats.singleOrigins}}]
|
||||
|
||||
h3.mt-3
|
||||
| Ignores statistics
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="update()")
|
||||
fa-icon([icon]="syncIcon" [fixedWidth]="true")
|
||||
|
||||
.stat-field Total ignore count: #[b {{other?.totalIgnores}}]
|
||||
|
||||
h3.mt-3
|
||||
| Other statistics
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="update()")
|
||||
fa-icon([icon]="syncIcon" [fixedWidth]="true")
|
||||
|
||||
.stat-field Auths with empty account: #[b {{other?.authsWithEmptyAccount}}]
|
||||
.stat-field Auths with missing account: #[b {{other?.authsWithMissingAccount}}]
|
||||
|
||||
.col-md-6
|
||||
h3 Origins distribution
|
||||
|
||||
table.table.table-sm(style="width: auto;")
|
||||
thead
|
||||
tr
|
||||
th origins
|
||||
th accounts
|
||||
th
|
||||
tbody
|
||||
tr(*ngFor="let d of stats.distribution; let i = index")
|
||||
td {{i}}
|
||||
td {{d}}
|
||||
td
|
||||
button.btn.btn-xs.btn-default((click)="clear(i)" [disabled]="pending" title="Clear old singles in this row")
|
||||
fa-icon([icon]="eraserIcon" [fixedWidth]="true")
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="clear(i, true)" [disabled]="pending" title="Clear old singles in or below this row")
|
||||
fa-icon([icon]="eraserIcon" [fixedWidth]="true")
|
||||
fa-icon([icon]="chevronDownIcon" [fixedWidth]="true")
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="clearSingles(i)" [disabled]="pending" title="Clear singles in or below this row")
|
||||
fa-icon([icon]="userIcon" [fixedWidth]="true")
|
||||
fa-icon([icon]="chevronDownIcon" [fixedWidth]="true")
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="clearOld(i)" [disabled]="pending" title="Clear very old (3 months) in or below this row")
|
||||
fa-icon([icon]="clockIcon" [fixedWidth]="true")
|
||||
fa-icon([icon]="chevronDownIcon" [fixedWidth]="true")
|
||||
button.btn.btn-xs.btn-default.ml-1((click)="clearTo10(i)" [disabled]="pending" title="Trim origins to 10 most recent")
|
||||
b 10
|
||||
fa-icon([icon]="chevronDownIcon" [fixedWidth]="true")
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { RequestStats, OriginStats, OtherStats } from '../../../common/adminInterfaces';
|
||||
import { faSync, faEraser, faClock, faUser, faChevronDown, faSpinner } from '../../../client/icons';
|
||||
import { AdminModel } from '../../services/adminModel';
|
||||
|
||||
@Component({
|
||||
selector: 'admin-origins',
|
||||
templateUrl: 'admin-origins.pug',
|
||||
})
|
||||
export class AdminOrigins implements OnInit {
|
||||
readonly syncIcon = faSync;
|
||||
readonly eraserIcon = faEraser;
|
||||
readonly clockIcon = faClock;
|
||||
readonly userIcon = faUser;
|
||||
readonly chevronDownIcon = faChevronDown;
|
||||
readonly spinnerIcon = faSpinner;
|
||||
stats?: OriginStats;
|
||||
other?: OtherStats;
|
||||
requestStats: RequestStats[] = [];
|
||||
pending = false;
|
||||
constructor(public model: AdminModel) {
|
||||
}
|
||||
ngOnInit() {
|
||||
if (this.model.connected) {
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
update() {
|
||||
this.model.getOriginStats().then(stats => this.stats = stats);
|
||||
this.model.getOtherStats().then(stats => this.other = stats);
|
||||
}
|
||||
clear(count: number, andHigher = false) {
|
||||
this.clearAll(count, andHigher, true, true, false);
|
||||
}
|
||||
clearOld(count: number) {
|
||||
this.clearAll(count, true, true, false, false);
|
||||
}
|
||||
clearSingles(count: number) {
|
||||
this.clearAll(count, true, false, true, false);
|
||||
}
|
||||
clearTo10(count: number) {
|
||||
this.clearAll(count, true, false, true, true);
|
||||
}
|
||||
clearAll(count: number, andHigher: boolean, old: boolean, singles: boolean, trim: boolean) {
|
||||
this.pending = true;
|
||||
|
||||
return this.model.clearOrigins(count, andHigher, { old, singles, trim })
|
||||
.finally(() => {
|
||||
this.pending = false;
|
||||
this.update();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
.row
|
||||
.col-6
|
||||
.form
|
||||
.form-group
|
||||
h3 Notifications
|
||||
|
||||
.form
|
||||
.form-group(*ngFor="let field of fields")
|
||||
label {{field.title}}
|
||||
textarea.form-control([(ngModel)]="field.value" rows="4" style="width: 400px;")
|
||||
.form-group
|
||||
button.btn.btn-primary(type="button" (click)="saveFields()") Save
|
||||
button.btn.btn-default.ml-1(type="button" (click)="resetFields()") Reset
|
||||
|
||||
.col-6
|
||||
.form
|
||||
.form-group
|
||||
h3 Patreon
|
||||
.form-group.d-flex
|
||||
button.btn.btn-default((click)="updatePatreon()")
|
||||
| Update Data
|
||||
.input-group.ml-1(style="width: 400px")
|
||||
input.form-control(#patreonToken placeholder="patreon token")
|
||||
.input-group-append
|
||||
button.btn.btn-warning((click)="updatePatreonToken(patreonToken.value); patreonToken.value = ''")
|
||||
| Update Token
|
||||
.form-group
|
||||
| To get new token, got to #[a(href="https://www.patreon.com/portal/registration/register-clients") My Clients],
|
||||
| refresh token and update patreon token field with "Creator's Access Token" from the site.
|
||||
.form-group
|
||||
button.btn.btn-default((click)="getLastPatreonData()")
|
||||
| Get last patreon data
|
||||
|
||||
.form
|
||||
.form-group
|
||||
h3 Other
|
||||
.form-group
|
||||
button.btn.btn-default((click)="updatePastSupporters()")
|
||||
| Update past supporters
|
||||
|
||||
.row
|
||||
.col-12
|
||||
.form
|
||||
.form-group
|
||||
label Suspicious pony info to report
|
||||
textarea.form-control([(ngModel)]="suspiciousPonies" rows="15")
|
||||
.form-group(*ngIf="suspiciousPoniesError")
|
||||
.alert.alert-danger
|
||||
| {{suspiciousPoniesError}}
|
||||
.form-group
|
||||
button.btn.btn-primary((click)="saveSuspiciousPonies()") Save
|
||||
button.btn.btn-default.ml-1((click)="resetSuspiciousPonies()") Reset
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { compact } from 'lodash';
|
||||
import { AdminModel } from '../../services/adminModel';
|
||||
import { Account, GeneralSettings } from '../../../common/adminInterfaces';
|
||||
import { Subscription } from '../../../common/interfaces';
|
||||
import { showTextInNewTab } from '../../../client/htmlUtils';
|
||||
|
||||
interface Field {
|
||||
key: keyof GeneralSettings;
|
||||
title: string;
|
||||
value: string | undefined;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'admin-other',
|
||||
templateUrl: 'admin-other.pug',
|
||||
})
|
||||
export class AdminOther implements OnInit, OnDestroy {
|
||||
fields: Field[] = [
|
||||
{ key: 'suspiciousNames', title: 'Suspicious pony names & emails to report', value: undefined },
|
||||
{ key: 'suspiciousAuths', title: 'Suspicious auths to report', value: undefined },
|
||||
{ key: 'suspiciousMessages', title: 'Suspicious messages to report (instant)', value: undefined },
|
||||
{ key: 'suspiciousSafeMessages', title: 'Suspicious messages to report (safe only) (5+)', value: undefined },
|
||||
{ key: 'suspiciousSafeWholeMessages', title: 'Suspicious messages to report (safe only) (5+) (whole words)', value: undefined },
|
||||
{ key: 'suspiciousSafeInstantMessages', title: 'Suspicious messages to report (safe only) (instant)', value: undefined },
|
||||
{
|
||||
key: 'suspiciousSafeInstantWholeMessages',
|
||||
title: 'Suspicious messages to report (safe only) (whole words) (instant)',
|
||||
value: undefined
|
||||
},
|
||||
];
|
||||
max = 100;
|
||||
value = 0;
|
||||
error?: string;
|
||||
succeeded = false;
|
||||
suspiciousPonies?: string;
|
||||
suspiciousPoniesError?: string;
|
||||
ignoreErrors?: string;
|
||||
account: Account | undefined;
|
||||
private subscription?: Subscription;
|
||||
constructor(public model: AdminModel) {
|
||||
}
|
||||
ngOnInit() {
|
||||
this.model.accountPromise.then(() => {
|
||||
this.resetFields();
|
||||
this.resetSuspiciousPonies();
|
||||
});
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.subscription && this.subscription.unsubscribe();
|
||||
}
|
||||
saveFields() {
|
||||
let settings: any = {};
|
||||
this.fields.forEach(field => settings[field.key] = field.value);
|
||||
this.model.updateSettings(settings);
|
||||
}
|
||||
resetFields() {
|
||||
this.fields.map(field => {
|
||||
field.value = this.model.state.loginServers[0][field.key] as string | undefined;
|
||||
});
|
||||
}
|
||||
saveSuspiciousPonies() {
|
||||
this.suspiciousPoniesError = undefined;
|
||||
|
||||
try {
|
||||
compact(this.suspiciousPonies!.split(/\n/g).map(x => x.trim())).map(x => JSON.parse(x));
|
||||
this.model.updateSettings({ suspiciousPonies: this.suspiciousPonies });
|
||||
} catch (e) {
|
||||
this.suspiciousPoniesError = e.message;
|
||||
}
|
||||
}
|
||||
resetSuspiciousPonies() {
|
||||
this.suspiciousPonies = this.model.state.loginServers[0].suspiciousPonies;
|
||||
}
|
||||
updatePatreon() {
|
||||
this.model.server.updatePatreon();
|
||||
}
|
||||
updatePatreonToken(patreonToken: string) {
|
||||
this.model.updateSettings({ patreonToken });
|
||||
}
|
||||
getLastPatreonData() {
|
||||
this.model.getLastPatreonData()
|
||||
.then(data => {
|
||||
showTextInNewTab(JSON.stringify(data, null, 2));
|
||||
});
|
||||
}
|
||||
updatePastSupporters() {
|
||||
this.model.updatePastSupporters();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
ng-template(#searchTooltip)
|
||||
.text-left.help-tooltip
|
||||
div: em search by:
|
||||
div exact:<name> #[em - exact pony name]
|
||||
div orphan #[em - list orphaned ponies]
|
||||
|
||||
.form-horizontal
|
||||
input.form-control.form-control-sm.admin-search(type="search" placeholder="search" [(ngModel)]="search")
|
||||
.btn-group.btn-group-sm.ml-1
|
||||
button.btn.btn-default((click)="refresh()")
|
||||
fa-icon([icon]="syncIcon" size="lg")
|
||||
|
||||
label.mx-2
|
||||
| order by:
|
||||
.btn-group.btn-group-sm(btnRadioGroup [(ngModel)]="orderBy")
|
||||
button.btn(btnRadio="" [btnHighlight]="!orderBy")
|
||||
| created
|
||||
button.btn(btnRadio="-lastUsed" [btnHighlight]="orderBy === '-lastUsed'")
|
||||
| last used
|
||||
|
||||
.pl-1([tooltip]="searchTooltip" placement="bottom" containerClass="tooltip-pre" style="width: 280px;")
|
||||
small.text-muted
|
||||
fa-icon.mr-1([icon]="filterIcon")
|
||||
| filters
|
||||
|
||||
.d-block.d-sm-flex.justify-content-end
|
||||
.table-loader.text-muted.flex-grow-1.text-left(*ngIf="loading")
|
||||
fa-icon.mr-1([icon]="spinnerIcon" [spin]="true")
|
||||
| Loading...
|
||||
strong.text-muted.mr-3(style="margin-top: 6px")
|
||||
| {{totalItems > 900000000 ? '---' : totalItems}}
|
||||
pagination(
|
||||
[totalItems]="totalItems" [itemsPerPage]="itemsPerPage" [(ngModel)]="currentPage" [maxSize]="15"
|
||||
[directionLinks]="false" [boundaryLinks]="true")
|
||||
|
||||
table.table.table-fixed.table-striped.table-hover.table-sm
|
||||
thead
|
||||
tr
|
||||
th.col-time created
|
||||
th.col-time last used
|
||||
th.col-pony pony
|
||||
th.col-account account
|
||||
th
|
||||
th(style="width: 80px;")
|
||||
tbody
|
||||
tr(*ngFor="let i of items")
|
||||
td.col-time
|
||||
time-field([time]="ponyInfo.pony?.createdAt")
|
||||
td.col-time
|
||||
time-field([time]="ponyInfo.pony?.lastUsed")
|
||||
td.col-pony
|
||||
pony-info-remote(#ponyInfo [ponyId]="i")
|
||||
td.col-account
|
||||
account-info-remote(#accountInfo [accountId]="ponyInfo.pony?.account" [showDuplicates]="true")
|
||||
td
|
||||
td(style="width: 80px;")
|
||||
button.btn.btn-xs.btn-danger((click)="remove(i)" title="Remove pony")
|
||||
fa-icon([icon]="trashIcon" [fixedWidth]="true")
|
||||
button.btn.btn-xs.btn-default.ml-1(
|
||||
(click)="$event.shiftKey ? chatLog.add(accountInfo.account) : chatLog.show(accountInfo.account)" title="Show chat")
|
||||
fa-icon([icon]="commentIcon" [fixedWidth]="true")
|
||||
|
||||
admin-chat-log(#chatLog)
|
||||
|
||||
.admin-bottom-padding
|
||||
@@ -0,0 +1,4 @@
|
||||
.table-loader {
|
||||
line-height: 33px;
|
||||
font-size: larger;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { isEqual, debounce } from 'lodash';
|
||||
import { Character, FindPonyQuery } from '../../../common/adminInterfaces';
|
||||
import { AdminModel } from '../../services/adminModel';
|
||||
import { faSync, faFilter, faTrash, faComment, faSpinner } from '../../../client/icons';
|
||||
import { cloneDeep, delay } from '../../../common/utils';
|
||||
|
||||
let currentPage = 1;
|
||||
let query: FindPonyQuery = {};
|
||||
|
||||
@Component({
|
||||
selector: 'admin-ponies',
|
||||
templateUrl: 'admin-ponies.pug',
|
||||
styleUrls: ['admin-ponies.scss'],
|
||||
})
|
||||
export class AdminPonies implements OnInit {
|
||||
readonly syncIcon = faSync;
|
||||
readonly filterIcon = faFilter;
|
||||
readonly spinnerIcon = faSpinner;
|
||||
readonly trashIcon = faTrash;
|
||||
readonly commentIcon = faComment;
|
||||
items?: string[];
|
||||
itemsPerPage = 20;
|
||||
query: FindPonyQuery = {};
|
||||
loading = false;
|
||||
private lastQuery: FindPonyQuery = {};
|
||||
private totalCount?: number;
|
||||
private execSearch = debounce(() => this.fetchPonies(false), 1000);
|
||||
constructor(private model: AdminModel) {
|
||||
}
|
||||
get totalItems() {
|
||||
return this.totalCount === undefined ? this.model.counts.characters : this.totalCount;
|
||||
}
|
||||
get search() {
|
||||
return query.search;
|
||||
}
|
||||
set search(value: string | undefined) {
|
||||
if (query.search !== value) {
|
||||
query.search = value;
|
||||
this.execSearch();
|
||||
}
|
||||
}
|
||||
get orderBy() {
|
||||
return query.orderBy;
|
||||
}
|
||||
set orderBy(value: string | undefined) {
|
||||
if (query.orderBy !== value) {
|
||||
query.orderBy = value;
|
||||
this.execSearch();
|
||||
}
|
||||
}
|
||||
get currentPage() {
|
||||
return currentPage;
|
||||
}
|
||||
set currentPage(value) {
|
||||
if (currentPage !== value) {
|
||||
currentPage = value;
|
||||
this.fetchPonies(isEqual(this.lastQuery, query));
|
||||
}
|
||||
}
|
||||
ngOnInit() {
|
||||
if (this.model.connected) {
|
||||
this.fetchPonies(false);
|
||||
}
|
||||
}
|
||||
refresh() {
|
||||
this.fetchPonies(false);
|
||||
}
|
||||
remove(pony: Character) {
|
||||
if (confirm('Are you sure?')) {
|
||||
this.model.removePony(pony._id)
|
||||
.then(() => delay(500))
|
||||
.then(() => this.refresh());
|
||||
}
|
||||
}
|
||||
private fetchPonies(skipTotalCount: boolean) {
|
||||
const thisQuery = cloneDeep(query);
|
||||
this.lastQuery = cloneDeep(query);
|
||||
this.loading = true;
|
||||
|
||||
thisQuery.search = thisQuery.search && thisQuery.search.trim();
|
||||
|
||||
this.model.findPonies(thisQuery, this.currentPage - 1, skipTotalCount)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
if (!skipTotalCount) {
|
||||
this.totalCount = result.totalCount;
|
||||
}
|
||||
|
||||
this.items = result.items;
|
||||
} else {
|
||||
this.items = [];
|
||||
}
|
||||
})
|
||||
.finally(() => this.loading = false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
div(style="position: relative;")
|
||||
div(style="position: absolute; top: -50px; right: 0;")
|
||||
b.p-2 {{server}}
|
||||
.btn-group(*ngIf="server")
|
||||
button.btn.btn-default((click)="resetZoom()")
|
||||
| Reset zoom
|
||||
button.btn.btn-default((click)="fitZoom()")
|
||||
| Fit zoom
|
||||
button.btn.btn-default((click)="fullFrameZoom()")
|
||||
| Full frame
|
||||
button.btn.btn-default.ml-1(*ngIf="server" (click)="load(server)")
|
||||
| Refresh
|
||||
.btn-group.dropdown.ml-1(dropdown)
|
||||
button.btn.btn-default.dropdown-toggle(dropdownToggle)
|
||||
| Load
|
||||
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
|
||||
button.dropdown-item(*ngFor="let s of servers" (click)="load(s)")
|
||||
| {{s}}
|
||||
|
||||
div(#container style="position: relative; overflow: hidden;")
|
||||
canvas(#canvas (mousemove)="mouseMove($event)" (wheel)="wheel($event)" (agDrag)="drag($event)")
|
||||
div(#tooltip style="display: none; position: absolute; left: 0; top: 0; padding: 2px 6px; background: #111; border-radius: 2px; font-size: 12px; white-space: pre; pointer-events: none;")
|
||||
|
||||
.float-right.text-muted
|
||||
div total samples: {{timings.length}}
|
||||
|
||||
table.table.table-sm(style="font-size: small; width: 1000px;")
|
||||
thead
|
||||
tr
|
||||
th.text-right Self Time
|
||||
th.text-right Self %
|
||||
th.text-right Total %
|
||||
th.text-right Count
|
||||
th Name
|
||||
tbody.font-monospace
|
||||
tr(*ngFor="let l of listing")
|
||||
td.text-right {{l.selfTime.toFixed(2)}} ms
|
||||
td.text-right.text-muted {{l.selfPercent.toFixed(2)}} %
|
||||
td.text-right.text-muted {{l.totalPercent.toFixed(2)}} %
|
||||
td.text-right.text-muted {{l.count}}
|
||||
td {{l.name}}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { Component, ViewChild, ElementRef } from '@angular/core';
|
||||
import { AdminModel } from '../../../services/adminModel';
|
||||
import { TimingEntry, TimingEntryType } from '../../../../common/adminInterfaces';
|
||||
import { findById, pointInRect, clamp } from '../../../../common/utils';
|
||||
import { SERVER_FPS } from '../../../../common/constants';
|
||||
import { AgDragEvent } from '../../../shared/directives/agDrag';
|
||||
|
||||
interface Tooltip {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface ListingEntry {
|
||||
name: string;
|
||||
count: number;
|
||||
selfTime: number;
|
||||
totalTime: number;
|
||||
selfPercent: number;
|
||||
totalPercent: number;
|
||||
}
|
||||
|
||||
const frameTime = 1000 / SERVER_FPS;
|
||||
const timePadding = 10; // ms
|
||||
|
||||
@Component({
|
||||
selector: 'admin-reports-perf',
|
||||
templateUrl: 'admin-reports-perf.pug',
|
||||
})
|
||||
export class AdminReportsPerf {
|
||||
@ViewChild('container', { static: true }) container!: ElementRef;
|
||||
@ViewChild('canvas', { static: true }) canvas!: ElementRef;
|
||||
@ViewChild('tooltip', { static: true }) tooltip!: ElementRef;
|
||||
loaded = false;
|
||||
server = '';
|
||||
startTime = 0;
|
||||
endTime = 0;
|
||||
listing: ListingEntry[] = [];
|
||||
timings: TimingEntry[] = [];
|
||||
private tooltips: Tooltip[] = [];
|
||||
private startTimeFrom = 0;
|
||||
private endTimeFrom = 0;
|
||||
private frame: any = 0;
|
||||
private lastZoom = 0;
|
||||
constructor(private model: AdminModel) {
|
||||
if (DEVELOPMENT) {
|
||||
const interval = setInterval(() => {
|
||||
if (findById(this.model.state.gameServers, 'dev')) {
|
||||
clearInterval(interval);
|
||||
this.load('dev');
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
get servers() {
|
||||
return this.model.state.gameServers.map(s => s.id);
|
||||
}
|
||||
async load(server: string) {
|
||||
this.server = server;
|
||||
this.timings = [];
|
||||
this.loaded = false;
|
||||
const result = await this.model.getTimings(server);
|
||||
|
||||
if (result) {
|
||||
this.loaded = true;
|
||||
this.timings = result as TimingEntry[];
|
||||
this.setupZoom();
|
||||
this.recalcListing();
|
||||
}
|
||||
|
||||
this.redraw();
|
||||
}
|
||||
mouseMove(e: MouseEvent) {
|
||||
const rect = this.container.nativeElement.getBoundingClientRect() as DOMRect;
|
||||
const x = e.pageX - rect.left;
|
||||
const y = e.pageY - rect.top;
|
||||
|
||||
const tooltip = this.tooltips.find(t => pointInRect(x, y, t));
|
||||
const element = this.tooltip.nativeElement as HTMLElement;
|
||||
|
||||
if (tooltip) {
|
||||
element.style.display = 'block';
|
||||
element.style.left = `${x + 10}px`;
|
||||
element.style.top = `${y + 10}px`;
|
||||
element.innerText = tooltip.text;
|
||||
} else {
|
||||
element.style.display = 'none';
|
||||
}
|
||||
}
|
||||
wheel(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
const deltaY = clamp(e.deltaY, -1, 1);
|
||||
const change = ((this.endTime - this.startTime) * 0.2 * deltaY);
|
||||
const rect = this.container.nativeElement.getBoundingClientRect() as DOMRect;
|
||||
const ratioFromLeft = (e.pageX - rect.left) / rect.width;
|
||||
|
||||
this.startTime = this.startTime - change * ratioFromLeft;
|
||||
this.endTime = this.endTime + change * (1 - ratioFromLeft);
|
||||
|
||||
this.redraw();
|
||||
}
|
||||
setupZoom() {
|
||||
switch (this.lastZoom) {
|
||||
case 0:
|
||||
default:
|
||||
this.resetZoom();
|
||||
break;
|
||||
case 1:
|
||||
this.fitZoom();
|
||||
break;
|
||||
case 2:
|
||||
this.fullFrameZoom();
|
||||
break;
|
||||
}
|
||||
}
|
||||
resetZoom() {
|
||||
const firstTime = this.timings[0].time;
|
||||
const lastTime = this.timings[this.timings.length - 1].time;
|
||||
this.startTime = firstTime - timePadding;
|
||||
this.endTime = lastTime + timePadding;
|
||||
this.redraw();
|
||||
this.lastZoom = 0;
|
||||
}
|
||||
fitZoom() {
|
||||
const firstTime = this.timings[0].time;
|
||||
const lastTime = this.timings[this.timings.length - 1].time;
|
||||
const totalTime = lastTime - firstTime;
|
||||
this.startTime = firstTime - totalTime * 0.05;
|
||||
this.endTime = lastTime + totalTime * 0.05;
|
||||
this.redraw();
|
||||
this.lastZoom = 1;
|
||||
}
|
||||
fullFrameZoom() {
|
||||
const firstTime = this.timings[0].time;
|
||||
const lastTime = firstTime + frameTime;
|
||||
this.startTime = firstTime - 2;
|
||||
this.endTime = lastTime + 2;
|
||||
this.redraw();
|
||||
this.lastZoom = 2;
|
||||
}
|
||||
drag(e: AgDragEvent) {
|
||||
if (e.type === 'start') {
|
||||
this.startTimeFrom = this.startTime;
|
||||
this.endTimeFrom = this.endTime;
|
||||
}
|
||||
|
||||
const scale = (this.endTime - this.startTime) / this.canvas.nativeElement.width;
|
||||
this.startTime = this.startTimeFrom - e.dx * scale;
|
||||
this.endTime = this.endTimeFrom - e.dx * scale;
|
||||
this.redraw();
|
||||
}
|
||||
redraw() {
|
||||
this.frame = this.frame || requestAnimationFrame(() => this.draw());
|
||||
}
|
||||
draw() {
|
||||
this.frame = 0;
|
||||
|
||||
const rect = this.container.nativeElement.getBoundingClientRect() as DOMRect;
|
||||
const canvas = this.canvas.nativeElement as HTMLCanvasElement;
|
||||
canvas.width = rect.width;
|
||||
canvas.height = 400;
|
||||
|
||||
const context = canvas.getContext('2d')!;
|
||||
context.fillStyle = '#222';
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
this.tooltips.length = 0;
|
||||
|
||||
if (!this.timings.length)
|
||||
return;
|
||||
|
||||
const firstTime = this.timings[0].time;
|
||||
const startTime = this.startTime;
|
||||
const endTime = this.endTime;
|
||||
const totalTime = endTime - startTime;
|
||||
const timeScale = canvas.width / totalTime;
|
||||
|
||||
function timeToX(time: number) {
|
||||
return (time - startTime) * timeScale;
|
||||
}
|
||||
|
||||
function timeToXAligned(time: number) {
|
||||
return Math.floor(timeToX(time)) + 0.5;
|
||||
}
|
||||
|
||||
context.textBaseline = 'middle';
|
||||
context.font = 'Arial 14px normal';
|
||||
|
||||
// scale
|
||||
const scaleHeight = 20;
|
||||
|
||||
context.strokeStyle = '#666';
|
||||
context.beginPath();
|
||||
|
||||
for (let time = firstTime; time < endTime; time += frameTime) {
|
||||
context.moveTo(timeToXAligned(time), 0);
|
||||
context.lineTo(timeToXAligned(time), canvas.height);
|
||||
}
|
||||
|
||||
context.stroke();
|
||||
|
||||
context.strokeStyle = '#ddd';
|
||||
context.beginPath();
|
||||
context.moveTo(0, scaleHeight + 0.5);
|
||||
context.lineTo(canvas.width, scaleHeight + 0.5);
|
||||
context.stroke();
|
||||
|
||||
// entries
|
||||
const rowHeight = 20;
|
||||
const startStack: TimingEntry[] = [];
|
||||
|
||||
for (const entry of this.timings) {
|
||||
if (entry.type === TimingEntryType.Start) {
|
||||
startStack.push(entry);
|
||||
} else {
|
||||
const start = startStack.pop()!;
|
||||
const name = start.name!;
|
||||
const startX = timeToX(start.time);
|
||||
const endX = timeToX(entry.time);
|
||||
const y = scaleHeight + 2 + startStack.length * rowHeight;
|
||||
const w = endX - startX;
|
||||
let text = name;
|
||||
const time = entry.time - start.time;
|
||||
|
||||
if (startStack.length === 0) {
|
||||
context.fillStyle = '#efc457';
|
||||
text = `${text} (${time.toFixed(2)} ms) ${(100 * time / frameTime).toFixed(0)}%`;
|
||||
} else if (/\(\)$/.test(name)) {
|
||||
context.fillStyle = '#d4ecc6';
|
||||
} else {
|
||||
context.fillStyle = '#c6dcec';
|
||||
}
|
||||
|
||||
context.fillRect(startX, y, w, rowHeight - 1);
|
||||
this.tooltips.push({ x: startX, y, w, h: rowHeight, text: `${name}\n${time.toFixed(2)} ms` });
|
||||
|
||||
if (w > 4) {
|
||||
context.fillStyle = '#222';
|
||||
context.fillText(text, startX + 4, y + rowHeight / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
recalcListing() {
|
||||
interface Entry extends TimingEntry {
|
||||
excludedTime: number;
|
||||
}
|
||||
|
||||
this.listing = [];
|
||||
const startStack: Entry[] = [];
|
||||
|
||||
for (const entry of this.timings) {
|
||||
if (entry.type === TimingEntryType.Start) {
|
||||
startStack.push({ ...entry, excludedTime: 0 });
|
||||
} else {
|
||||
const start = startStack.pop()!;
|
||||
const name = start.name!;
|
||||
const time = entry.time - start.time;
|
||||
|
||||
let listing = this.listing.find(l => l.name === name);
|
||||
|
||||
if (!listing) {
|
||||
listing = { name, selfTime: 0, totalTime: 0, selfPercent: 0, totalPercent: 0, count: 0 };
|
||||
this.listing.push(listing);
|
||||
}
|
||||
|
||||
listing.count++;
|
||||
listing.selfTime += (time - start.excludedTime);
|
||||
listing.totalTime += time;
|
||||
|
||||
if (startStack.length) {
|
||||
startStack[startStack.length - 1].excludedTime += time;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const firstTime = this.timings[0].time;
|
||||
const lastTime = this.timings[this.timings.length - 1].time;
|
||||
const totalTime = lastTime - firstTime;
|
||||
|
||||
for (const listing of this.listing) {
|
||||
listing.selfPercent = 100 * listing.selfTime / totalTime;
|
||||
listing.totalPercent = 100 * listing.totalTime / totalTime;
|
||||
}
|
||||
|
||||
this.listing.sort((a, b) => b.selfTime - a.selfTime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
nav.nav.nav-pills.mb-3
|
||||
a.nav-item.nav-link(routerLink="/reports/perf" routerLinkActive="active") Perf
|
||||
|
||||
router-outlet
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'admin-reports',
|
||||
templateUrl: 'admin-reports.pug',
|
||||
})
|
||||
export class AdminReports {
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
.mx-auto.mt-4(style="max-width: 400px")
|
||||
sign-in-box
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'admin-sign-in',
|
||||
templateUrl: 'admin-sign-in.pug',
|
||||
})
|
||||
export class AdminSignIn {
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
div(*ngIf="isSuperadmin")
|
||||
button.btn.btn-sm.btn-default((click)="notifyOfUpdate()")
|
||||
| Notify of update
|
||||
button.btn.btn-sm.btn-default.ml-1((click)="shutdownServers()")
|
||||
| Shutdown servers
|
||||
button.btn.btn-sm.btn-default.ml-1((click)="resetUpdating()")
|
||||
| Reset updating
|
||||
|
||||
h3.mt-4 Servers
|
||||
|
||||
.server-box(*ngFor="let s of loginServers")
|
||||
.row.form-group
|
||||
.col-md-4
|
||||
strong Login server
|
||||
.col-md-2.pb-1
|
||||
.badge.badge-none(*ngIf="s.dead") dead
|
||||
.badge.badge-success(*ngIf="!s.dead && !s.isPageOffline") online
|
||||
.badge.badge-danger(*ngIf="!s.dead && s.isPageOffline") offline
|
||||
.badge.badge-warning.ml-1(*ngIf="s.updating") updating
|
||||
.col-md-6
|
||||
button.btn.btn-sm.btn-success((click)="updateLoginSettings({ isPageOffline: false })")
|
||||
| Switch online
|
||||
button.btn.btn-sm.btn-danger.ml-1((click)="updateLoginSettings({ isPageOffline: true })")
|
||||
| Switch offline
|
||||
button.btn.btn-sm.btn-default.ml-1((click)="fetchRequestStats()" title="Show request stats from last hour")
|
||||
| Stats
|
||||
.row.form-group
|
||||
.col-md-3
|
||||
.col-md-9
|
||||
.text-left(style="column-width: 280px;")
|
||||
div(*ngFor="let o of loginOptions")
|
||||
on-off-switch([on]="s[o.id]" (toggle)="updateLoginSetting(o.id, $event)" [label]="o.label")
|
||||
|
||||
.row.form-group(*ngIf="requestStats")
|
||||
.col-md-12
|
||||
table.table.table-striped.table-hover.table-sm
|
||||
thead
|
||||
tr
|
||||
th path
|
||||
th all
|
||||
th count
|
||||
th avg
|
||||
th
|
||||
button.float-right.btn.btn-default.btn-xs((click)="resetRequestStats()") close
|
||||
| total
|
||||
tbody
|
||||
tr(*ngFor="let s of requestStats")
|
||||
td.limit-length {{s.path}}
|
||||
td {{s.totalCount}}
|
||||
td {{s.count}}
|
||||
td {{s.average}}
|
||||
td {{s.total}}
|
||||
|
||||
.row.form-group(*ngIf="userCounts")
|
||||
.col-md-12
|
||||
table.table.table-striped.table-hover.table-sm
|
||||
thead
|
||||
tr
|
||||
th date
|
||||
th unique users
|
||||
tbody
|
||||
tr(*ngFor="let u of userCounts")
|
||||
td {{u.date | date:'MMM d'}}
|
||||
td {{u.count}}
|
||||
|
||||
.server-box(*ngFor="let s of servers")
|
||||
.row.form-group
|
||||
.col-md-2
|
||||
strong {{s.name}}
|
||||
.col-md-2
|
||||
div
|
||||
strong.mr-1 {{s.online}}
|
||||
span.text-muted users
|
||||
span.text-muted.ml-2(title="Users in queue | on main map") ({{s.queued}} | {{s.onMain}})
|
||||
strong.mr-1.ml-2 {{s.maps}}
|
||||
span.text-muted maps
|
||||
.col-md-2.pb-1
|
||||
.badge.badge-none(*ngIf="s.dead") dead
|
||||
.badge.badge-danger(*ngIf="!s.dead && s.settings.isServerOffline") offline
|
||||
.badge.badge-success(*ngIf="!s.dead && !s.settings.isServerOffline") online
|
||||
.badge.badge-forbidden.ml-1(*ngIf="s.require") {{s.require}}
|
||||
.badge.badge-warning.ml-1(*ngIf="s.shutdown") shutdown
|
||||
.col-md-6
|
||||
.d-flex
|
||||
button.btn.btn-sm.btn-success((click)="updateServerSettings(s, { isServerOffline: false })" [disabled]="s.dead")
|
||||
| Switch online
|
||||
button.btn.btn-sm.btn-danger.ml-1((click)="updateServerSettings(s, { isServerOffline: true })" [disabled]="s.dead")
|
||||
| Switch offline
|
||||
button.btn.btn-sm.btn-danger.ml-1((click)="kickAll(s)" [disabled]="s.dead")
|
||||
| Kick all
|
||||
.dropdown.ml-1.d-flex(dropdown)
|
||||
button.dropdown-toggle.btn.btn-sm.btn-default(dropdownToggle [disabled]="s.dead")
|
||||
fa-icon([icon]="cogIcon" [fixedWidth]="true")
|
||||
.dropdown-menu(*dropdownMenu)
|
||||
button.dropdown-item.btn.btn-sm.btn-default((click)="fetchStats(s)")
|
||||
| Stats: Socket transfer rates from last hour
|
||||
button.dropdown-item.btn.btn-sm.btn-default((click)="fetchCountryStats(s)")
|
||||
| Stats: Online users per country
|
||||
button.dropdown-item.btn.btn-sm.btn-default((click)="fetchSupportStats(s)")
|
||||
| Stats: Support
|
||||
button.dropdown-item.btn.btn-sm.btn-default((click)="fetchMapStats(s)")
|
||||
| Stats: Maps
|
||||
button.dropdown-item.btn.btn-sm.btn-default((click)="notifyOfUpdate(s.id)")
|
||||
| Notify of update
|
||||
button.dropdown-item.btn.btn-sm.btn-default((click)="shutdownServers(s.id)")
|
||||
| Shutdown server
|
||||
button.dropdown-item.btn.btn-sm.btn-default((click)="resetUpdating(s.id)")
|
||||
| Reset updating
|
||||
button.btn.btn-sm.btn-default.ml-1(
|
||||
(click)="showSettings[s.id] = !showSettings[s.id]" [class.active]="showSettings[s.id]"
|
||||
[disabled]="s.dead" title="Settings")
|
||||
fa-icon([icon]="optionsIcon")
|
||||
|
||||
.row.form-group(*ngIf="s.settings && !s.dead")
|
||||
.col-md-3.pb-1
|
||||
.badge.badge-none.mr-1(*ngFor="let f of s.flags | keys")
|
||||
| {{f}}
|
||||
.col-md-9(*ngIf="showSettings[s.id]")
|
||||
.text-left(style="column-width: 280px;")
|
||||
div(*ngFor="let o of options")
|
||||
on-off-switch(
|
||||
[on]="s.settings[o.id]" (toggle)="updateServerSetting(s, o.id, $event)" [disabled]="s.dead" [label]="o.label")
|
||||
|
||||
.row.form-group(*ngIf="getStats(s); let stats")
|
||||
.col-12
|
||||
table.table.table-striped.table-hover.table-sm
|
||||
thead
|
||||
tr
|
||||
th id
|
||||
th name
|
||||
th type
|
||||
th count (bin)
|
||||
th count (str)
|
||||
th average
|
||||
th
|
||||
button.float-right.btn.btn-default.btn-xs((click)="resetStats(s)") close
|
||||
| total
|
||||
tbody
|
||||
tr(*ngFor="let a of stats.actions")
|
||||
th {{a.id}}
|
||||
th {{a.name}}
|
||||
td([ngClass]="a.type === 'recv' ? 'text-danger' : 'text-success'") {{a.type}}
|
||||
td {{a.countBin | number}}
|
||||
td {{a.countStr | number}}
|
||||
td {{a.average}}
|
||||
td {{a.total}}
|
||||
|
||||
.row.form-group(*ngIf="getStatsTable(s); let stats")
|
||||
.col-12
|
||||
table.table.table-striped.table-hover.table-sm
|
||||
thead
|
||||
tr
|
||||
th(*ngFor="let cell of stats[0]") {{cell}}
|
||||
th
|
||||
button.float-right.btn.btn-default.btn-xs((click)="resetStatsTable(s)") close
|
||||
|
|
||||
tbody
|
||||
tr(*ngFor="let row of stats | slice:1")
|
||||
th {{row[0]}}
|
||||
td(*ngFor="let cell of row | slice:1") {{cell}}
|
||||
td
|
||||
@@ -0,0 +1,14 @@
|
||||
@import 'partials/variables';
|
||||
|
||||
.limit-length {
|
||||
max-width: 350px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// .server-box {
|
||||
// background: #272727;
|
||||
// border-radius: $border-radius-lg;
|
||||
// padding: $spacer / 2 $spacer;
|
||||
// margin-bottom: $spacer / 2;
|
||||
// }
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Component } from '@angular/core';
|
||||
import {
|
||||
GameServerState, SERVER_SETTINGS, LOGIN_SERVER_SETTINGS, ServerStats, RequestStats,
|
||||
UserCountStats, Stats, StatsTable
|
||||
} from '../../../common/adminInterfaces';
|
||||
import { hasRole } from '../../../common/accountUtils';
|
||||
import { AdminModel } from '../../services/adminModel';
|
||||
import { faCog, faSlidersH } from '../../../client/icons';
|
||||
|
||||
@Component({
|
||||
selector: 'admin-state',
|
||||
templateUrl: 'admin-state.pug',
|
||||
styleUrls: ['admin-state.scss'],
|
||||
})
|
||||
export class AdminState {
|
||||
readonly cogIcon = faCog;
|
||||
readonly optionsIcon = faSlidersH;
|
||||
readonly loginOptions = LOGIN_SERVER_SETTINGS;
|
||||
readonly options = SERVER_SETTINGS;
|
||||
private stats = new Map<string, ServerStats>();
|
||||
private statsTables = new Map<string, StatsTable>();
|
||||
requestStats?: RequestStats[];
|
||||
userCounts?: UserCountStats[];
|
||||
showSettings: any = {};
|
||||
constructor(private model: AdminModel) {
|
||||
}
|
||||
get state() {
|
||||
return this.model.state;
|
||||
}
|
||||
get loginServers() {
|
||||
return this.model.state.loginServers;
|
||||
}
|
||||
get servers() {
|
||||
return this.model.state.gameServers;
|
||||
}
|
||||
get isSuperadmin() {
|
||||
return hasRole(this.model.account, 'superadmin');
|
||||
}
|
||||
kickAll(server: GameServerState) {
|
||||
return this.model.kickAll(server.id);
|
||||
}
|
||||
updateLoginSetting(key: string, value: boolean) {
|
||||
return this.model.updateSettings({ [key]: value });
|
||||
}
|
||||
updateLoginSettings(state: any) {
|
||||
return this.model.updateSettings(state);
|
||||
}
|
||||
updateServerSetting(server: GameServerState, key: string, value: boolean) {
|
||||
return this.model.updateGameServerSettings(server.id, { [key]: value });
|
||||
}
|
||||
updateServerSettings(server: GameServerState, state: any) {
|
||||
return this.model.updateGameServerSettings(server.id, state);
|
||||
}
|
||||
// request stats
|
||||
fetchRequestStats() {
|
||||
this.model.getRequestStats()
|
||||
.then(stats => {
|
||||
this.requestStats = stats && stats.requests;
|
||||
this.userCounts = stats && stats.userCounts;
|
||||
});
|
||||
}
|
||||
resetRequestStats() {
|
||||
this.requestStats = undefined;
|
||||
this.userCounts = undefined;
|
||||
}
|
||||
// socket stats
|
||||
fetchStats(server: GameServerState) {
|
||||
return this.model.fetchServerStats(server.id)
|
||||
.then(stats => stats && this.stats.set(server.id, stats));
|
||||
}
|
||||
resetStats(server: GameServerState) {
|
||||
this.stats.delete(server.id);
|
||||
}
|
||||
getStats(server: GameServerState) {
|
||||
return this.stats.get(server.id);
|
||||
}
|
||||
// stats tables
|
||||
fetchCountryStats(server: GameServerState) {
|
||||
return this.fetchStatsTable(server, Stats.Country);
|
||||
}
|
||||
fetchSupportStats(server: GameServerState) {
|
||||
return this.fetchStatsTable(server, Stats.Support);
|
||||
}
|
||||
fetchMapStats(server: GameServerState) {
|
||||
return this.fetchStatsTable(server, Stats.Maps);
|
||||
}
|
||||
resetStatsTable(server: GameServerState) {
|
||||
this.statsTables.delete(server.id);
|
||||
}
|
||||
getStatsTable(server: GameServerState) {
|
||||
return this.statsTables.get(server.id);
|
||||
}
|
||||
private fetchStatsTable(server: GameServerState, stats: Stats) {
|
||||
return this.model.fetchServerStatsTable(server.id, stats)
|
||||
.then(stats => stats && this.statsTables.set(server.id, stats));
|
||||
}
|
||||
// updates
|
||||
notifyOfUpdate(server = '*') {
|
||||
if (confirm('Are you sure?')) {
|
||||
this.model.notifyUpdate(server);
|
||||
}
|
||||
}
|
||||
shutdownServers(server = '*') {
|
||||
if (confirm('Are you sure?')) {
|
||||
this.model.shutdownServers(server);
|
||||
}
|
||||
}
|
||||
resetUpdating(server = '*') {
|
||||
this.model.resetUpdating(server);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||
import { TooltipModule } from 'ngx-bootstrap/tooltip';
|
||||
import { PopoverModule } from 'ngx-bootstrap/popover';
|
||||
import { PaginationModule } from 'ngx-bootstrap/pagination';
|
||||
import { ButtonsModule } from 'ngx-bootstrap/buttons';
|
||||
import { ModalModule } from 'ngx-bootstrap/modal';
|
||||
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
|
||||
import { KeysPipe } from './pipes/keysPipe';
|
||||
import { OrderByPipe } from './pipes/orderByPipe';
|
||||
import { TranslitPipe } from './pipes/translitPipe';
|
||||
|
||||
import { EventsTable } from './shared/events-table/events-table';
|
||||
import { AccountInfo } from './shared/account-info/account-info';
|
||||
import { AccountInfoRemote } from './shared/account-info-remote/account-info-remote';
|
||||
import { AccountStatus } from './shared/account-status/account-status';
|
||||
import { AccountTooltip } from './shared/account-tooltip/account-tooltip';
|
||||
import { OriginInfo } from './shared/origin-info/origin-info';
|
||||
import { OriginInfoRemote } from './shared/origin-info-remote/origin-info-remote';
|
||||
import { OriginListRemote } from './shared/origin-list-remote/origin-list-remote';
|
||||
import { PonyInfo } from './shared/pony-info/pony-info';
|
||||
import { PonyInfoRemote } from './shared/pony-info-remote/pony-info-remote';
|
||||
import { PonyListRemote } from './shared/pony-list-remote/pony-list-remote';
|
||||
import { AuthInfo } from './shared/auth-info/auth-info';
|
||||
import { AuthInfoRemote } from './shared/auth-info-remote/auth-info-remote';
|
||||
import { AuthInfoEdit } from './shared/auth-info-edit/auth-info-edit';
|
||||
import { AuthList } from './shared/auth-list/auth-list';
|
||||
import { AuthListRemote } from './shared/auth-list-remote/auth-list-remote';
|
||||
import { OnOffSwitch } from './shared/on-off-switch/on-off-switch';
|
||||
import { AdminChatLog } from './shared/admin-chat-log/admin-chat-log';
|
||||
import { BanIcon } from './shared/ban-icon/ban-icon';
|
||||
import { EmailList } from './shared/email-list/email-list';
|
||||
import { FromNow } from './shared/from-now';
|
||||
import { TimeField } from './shared/time-field/time-field';
|
||||
import { UAInfo } from './shared/ua-info/ua-info';
|
||||
|
||||
import { AdminState } from './admin-state/admin-state';
|
||||
import { AdminEvents } from './admin-events/admin-events';
|
||||
import { AdminOther } from './admin-other/admin-other';
|
||||
import { AdminAccounts } from './admin-accounts/admin-accounts';
|
||||
import { AdminAccountDetails } from './admin-account-details/admin-account-details';
|
||||
import { AdminPonies } from './admin-ponies/admin-ponies';
|
||||
import { AdminReports } from './admin-reports/admin-reports';
|
||||
import { AdminReportsPerf } from './admin-reports/admin-reports-perf/admin-reports-perf';
|
||||
import { AdminOrigins } from './admin-origins/admin-origins';
|
||||
import { AdminOriginDetails } from './admin-origin-details/admin-origin-details';
|
||||
import { AdminSignIn } from './admin-sign-in/admin-sign-in';
|
||||
import { AdminApp } from './admin';
|
||||
|
||||
import { ErrorReporter } from '../services/errorReporter';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', component: AdminState },
|
||||
{ path: 'sign-in', component: AdminSignIn },
|
||||
{ path: 'events', component: AdminEvents },
|
||||
{ path: 'accounts', component: AdminAccounts },
|
||||
{ path: 'accounts/:id', component: AdminAccountDetails },
|
||||
{ path: 'ponies', component: AdminPonies },
|
||||
{ path: 'origins', component: AdminOrigins },
|
||||
{ path: 'origins/:ip/:country', component: AdminOriginDetails },
|
||||
{
|
||||
path: 'reports',
|
||||
component: AdminReports,
|
||||
children: [
|
||||
{ path: '', redirectTo: 'perf', pathMatch: 'full' },
|
||||
{ path: 'perf', component: AdminReportsPerf },
|
||||
],
|
||||
},
|
||||
{ path: 'other', component: AdminOther },
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
BrowserModule,
|
||||
RouterModule,
|
||||
FormsModule,
|
||||
HttpClientModule,
|
||||
PopoverModule.forRoot(),
|
||||
PaginationModule.forRoot(),
|
||||
ButtonsModule.forRoot(),
|
||||
ModalModule,
|
||||
TooltipModule,
|
||||
SharedModule,
|
||||
RouterModule.forRoot(routes),
|
||||
FontAwesomeModule,
|
||||
],
|
||||
declarations: [
|
||||
KeysPipe,
|
||||
OrderByPipe,
|
||||
TranslitPipe,
|
||||
EventsTable,
|
||||
AccountInfo,
|
||||
AccountInfoRemote,
|
||||
AccountStatus,
|
||||
AccountTooltip,
|
||||
OriginInfo,
|
||||
OriginInfoRemote,
|
||||
OriginListRemote,
|
||||
PonyInfo,
|
||||
PonyInfoRemote,
|
||||
PonyListRemote,
|
||||
AuthInfo,
|
||||
AuthInfoRemote,
|
||||
AuthInfoEdit,
|
||||
AuthList,
|
||||
AuthListRemote,
|
||||
OnOffSwitch,
|
||||
AdminChatLog,
|
||||
BanIcon,
|
||||
EmailList,
|
||||
FromNow,
|
||||
TimeField,
|
||||
UAInfo,
|
||||
AdminState,
|
||||
AdminEvents,
|
||||
AdminOther,
|
||||
AdminAccounts,
|
||||
AdminAccountDetails,
|
||||
AdminPonies,
|
||||
AdminReports,
|
||||
AdminReportsPerf,
|
||||
AdminOrigins,
|
||||
AdminOriginDetails,
|
||||
AdminSignIn,
|
||||
AdminApp,
|
||||
],
|
||||
providers: [
|
||||
ErrorReporter,
|
||||
],
|
||||
bootstrap: [AdminApp],
|
||||
})
|
||||
export class AdminAppModule {
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.container-fluid
|
||||
|
||||
.d-flex.justify-content-between
|
||||
.admin-initializing
|
||||
div(*ngIf="loading")
|
||||
fa-icon.mr-1([icon]="spinnerIcon" [spin]="true")
|
||||
| {{loading}}...
|
||||
|
||||
menu-bar([loading]="model.loading" [account]="model.account" (signOut)="signOut()")
|
||||
menu-item(route="/" name="State ({{clients}})" [icon]="stateIcon")
|
||||
menu-item(route="/events" name="Events ({{events}})" [icon]="eventsIcon")
|
||||
menu-item(route="/accounts" name="Accounts ({{accounts}})" [icon]="accountsIcon")
|
||||
menu-item(route="/ponies" name="Ponies ({{ponies}})" [icon]="poniesIcon")
|
||||
menu-item(route="/origins" name="Origins ({{origins}})" [icon]="originsIcon")
|
||||
menu-item(route="/reports" name="Reports" *ngIf="isSuperadmin" [icon]="reportsIcon")
|
||||
menu-item(route="/other" name="Other" *ngIf="isSuperadmin" [icon]="otherIcon")
|
||||
|
||||
.admin-view.mt-5
|
||||
router-outlet
|
||||
|
||||
.admin-error(*ngIf="model.error")
|
||||
.alert.alert-danger
|
||||
button.close.ml-2((click)="model.error = null") ×
|
||||
| {{model.error}}
|
||||
@@ -0,0 +1,14 @@
|
||||
@import 'partials/variables';
|
||||
|
||||
.admin-initializing {
|
||||
margin: 15px;
|
||||
font-size: larger;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.admin-error {
|
||||
position: fixed;
|
||||
left: 15px;
|
||||
top: 10px;
|
||||
width: 400px;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Component, HostListener } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { TooltipConfig } from 'ngx-bootstrap/tooltip';
|
||||
import { PopoverConfig } from 'ngx-bootstrap/popover';
|
||||
import { hasRole } from '../../common/accountUtils';
|
||||
import { AdminModel } from '../services/adminModel';
|
||||
import {
|
||||
faSpinner, faSlidersH, faExclamationCircle, faUsers, faHorseHead, faMapMarkerAlt, faChartPie, faCog
|
||||
} from '../../client/icons';
|
||||
|
||||
export function tooltipConfig() {
|
||||
return Object.assign(new TooltipConfig(), { container: 'body' });
|
||||
}
|
||||
|
||||
export function popoverConfig() {
|
||||
return Object.assign(new PopoverConfig(), { container: 'body' });
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pony-town-app',
|
||||
templateUrl: 'admin.pug',
|
||||
styleUrls: ['admin.scss'],
|
||||
providers: [
|
||||
{ provide: TooltipConfig, useFactory: tooltipConfig },
|
||||
{ provide: PopoverConfig, useFactory: popoverConfig },
|
||||
]
|
||||
})
|
||||
export class AdminApp {
|
||||
readonly spinnerIcon = faSpinner;
|
||||
readonly stateIcon = faSlidersH;
|
||||
readonly eventsIcon = faExclamationCircle;
|
||||
readonly accountsIcon = faUsers;
|
||||
readonly poniesIcon = faHorseHead;
|
||||
readonly originsIcon = faMapMarkerAlt;
|
||||
readonly reportsIcon = faChartPie;
|
||||
readonly otherIcon = faCog;
|
||||
constructor(public model: AdminModel, private router: Router) {
|
||||
}
|
||||
get loading() {
|
||||
if (!this.model.initialized) {
|
||||
return 'Initializing';
|
||||
} else if (!this.model.connected) {
|
||||
return 'Connecting';
|
||||
} else if (!this.model.loaded) {
|
||||
return 'Loading';
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
get clients() {
|
||||
return this.model.state.gameServers.reduce((sum, s) => sum + s.online, 0);
|
||||
}
|
||||
get events() {
|
||||
return this.model.events.length;
|
||||
}
|
||||
get accounts() {
|
||||
return this.model.counts.accounts;
|
||||
}
|
||||
get ponies() {
|
||||
return this.model.counts.characters;
|
||||
}
|
||||
get origins() {
|
||||
return this.model.counts.origins;
|
||||
}
|
||||
get isSuperadmin() {
|
||||
return hasRole(this.model.account, 'superadmin');
|
||||
}
|
||||
@HostListener('window:go-to-account', ['$event'])
|
||||
goToAccount({ detail }: CustomEvent) {
|
||||
this.router.navigate(['/accounts', detail]);
|
||||
}
|
||||
signOut() {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
export interface BaseTableState {
|
||||
search: string;
|
||||
currentPage: number;
|
||||
sortedBy: string;
|
||||
sortedAsc: boolean;
|
||||
}
|
||||
|
||||
export abstract class BaseTable<T> {
|
||||
itemsPerPage = 20;
|
||||
sorted: T[] = [];
|
||||
filtered: T[] = [];
|
||||
filteredOnPage: T[] = [];
|
||||
sortedBy = 'createdAt';
|
||||
sortedAsc = true;
|
||||
private _search = '';
|
||||
private _currentPage = 1;
|
||||
private execSearch = debounce(() => {
|
||||
this.updateFiltered();
|
||||
this.onChange();
|
||||
}, 500);
|
||||
get itemsFrom() {
|
||||
return (this.currentPage - 1) * this.itemsPerPage;
|
||||
}
|
||||
get items(): T[] {
|
||||
return [];
|
||||
}
|
||||
get search() {
|
||||
return this._search;
|
||||
}
|
||||
set search(value: string) {
|
||||
if (this._search !== value) {
|
||||
this._search = value;
|
||||
this.execSearch();
|
||||
}
|
||||
}
|
||||
get currentPage() {
|
||||
return this._currentPage;
|
||||
}
|
||||
set currentPage(value: number) {
|
||||
if (this._currentPage !== value) {
|
||||
this._currentPage = value;
|
||||
this.updatePage();
|
||||
this.onChange();
|
||||
}
|
||||
}
|
||||
sortBy(field: string) {
|
||||
if (this.sortedBy === field) {
|
||||
this.sortedAsc = !this.sortedAsc;
|
||||
} else {
|
||||
this.sortedBy = field;
|
||||
this.sortedAsc = true;
|
||||
}
|
||||
|
||||
this.updateSorted();
|
||||
this.onChange();
|
||||
}
|
||||
sortedClass(field: string) {
|
||||
return this.sortedBy === field ? (this.sortedAsc ? 'sorted-asc' : 'sorted-desc') : undefined;
|
||||
}
|
||||
protected updateItems() {
|
||||
this.updateSorted();
|
||||
}
|
||||
protected updateSorted() {
|
||||
this.sorted = this.sortItems(this.items, this.sortedBy, this.sortedAsc);
|
||||
this.updateFiltered();
|
||||
}
|
||||
protected updateFiltered() {
|
||||
this.filtered = this.filterItems(this.sorted, this.search);
|
||||
this.updatePage();
|
||||
}
|
||||
protected updatePage() {
|
||||
this.filteredOnPage = this.filtered.slice(this.itemsFrom, this.itemsFrom + this.itemsPerPage);
|
||||
}
|
||||
protected sortItems(items: T[], _by: string, _asc: boolean) {
|
||||
return items;
|
||||
}
|
||||
protected filterItems(items: T[], _search: string) {
|
||||
return items;
|
||||
}
|
||||
protected onChange() {
|
||||
}
|
||||
protected getState(): BaseTableState {
|
||||
return {
|
||||
search: this.search,
|
||||
currentPage: this.currentPage,
|
||||
sortedBy: this.sortedBy,
|
||||
sortedAsc: this.sortedAsc,
|
||||
};
|
||||
}
|
||||
protected setState(state: BaseTableState | undefined) {
|
||||
if (state) {
|
||||
this._search = state.search;
|
||||
this._currentPage = state.currentPage || 1;
|
||||
this.sortedBy = state.sortedBy;
|
||||
this.sortedAsc = state.sortedAsc;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({
|
||||
name: 'keys',
|
||||
})
|
||||
export class KeysPipe implements PipeTransform {
|
||||
transform(value: any) {
|
||||
return value ? Object.keys(value) : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({
|
||||
name: 'orderBy',
|
||||
})
|
||||
export class OrderByPipe implements PipeTransform {
|
||||
transform(value: any[] | undefined, compare?: any) {
|
||||
return value && value.slice().sort(compare);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { transliterate } from 'transliteration';
|
||||
|
||||
@Pipe({
|
||||
name: 'translit',
|
||||
})
|
||||
export class TranslitPipe implements PipeTransform {
|
||||
transform(value: string) {
|
||||
if (!value || /^[a-z0-9-_.,\[\]!@#$%^&*{}|\/\\ ]+$/i.test(value))
|
||||
return undefined;
|
||||
|
||||
const translit = transliterate(value);
|
||||
return translit !== value ? translit : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
account-info(
|
||||
*ngIf="!basic" [account]="account" [extendedAuths]="extendedAuths" [popoverPlacement]="popoverPlacement"
|
||||
[showDuplicates]="showDuplicates")
|
||||
span(*ngIf="basic")
|
||||
| {{account?.name || '[none]'}} [{{account?._id | slice:-3}}]
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Component, Input, OnDestroy } from '@angular/core';
|
||||
import { Account } from '../../../../common/adminInterfaces';
|
||||
import { Subscription } from '../../../../common/interfaces';
|
||||
import { AdminModel } from '../../../services/adminModel';
|
||||
|
||||
@Component({
|
||||
selector: 'account-info-remote',
|
||||
templateUrl: 'account-info-remote.pug',
|
||||
})
|
||||
export class AccountInfoRemote implements OnDestroy {
|
||||
@Input() extendedAuths = false;
|
||||
@Input() popoverPlacement?: string;
|
||||
@Input() showDuplicates = false;
|
||||
@Input() basic = false;
|
||||
account?: Account;
|
||||
private _accountId?: string;
|
||||
private subscription?: Subscription;
|
||||
constructor(private model: AdminModel) {
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.subscription && this.subscription.unsubscribe();
|
||||
}
|
||||
get accountId() {
|
||||
return this._accountId;
|
||||
}
|
||||
@Input() set accountId(value) {
|
||||
if (this._accountId !== value) {
|
||||
this._accountId = value;
|
||||
this.account = undefined;
|
||||
this.updateSubscription();
|
||||
}
|
||||
}
|
||||
private updateSubscription() {
|
||||
if (this.subscription) {
|
||||
this.subscription.unsubscribe();
|
||||
this.subscription = undefined;
|
||||
}
|
||||
|
||||
if (this.accountId) {
|
||||
this.subscription = this.model.accounts
|
||||
.subscribe(this.accountId, account => this.account = account);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
ng-template(#notePopover)
|
||||
textarea.form-control.note-editor(
|
||||
cols="20" rows="15" agAutoFocus [(ngModel)]="note" (keydown.escape)="blur()" (blur)="blur()")
|
||||
|
||||
ng-template(#accountTooltip)
|
||||
account-tooltip([account]="account" [extendedAuths]="extendedAuths")
|
||||
|
||||
ng-template(#noteTooltip)
|
||||
small.text-muted
|
||||
from-now([time]="account.noteUpdated")
|
||||
div {{account.note}}
|
||||
|
||||
ng-template(#alertTooltip)
|
||||
.text-left
|
||||
small.text-muted
|
||||
| expires in
|
||||
from-now.ml-1([time]="alert.expires")
|
||||
div {{alert.message}}
|
||||
|
||||
ng-template(#supporterTooltip)
|
||||
div {{supporterTitle}}
|
||||
.text-muted(*ngIf="account.supporterDeclinedSince as declined")
|
||||
| declined: #[from-now([time]="declined")] ago | {{declined | date:'MMM d'}}
|
||||
|
||||
ng-template(#flagsTooltip)
|
||||
.text-left
|
||||
div(*ngFor="let c of counters")
|
||||
span.text-muted.mr-1 {{c.label}}:
|
||||
span {{getCounter(c.name)}}
|
||||
br(*ngIf="account.flags || account.supporter")
|
||||
div(*ngFor="let f of flags")
|
||||
span(*ngIf="hasFlag(f.value)")
|
||||
fa-icon.mr-1([icon]="checkIcon")
|
||||
| {{f.label}}
|
||||
div(*ngFor="let f of supporterFlags")
|
||||
span(*ngIf="hasSupporterFlag(f.value)")
|
||||
fa-icon.mr-1([icon]="checkIcon")
|
||||
| {{f.label}}
|
||||
|
||||
ng-template(#alertModal)
|
||||
.modal-header.d-none.d-sm-block
|
||||
h4.modal-title(labelledBy=".modal")
|
||||
| Account Alert
|
||||
.modal-body.modal-checkboxes
|
||||
.form-group
|
||||
label Predefined:
|
||||
.btn-group.ml-2
|
||||
button.btn.btn-sm.btn-default(*ngFor="let a of predefinedAlerts" (click)="alertMessage = a.message")
|
||||
| {{a.name}}
|
||||
.form-group
|
||||
textarea.form-control([(ngModel)]="alertMessage" autofocus rows="4")
|
||||
.form-group
|
||||
label Expire in
|
||||
.btn-group.ml-2(btnRadioGroup [(ngModel)]="alertExpire")
|
||||
button.btn.btn-sm.btn-default(*ngFor="let e of alertExpires" [btnRadio]="e" [btnHighlight]="alertExpire === e")
|
||||
| {{e.name}}
|
||||
.modal-footer.d-flex
|
||||
.flex-grow-1
|
||||
button.btn.btn-outline-secondary((click)="cancelAlert()")
|
||||
| Cancel
|
||||
button.btn.btn-outline-secondary.ml-2((click)="confirmAlert()")
|
||||
| Set
|
||||
|
||||
//- component
|
||||
|
||||
.account-info(*ngIf="account" [class.outdated]="isInactive")
|
||||
span.badge.badge-admin.badge-duplicates.mr-1(
|
||||
*ngIf="showDuplicates && duplicates" title="Duplicates" [class.none]="duplicates.count === 0")
|
||||
| {{duplicates.count}}
|
||||
fa-icon.ml-1(*ngIf="duplicates.browserId" [icon]="duplicateBrowserIdIcon")
|
||||
fa-icon.ml-1(*ngIf="duplicates.emails" [icon]="duplicateEmailIcon")
|
||||
fa-icon.ml-1(*ngIf="duplicates.name" [icon]="duplicateNameIcon")
|
||||
fa-icon.ml-1(*ngIf="duplicates.perma" [icon]="duplicatePermaIcon")
|
||||
|
||||
a.account-info-name.mr-1(
|
||||
[routerLink]="['/accounts', account._id]"
|
||||
[tooltip]="accountTooltip"
|
||||
[placement]="popoverPlacement || 'top'"
|
||||
containerClass="tooltip-pre tooltip-account")
|
||||
| {{account.name || '[none]'}} [{{account._id | slice:-3}}]
|
||||
|
||||
span.badge.badge-admin.badge-none(*ngFor="let r of roles" title="role")
|
||||
| {{r}}
|
||||
|
||||
//-span.badge.badge-admin.badge-primary([title]="account.birthdate | date:'yyyy-MM-dd'")
|
||||
| {{age}}
|
||||
|
||||
span.badge.badge-admin.badge-supporter(
|
||||
*ngIf="isPatreonOrSupporter"
|
||||
[ngClass]="supporterClass"
|
||||
[class.declined]="account.supporterDeclinedSince && supporterLevel"
|
||||
[tooltip]="supporterTooltip")
|
||||
fa-icon([icon]="supporterIcon" [fixedWidth]="true")
|
||||
| {{supporterLevelString}}
|
||||
|
||||
span.badge.badge-admin.badge-supporter.badge-success(*ngIf="account.totalPledged" title="lifetime support")
|
||||
| ${{account.totalPledged}}
|
||||
|
||||
span.badge.badge-admin.badge-danger(*ngIf="account.ignoresCount" title="ignored by")
|
||||
| {{account.ignoresCount}}
|
||||
|
||||
fa-icon.text-warning(*ngIf="isNew" [icon]="newIcon" [fixedWidth]="true" title="new account")
|
||||
|
||||
a.icon-button.cursor-pointer(
|
||||
[class.pointer-none]="isNoteOpen"
|
||||
[popover]="notePopover"
|
||||
[isOpen]="isNoteOpen"
|
||||
[placement]="popoverPlacement || 'right'"
|
||||
(onShown)="isNoteOpen = true"
|
||||
(onHidden)="isNoteOpen = false"
|
||||
containerClass="popover-notes")
|
||||
fa-icon.text-muted(
|
||||
[icon]="noteIcon"
|
||||
[fixedWidth]="true"
|
||||
[class.text-present]="account.note"
|
||||
[tooltip]="isNoteOpen ? null : noteTooltip"
|
||||
[isDisabled]="!account.note || isNoteOpen"
|
||||
[placement]="popoverPlacement || 'top'"
|
||||
containerClass="tooltip-notes")
|
||||
span.account-info-note-indicator.pointer-none(*ngIf="hasDuplicateNote") d
|
||||
|
||||
span.dropdown(dropdown autoClose="outsideClick")
|
||||
a.icon-button.cursor-pointer.dropdown-toggle.no-arrow(dropdownToggle [tooltip]="flagsTooltip")
|
||||
fa-icon([icon]="flagIcon" [fixedWidth]="true" [ngClass]="flagClass")
|
||||
.dropdown-menu(*dropdownMenu [class.dropdown-menu-right]="!popoverPlacement")
|
||||
a.dropdown-item(*ngFor="let f of flags" (click)="toggleFlag(f.value)")
|
||||
fa-icon.mr-1([icon]="checkIcon" [fixedWidth]="true" [class.invisible]="!hasFlag(f.value)")
|
||||
| {{f.label}}
|
||||
.dropdown-divider
|
||||
a.dropdown-item(*ngFor="let f of supporterFlags" (click)="toggleSupporterFlag(f.value)")
|
||||
fa-icon.mr-1([icon]="checkIcon" [fixedWidth]="true" [class.invisible]="!hasSupporterFlag(f.value)")
|
||||
| {{f.label}}
|
||||
.dropdown-item
|
||||
.d-flex.justify-content-between
|
||||
div
|
||||
fa-icon.mr-1([icon]="checkIcon" [fixedWidth]="true" [class.invisible]="!hasAnySupporter")
|
||||
| supporter
|
||||
.btn-group.supporter-buttons
|
||||
button.btn.btn-xs.btn-info([class.active]="isSupporter(0)" (click)="setSupporter(0)") none
|
||||
button.btn.btn-xs.btn-info([class.active]="isSupporter(1)" (click)="setSupporter(1)") 1
|
||||
button.btn.btn-xs.btn-info([class.active]="isSupporter(2)" (click)="setSupporter(2)") 2
|
||||
button.btn.btn-xs.btn-info([class.active]="isSupporter(3)" (click)="setSupporter(3)") 3
|
||||
.dropdown-item
|
||||
.d-flex.justify-content-between
|
||||
div
|
||||
fa-icon.mr-1([icon]="checkIcon" [fixedWidth]="true" [class.invisible]="!hasPastSupporter")
|
||||
| past supporter
|
||||
.btn-group.supporter-buttons
|
||||
button.btn.btn-xs.btn-info([class.active]="isForcePastSupporter" (click)="toggleForcePastSupporter()") force
|
||||
button.btn.btn-xs.btn-info([class.active]="isIgnorePastSupporter" (click)="toggleIgnorePastSupporter()") ignore
|
||||
.dropdown-divider
|
||||
.dropdown-item.counter-item(*ngFor="let c of counters")
|
||||
span.text-muted {{c.label}}:
|
||||
span.counter-value {{getCounter(c.name)}}
|
||||
button.btn.btn-xs.btn-info((click)="decrementCounter(c.name)")
|
||||
fa-icon([icon]="minusIcon" [fixedWidth]="true")
|
||||
button.btn.btn-xs.btn-info((click)="incrementCounter(c.name)")
|
||||
fa-icon([icon]="plusIcon" [fixedWidth]="true")
|
||||
|
||||
span.dropdown(dropdown)
|
||||
a.icon-button.cursor-pointer.dropdown-toggle.no-arrow(dropdownToggle [tooltip]="alert ? alertTooltip : undefined")
|
||||
fa-icon.text-muted([icon]="cogIcon" [fixedWidth]="true" [class.text-present]="alert")
|
||||
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
|
||||
a.dropdown-item((click)="kick()")
|
||||
| kick
|
||||
a.dropdown-item((click)="report()")
|
||||
| report
|
||||
.dropdown-divider
|
||||
a.dropdown-item((click)="removeAlert()")
|
||||
| remove alert
|
||||
a.dropdown-item((click)="setAlert()")
|
||||
| set alert
|
||||
|
||||
a.icon-button.cursor-pointer(title="Teleport to" (click)="teleportTo()")
|
||||
fa-icon.text-muted([icon]="teleportIcon" [fixedWidth]="true")
|
||||
|
||||
ban-icon((toggle)="toggleBan('mute', $event)" [value]="account.mute" type="mute")
|
||||
ban-icon.ml-ban((toggle)="toggleBan('shadow', $event)" [value]="account.shadow" type="shadow")
|
||||
ban-icon.ml-ban((toggle)="toggleBan('ban', $event)" [value]="account.ban" type="ban")
|
||||
@@ -0,0 +1,98 @@
|
||||
@import 'partials/variables';
|
||||
|
||||
.account-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.account-info-name {
|
||||
.outdated & {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.table & {
|
||||
display: inline-block;
|
||||
max-width: 130px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: bottom;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.account-info-note-indicator {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 2px;
|
||||
color: $body-bg;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
font-size: 10px;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.badge-supporter {
|
||||
padding-left: 3px;
|
||||
padding-right: 3px;
|
||||
|
||||
&.declined {
|
||||
border-right: solid 5px $warning;
|
||||
}
|
||||
|
||||
> i {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-duplicates {
|
||||
@include badge-variant(#bf6060);
|
||||
|
||||
&.none {
|
||||
@include badge-variant(#63a367);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
filter: drop-shadow(0 0 2px #000);
|
||||
}
|
||||
}
|
||||
|
||||
.supporter-buttons .btn {
|
||||
min-width: 25px;
|
||||
}
|
||||
|
||||
.counter-item {
|
||||
margin-bottom: 5px;
|
||||
|
||||
> .text-muted {
|
||||
display: inline-block;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
> .counter-value {
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
width: 60px;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
> button {
|
||||
margin-right: 5px;
|
||||
width: 35px;
|
||||
}
|
||||
}
|
||||
|
||||
.text-present, .text-muted.text-present {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.ml-ban {
|
||||
margin-left: 2px;
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { Component, Input, OnChanges, SimpleChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
|
||||
import { BsModalService, BsModalRef } from 'ngx-bootstrap/modal';
|
||||
import { compact } from 'lodash';
|
||||
import { DAY, MINUTE, HOUR } from '../../../../common/constants';
|
||||
import { hasFlag, fromNow, toInt, setFlag } from '../../../../common/utils';
|
||||
import {
|
||||
Account, AccountFlags, accountCounters, accountFlags, SupporterFlags, supporterFlags, BannedMuted, DuplicatesInfo
|
||||
} from '../../../../common/adminInterfaces';
|
||||
import { supporterLevel, patreonSupporterLevel, getAge, isPastSupporter } from '../../../../common/adminUtils';
|
||||
import { AdminModel } from '../../../services/adminModel';
|
||||
import { AccountCounters } from '../../../../common/interfaces';
|
||||
import {
|
||||
faPatreon, faCog, faMinus, faPlus, faCheck, faFlag, faStickyNote, faCertificate, faIdBadge, faEnvelope, faFont,
|
||||
faClock, faBan, faMapMarkerAlt
|
||||
} from '../../../../client/icons';
|
||||
|
||||
const EMPTY_ROLES: string[] = [];
|
||||
const oldTime = fromNow(-14 * DAY).getTime();
|
||||
const newTime = fromNow(-DAY).getTime();
|
||||
|
||||
const accountDuplicatesIntervalTime = 10 * MINUTE;
|
||||
const accountDuplicates = new Map<string, DuplicatesInfo>();
|
||||
|
||||
const predefinedAlerts = [
|
||||
{
|
||||
name: 'erp:flagged',
|
||||
message: `Your account has been flagged for inappropriate bahavior on PG rated server. `
|
||||
+ `Continuing that behavior may result in permanent ban.`
|
||||
},
|
||||
{
|
||||
name: 'erp:timeout',
|
||||
message: `Your account has been timed out for inappropriate language and bahavior on PG rated server. `
|
||||
+ `Continuing that behavior may result in permanent ban.`
|
||||
},
|
||||
{
|
||||
name: 'dups',
|
||||
message: `Your account has been flagged for making multiple accounts. `
|
||||
+ `Continuing that behavior may result in permanent ban.`
|
||||
},
|
||||
{
|
||||
name: 'under',
|
||||
message: `Your account has been reported for being underage, please do NOT play on 18+ server. `
|
||||
+ `Continuing that may result in permanent ban.`
|
||||
},
|
||||
];
|
||||
|
||||
const alertExpires = [
|
||||
{ name: '1h', length: HOUR },
|
||||
{ name: '5h', length: 5 * HOUR },
|
||||
{ name: '12h', length: 12 * HOUR },
|
||||
{ name: '1d', length: DAY },
|
||||
{ name: '2d', length: 2 * DAY },
|
||||
{ name: '5d', length: 5 * DAY },
|
||||
{ name: '7d', length: 7 * DAY },
|
||||
{ name: '2w', length: 14 * DAY },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'account-info',
|
||||
templateUrl: 'account-info.pug',
|
||||
styleUrls: ['account-info.scss'],
|
||||
})
|
||||
export class AccountInfo implements OnInit, OnChanges {
|
||||
readonly counters = accountCounters;
|
||||
readonly flags = accountFlags;
|
||||
readonly supporterFlags = supporterFlags;
|
||||
readonly cogIcon = faCog;
|
||||
readonly minusIcon = faMinus;
|
||||
readonly plusIcon = faPlus;
|
||||
readonly checkIcon = faCheck;
|
||||
readonly flagIcon = faFlag;
|
||||
readonly noteIcon = faStickyNote;
|
||||
readonly newIcon = faCertificate;
|
||||
readonly duplicateBrowserIdIcon = faIdBadge;
|
||||
readonly duplicateEmailIcon = faEnvelope;
|
||||
readonly duplicateNameIcon = faFont;
|
||||
readonly duplicatePermaIcon = faBan;
|
||||
readonly teleportIcon = faMapMarkerAlt;
|
||||
predefinedAlerts = predefinedAlerts;
|
||||
alertExpires = alertExpires;
|
||||
alertExpire = alertExpires[3];
|
||||
alertMessage = '';
|
||||
@Input() account!: Account;
|
||||
@Input() extendedAuths = false;
|
||||
@Input() popoverPlacement?: string;
|
||||
@Input() showDuplicates = false;
|
||||
@ViewChild('alertModal', { static: true }) alertModal!: TemplateRef<any>;
|
||||
note?: string;
|
||||
duplicates?: DuplicatesInfo;
|
||||
private alertModalRef?: BsModalRef;
|
||||
private _isNoteOpen = false;
|
||||
constructor(private model: AdminModel, private modalService: BsModalService) {
|
||||
}
|
||||
ngOnInit() {
|
||||
this.updateDuplicates();
|
||||
}
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if (changes.account) {
|
||||
this.updateDuplicates();
|
||||
}
|
||||
}
|
||||
get age() {
|
||||
return this.account.birthdate ? getAge(this.account.birthdate) : '-';
|
||||
}
|
||||
get alert() {
|
||||
const alert = this.account.alert;
|
||||
return (alert && alert.expires.getTime() > Date.now()) ? alert : undefined;
|
||||
}
|
||||
get isNew() {
|
||||
return this.account.createdAt && this.account.createdAt.getTime() > newTime;
|
||||
}
|
||||
get isNoteOpen() {
|
||||
return this._isNoteOpen;
|
||||
}
|
||||
set isNoteOpen(value: boolean) {
|
||||
if (this._isNoteOpen !== value) {
|
||||
this._isNoteOpen = value;
|
||||
|
||||
if (value) {
|
||||
this.note = this.account.note;
|
||||
}
|
||||
}
|
||||
}
|
||||
get isInactive() {
|
||||
return this.account && this.account.lastVisit && this.account.lastVisit.getTime() < oldTime;
|
||||
}
|
||||
get roles() {
|
||||
return this.account.roles ? this.account.roles.filter(r => r !== 'superadmin') : EMPTY_ROLES;
|
||||
}
|
||||
get flagClass() {
|
||||
const counters = this.account.counters;
|
||||
|
||||
if (this.account.flags) {
|
||||
return 'text-banned';
|
||||
} else if (!counters && !this.account.supporter) {
|
||||
return 'text-muted';
|
||||
} else if (counters && ((counters.spam || 0) > 100 || (counters.swears || 0) > 10)) {
|
||||
return 'text-alert';
|
||||
} else {
|
||||
return 'text-present';
|
||||
}
|
||||
}
|
||||
get hasDuplicateNote() {
|
||||
return this.account.note && /duplicate/i.test(this.account.note);
|
||||
}
|
||||
hasFlag(value: AccountFlags) {
|
||||
return hasFlag(this.account.flags, value);
|
||||
}
|
||||
toggleFlag(value: AccountFlags) {
|
||||
this.model.setAccountFlags(this.account._id, this.account.flags ^ value);
|
||||
}
|
||||
toggleBan(field: keyof BannedMuted, value: number) {
|
||||
this.model.setAccountBanField(this.account._id, field, value);
|
||||
}
|
||||
kick() {
|
||||
this.model.kick(this.account._id);
|
||||
}
|
||||
report() {
|
||||
this.model.report(this.account._id);
|
||||
}
|
||||
blur() {
|
||||
this.model.setNote(this.account._id, this.note || '');
|
||||
this.isNoteOpen = false;
|
||||
}
|
||||
decrementCounter(name: keyof AccountCounters) {
|
||||
if (this.getCounter(name) > 0) {
|
||||
this.setCounter(name, this.getCounter(name) - 1);
|
||||
}
|
||||
}
|
||||
incrementCounter(name: keyof AccountCounters) {
|
||||
this.setCounter(name, this.getCounter(name) + 1);
|
||||
}
|
||||
getCounter(name: keyof AccountCounters) {
|
||||
const counters = this.account.counters;
|
||||
return toInt(counters && counters[name]);
|
||||
}
|
||||
setCounter(name: keyof AccountCounters, value: number) {
|
||||
const counters = this.account.counters || (this.account.counters = {});
|
||||
counters[name] = value;
|
||||
this.model.setAccountCounter(this.account._id, name, value);
|
||||
}
|
||||
removeAlert() {
|
||||
this.model.setAlert(this.account._id, '', 0);
|
||||
}
|
||||
setAlert() {
|
||||
this.alertMessage = this.alert ? this.alert.message : '';
|
||||
this.alertExpire = this.alertExpires[2];
|
||||
this.alertModalRef = this.modalService.show(this.alertModal, { ignoreBackdropClick: true });
|
||||
}
|
||||
cancelAlert() {
|
||||
this.alertModalRef && this.alertModalRef.hide();
|
||||
this.alertModalRef = undefined;
|
||||
}
|
||||
confirmAlert() {
|
||||
this.model.setAlert(this.account._id, this.alertMessage, this.alertExpire.length);
|
||||
this.cancelAlert();
|
||||
}
|
||||
private updateDuplicates() {
|
||||
const account = this.account;
|
||||
|
||||
if (this.showDuplicates && account) {
|
||||
const cached = accountDuplicates.get(account._id);
|
||||
const threshold = fromNow(-accountDuplicatesIntervalTime);
|
||||
|
||||
if (cached && cached.generatedAt > threshold.getTime()) {
|
||||
this.duplicates = cached;
|
||||
} else {
|
||||
this.model.getAllDuplicatesQuickInfo(account._id)
|
||||
.then(duplicates => {
|
||||
if (duplicates) {
|
||||
accountDuplicates.set(account._id, duplicates);
|
||||
this.duplicates = duplicates;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
teleportTo() {
|
||||
this.model.teleportTo(this.account._id);
|
||||
}
|
||||
// supporters
|
||||
get isPatreonOrSupporter() {
|
||||
return !!(this.account.patreon || this.account.supporter || this.account.supporterDeclinedSince);
|
||||
}
|
||||
get supporterClass() {
|
||||
return supporterLevel(this.account) ? 'badge-success' : 'badge-warning';
|
||||
}
|
||||
get supporterTitle() {
|
||||
const supporter = this.account.supporter!;
|
||||
const flagSupporter = (supporter & SupporterFlags.SupporterMask) !== 0;
|
||||
const patreonSupporter = patreonSupporterLevel(this.account);
|
||||
const ignorePatreon = hasFlag(supporter, SupporterFlags.IgnorePatreon);
|
||||
const pastSupporter = hasFlag(supporter, SupporterFlags.PastSupporter);
|
||||
|
||||
return compact([
|
||||
flagSupporter && 'flags',
|
||||
patreonSupporter && `patreon`,
|
||||
ignorePatreon && 'ignore',
|
||||
!patreonSupporter && this.account.supporterDeclinedSince && 'declined',
|
||||
pastSupporter && 'past',
|
||||
]).join(', ');
|
||||
}
|
||||
get supporterIcon() {
|
||||
const hasPatreon = patreonSupporterLevel(this.account);
|
||||
const hasIgnoreFlag = hasFlag(this.account.supporter, SupporterFlags.IgnorePatreon);
|
||||
const hasDeclined = !!this.account.supporterDeclinedSince;
|
||||
return hasPatreon ? faPatreon : ((hasIgnoreFlag || !hasDeclined) ? faFlag : faClock);
|
||||
}
|
||||
get hasAnySupporter() {
|
||||
return (this.account.supporter! & SupporterFlags.SupporterMask) !== 0;
|
||||
}
|
||||
get hasPastSupporter() {
|
||||
return hasFlag(this.account.supporter, SupporterFlags.PastSupporter);
|
||||
}
|
||||
get supporterLevel() {
|
||||
return supporterLevel(this.account);
|
||||
}
|
||||
get supporterLevelString() {
|
||||
const level = supporterLevel(this.account);
|
||||
return level ? level : (isPastSupporter(this.account) ? 'P' : '');
|
||||
}
|
||||
isSupporter(level: number) {
|
||||
return (this.account.supporter! & SupporterFlags.SupporterMask) === level;
|
||||
}
|
||||
setSupporter(level: number) {
|
||||
const supporter = (this.account.supporter! & ~SupporterFlags.SupporterMask) | level;
|
||||
this.model.setSupporterFlags(this.account._id, supporter);
|
||||
}
|
||||
hasSupporterFlag(value: SupporterFlags) {
|
||||
return hasFlag(this.account.supporter, value);
|
||||
}
|
||||
toggleSupporterFlag(value: SupporterFlags) {
|
||||
this.model.setSupporterFlags(this.account._id, this.account.supporter! ^ value);
|
||||
}
|
||||
// past supporter
|
||||
get isForcePastSupporter() {
|
||||
return hasFlag(this.account.supporter, SupporterFlags.ForcePastSupporter);
|
||||
}
|
||||
get isIgnorePastSupporter() {
|
||||
return hasFlag(this.account.supporter, SupporterFlags.IgnorePastSupporter);
|
||||
}
|
||||
toggleForcePastSupporter() {
|
||||
const has = hasFlag(this.account.supporter, SupporterFlags.ForcePastSupporter);
|
||||
const supporter = setFlag(this.account.supporter, SupporterFlags.ForcePastSupporter, !has);
|
||||
this.model.setSupporterFlags(this.account._id, supporter);
|
||||
}
|
||||
toggleIgnorePastSupporter() {
|
||||
const has = hasFlag(this.account.supporter, SupporterFlags.IgnorePastSupporter);
|
||||
const supporter = setFlag(this.account.supporter, SupporterFlags.IgnorePastSupporter, !has);
|
||||
this.model.setSupporterFlags(this.account._id, supporter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.text-muted(*ngIf="!status")
|
||||
| checking...
|
||||
.text-unsafe(*ngIf="status && !status.length")
|
||||
| offline
|
||||
.text-safe(*ngFor="let s of status")
|
||||
| [{{s.server}}] [{{s.map}}] {{s.character}} ({{s.x}}, {{s.y}}) ({{s.duration}})
|
||||
ua-info.ml-1([userAgent]="s.userAgent")
|
||||
fa-icon.ml-1(*ngIf="s.incognito" [icon]="incognitoIcon")
|
||||
.text-muted(*ngIf="verbose")
|
||||
small {{s.userAgent}}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Component, OnInit, Input, OnChanges } from '@angular/core';
|
||||
import { AdminModel } from '../../../services/adminModel';
|
||||
import { Account, AccountStatus as IAccountStatus } from '../../../../common/adminInterfaces';
|
||||
import { faUserSecret } from '../../../../client/icons';
|
||||
|
||||
@Component({
|
||||
selector: 'account-status',
|
||||
templateUrl: 'account-status.pug',
|
||||
})
|
||||
export class AccountStatus implements OnInit, OnChanges {
|
||||
readonly incognitoIcon = faUserSecret;
|
||||
@Input() account!: Account;
|
||||
@Input() verbose = false;
|
||||
status: IAccountStatus[] | undefined = undefined;
|
||||
constructor(private model: AdminModel) {
|
||||
}
|
||||
ngOnInit() {
|
||||
this.refresh();
|
||||
}
|
||||
ngOnChanges() {
|
||||
this.status = undefined;
|
||||
this.refresh();
|
||||
}
|
||||
refresh() {
|
||||
this.model.getAccountStatus(this.account._id)
|
||||
.then(status => this.status = status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.text-left
|
||||
div
|
||||
| {{account.name || '[none]'}} [{{account._id | slice:-3}}]
|
||||
auth-list-remote.ml-1(*ngIf="!extendedAuths" [accountId]="account._id")
|
||||
.text-muted
|
||||
em {{account.name | translit}}
|
||||
div(*ngIf="extendedAuths")
|
||||
auth-list-remote([accountId]="account._id" [extended]="true")
|
||||
.text-muted(*ngFor="let e of account.emails | slice:0:3")
|
||||
| {{e}}
|
||||
.text-muted(*ngIf="account.emails && account.emails.length > 3")
|
||||
| ({{account.emails.length - 3}} more)
|
||||
div
|
||||
span.text-muted created:
|
||||
from-now.ml-1([time]="account.createdAt")
|
||||
div
|
||||
span.text-muted last visit:
|
||||
from-now.ml-1([time]="account.lastVisit")
|
||||
div
|
||||
span.text-muted ponies:
|
||||
span.ml-1 {{account.characterCount}}
|
||||
div
|
||||
span.text-muted age:
|
||||
span.ml-1([class.text-strike]="account.birthyear") {{age}}
|
||||
span.ml-1 {{forceAge}}
|
||||
div
|
||||
account-status([account]="account")
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { Account } from '../../../../common/adminInterfaces';
|
||||
import { getAge } from '../../../../common/adminUtils';
|
||||
|
||||
const year = (new Date()).getFullYear();
|
||||
|
||||
@Component({
|
||||
selector: 'account-tooltip',
|
||||
templateUrl: 'account-tooltip.pug',
|
||||
})
|
||||
export class AccountTooltip {
|
||||
@Input() account!: Account;
|
||||
@Input() extendedAuths = false;
|
||||
get age() {
|
||||
return this.account.birthdate ? getAge(this.account.birthdate) : '-';
|
||||
}
|
||||
get forceAge() {
|
||||
return this.account.birthyear ? (year - this.account.birthyear) : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
div(*ngIf="open || !canClose")
|
||||
.d-flex.justify-content-between.align-items-center
|
||||
h3
|
||||
| {{title}}
|
||||
span.text-muted.ml-1
|
||||
| ({{date ? date.label : 'none'}})
|
||||
span.text-muted.ml-1(*ngIf="loading")
|
||||
fa-icon([icon]="spinnerIcon" [spin]="true")
|
||||
.form-inline
|
||||
ng-content
|
||||
form.d-inline-block((submit)="searchChat(searchInput.value)")
|
||||
input.form-control.form-control-sm.ml-1(#searchInput type="search" placeholder="search")
|
||||
button.btn.btn-sm.btn-default.ml-1(type="submit" title="Search whole chat for phrase")
|
||||
fa-icon([icon]="searchIcon" [fixedWidth]="true" size="lg")
|
||||
button.btn.btn-sm.btn-default.ml-1((click)="openLog()" title="Open log in plain text format")
|
||||
fa-icon([icon]="fileIcon" [fixedWidth]="true" size="lg")
|
||||
button.btn.btn-sm.ml-1(
|
||||
[(ngModel)]="autoRefresh" btnCheckbox [btnHighlight]="autoRefresh" title="Auto-refresh every 10 seconds")
|
||||
fa-icon.mr-1([icon]="syncIcon" [fixedWidth]="true" size="lg")
|
||||
| auto
|
||||
.btn-group.btn-group-sm.ml-1
|
||||
button.btn.btn-sm.btn-default((click)="prev()" title="Load previous day")
|
||||
fa-icon([icon]="chevronLeftIcon" [fixedWidth]="true" size="lg")
|
||||
button.btn.btn-sm.btn-default((click)="next()" title="Load next day")
|
||||
fa-icon([icon]="chevronRightIcon" [fixedWidth]="true" size="lg")
|
||||
.btn-group.btn-group-sm.dropup.ml-1(dropdown)
|
||||
button.btn.btn-sm.btn-default((click)="showDate(today)")
|
||||
| Load todays chat
|
||||
button.btn.btn-default.dropdown-toggle(dropdownToggle)
|
||||
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
|
||||
a.dropdown-item(*ngFor="let d of dates" (click)="showDate(d)")
|
||||
| {{d.label}}
|
||||
button.btn.btn-sm.btn-default.ml-1(*ngIf="canClose" (click)="close()")
|
||||
fa-icon([icon]="closeIcon" [fixedWidth]="true")
|
||||
.mb-1(*ngIf="accounts.length")
|
||||
a.badge.badge-none.mr-1(*ngFor="let a of accounts; let i = index" (click)="removeAccount(i)" title="Remove")
|
||||
| [{{i + 1}}] {{a.name}}
|
||||
.well.pre.chatlog
|
||||
@@ -0,0 +1 @@
|
||||
// @import 'partials/variables';
|
||||
@@ -0,0 +1,182 @@
|
||||
import { Component, Input, OnDestroy, ElementRef } from '@angular/core';
|
||||
import * as moment from 'moment';
|
||||
import { AdminModel } from '../../../services/adminModel';
|
||||
import { Account } from '../../../../common/adminInterfaces';
|
||||
import { ChatDate, createChatDate, createDateRange, replaceSwears } from '../../../../common/adminUtils';
|
||||
import { faSearch, faSpinner, faSync, faFileAlt, faTimes, faChevronLeft, faChevronRight } from '../../../../client/icons';
|
||||
import { removeAllNodes, appendAllNodes, showTextInNewTab } from '../../../../client/htmlUtils';
|
||||
import { includes } from '../../../../common/utils';
|
||||
|
||||
@Component({
|
||||
selector: 'admin-chat-log',
|
||||
templateUrl: 'admin-chat-log.pug',
|
||||
styleUrls: ['admin-chat-log.scss'],
|
||||
})
|
||||
export class AdminChatLog implements OnDestroy {
|
||||
readonly searchIcon = faSearch;
|
||||
readonly spinnerIcon = faSpinner;
|
||||
readonly syncIcon = faSync;
|
||||
readonly fileIcon = faFileAlt;
|
||||
readonly closeIcon = faTimes;
|
||||
readonly chevronLeftIcon = faChevronLeft;
|
||||
readonly chevronRightIcon = faChevronRight;
|
||||
@Input() canClose = true;
|
||||
accounts: Account[] = [];
|
||||
search?: string;
|
||||
open = false;
|
||||
today: ChatDate = createChatDate(moment());
|
||||
dates: ChatDate[] = [/*{ value: 'all', label: 'All' },*/ ...createDateRange(new Date(), 14)];
|
||||
date?: ChatDate;
|
||||
chatRaw?: string;
|
||||
loading = false;
|
||||
private _account?: Account;
|
||||
private refreshInterval?: any;
|
||||
constructor(private model: AdminModel, private element: ElementRef) {
|
||||
}
|
||||
get autoRefresh() {
|
||||
return !!this.refreshInterval;
|
||||
}
|
||||
set autoRefresh(value: boolean) {
|
||||
if (value) {
|
||||
this.refreshInterval = this.refreshInterval || setInterval(() => this.refresh(), 10 * 1000);
|
||||
} else {
|
||||
this.stopInterval();
|
||||
}
|
||||
}
|
||||
get title() {
|
||||
return this.search || (this.account && this.account.name) || 'Chat';
|
||||
}
|
||||
get account() {
|
||||
return this._account;
|
||||
}
|
||||
@Input() set account(value) {
|
||||
const theSame = this._account === value || (value && this._account && value._id === this._account._id);
|
||||
this._account = value;
|
||||
|
||||
if (!theSame) {
|
||||
this.date = undefined;
|
||||
this.setChatlogElements([]);
|
||||
}
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.close();
|
||||
}
|
||||
show(account?: Account, date?: ChatDate) {
|
||||
this.search = undefined;
|
||||
this.account = account;
|
||||
this.date = date || this.today;
|
||||
this.open = true;
|
||||
this.accounts = [];
|
||||
this.refresh();
|
||||
}
|
||||
add(account: Account) {
|
||||
if (!this.account) {
|
||||
this.show(account);
|
||||
} else if (account !== this.account && !includes(this.accounts, account)) {
|
||||
this.date = this.date || this.today;
|
||||
this.accounts.push(account);
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
removeAccount(index: number) {
|
||||
this.accounts.splice(index, 1);
|
||||
this.refresh();
|
||||
}
|
||||
showDate(date: ChatDate) {
|
||||
this.date = date;
|
||||
this.refresh();
|
||||
}
|
||||
prev() {
|
||||
this.switchDate(-1);
|
||||
}
|
||||
next() {
|
||||
this.switchDate(1);
|
||||
}
|
||||
all() {
|
||||
this.date = this.dates[0];
|
||||
this.refresh();
|
||||
}
|
||||
close() {
|
||||
this.account = undefined;
|
||||
this.date = undefined;
|
||||
this.open = false;
|
||||
this.setChatlogElements([]);
|
||||
this.stopInterval();
|
||||
}
|
||||
searchChat(search: string | undefined) {
|
||||
this.search = search;
|
||||
this.refresh();
|
||||
}
|
||||
refresh() {
|
||||
const date = this.date && this.date.value;
|
||||
|
||||
if (this.account) {
|
||||
const accounts = [this.account._id, ...this.accounts.map(a => a._id)];
|
||||
this.handleChat(this.model.accountsFormattedChat(accounts, date));
|
||||
} else if (this.search) {
|
||||
this.handleChat(this.model.searchFormattedChat(this.search, date));
|
||||
}
|
||||
}
|
||||
openLog() {
|
||||
showTextInNewTab(`${this.date ? this.date.label : 'none'}\n\n${(this.chatRaw || '').replace(/\t/g, ' ')}`);
|
||||
}
|
||||
private handleChat(promise: Promise<{ raw: string; html: HTMLElement[]; }>) {
|
||||
this.loading = true;
|
||||
|
||||
promise
|
||||
.then(({ raw, html }) => {
|
||||
this.chatRaw = raw;
|
||||
this.setChatlogElements(html);
|
||||
})
|
||||
.finally(() => this.loading = false);
|
||||
}
|
||||
private switchDate(days: number) {
|
||||
const validDate = this.date && this.date.value !== 'all';
|
||||
this.date = validDate ? createChatDate(moment(this.date!.value).add(days, 'days')) : this.today;
|
||||
this.refresh();
|
||||
}
|
||||
private stopInterval() {
|
||||
clearInterval(this.refreshInterval);
|
||||
this.refreshInterval = undefined;
|
||||
}
|
||||
private getChatlogElement() {
|
||||
return (this.element.nativeElement as HTMLElement).querySelector('.chatlog');
|
||||
}
|
||||
private setChatlogElements(elements: HTMLElement[]) {
|
||||
const element = this.getChatlogElement();
|
||||
|
||||
if (element) {
|
||||
removeAllNodes(element);
|
||||
appendAllNodes(element, elements);
|
||||
this.nodesToProcess = [
|
||||
...Array.from(element.getElementsByClassName('name')),
|
||||
...Array.from(element.getElementsByClassName('message')),
|
||||
] as HTMLElement[];
|
||||
this.atNode = 0;
|
||||
this.processNodes();
|
||||
}
|
||||
}
|
||||
private processNodes() {
|
||||
const processStep = 50;
|
||||
const nodes = this.nodesToProcess;
|
||||
|
||||
cancelIdleCallback(this.processIdle);
|
||||
|
||||
if (nodes && this.atNode < nodes.length) {
|
||||
let i = 0;
|
||||
|
||||
while (i < processStep && (i + this.atNode) < nodes.length) {
|
||||
replaceSwears(nodes[i + this.atNode]);
|
||||
i++;
|
||||
}
|
||||
|
||||
this.atNode += i;
|
||||
this.processIdle = requestIdleCallback(() => this.processNodes());
|
||||
} else {
|
||||
this.nodesToProcess = undefined;
|
||||
}
|
||||
}
|
||||
private nodesToProcess?: HTMLElement[];
|
||||
private atNode = 0;
|
||||
private processIdle = 0;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
.d-flex.align-items-center.mb-1
|
||||
auth-info.flex-grow-1([auth]="auth" [showName]="true")
|
||||
small.text-muted
|
||||
from-now.mr-1([time]="auth?.lastUsed")
|
||||
.btn-group.dropdown(dropdown)
|
||||
button.btn.btn-xs.btn-default(dropdownToggle title="Assign to another account")
|
||||
fa-icon([icon]="assignIcon" [fixedWidth]="true")
|
||||
.dropdown-menu(*dropdownMenu)
|
||||
button.dropdown-item(*ngFor="let d of duplicates" (click)="assignTo(d.account)")
|
||||
span.text-muted assign to
|
||||
account-info-remote.ml-1([accountId]="d.account" [basic]="true")
|
||||
.btn-group.ml-1
|
||||
button.btn.btn-xs.btn-default((click)="showAuthData(auth)" title="Show more data")
|
||||
fa-icon([icon]="infoIcon" [fixedWidth]="true")
|
||||
button.btn.btn-xs((click)="toggleAuthBanned(auth)" [btnHighlight]="auth?.banned || false"
|
||||
title="Toggle auth locked (can't be restored by user)")
|
||||
fa-icon([icon]="lockIcon" [fixedWidth]="true")
|
||||
button.btn.btn-xs((click)="toggleAuthDisabled(auth)" [btnHighlight]="auth?.disabled || false"
|
||||
title="Toggle auth disabled (can be restored by user)")
|
||||
fa-icon([icon]="eyeSlashIcon" [fixedWidth]="true")
|
||||
button.btn.btn-xs.btn-default((click)="removeAuth(auth)" title="Remove auth")
|
||||
fa-icon([icon]="trashIcon" [fixedWidth]="true")
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Component, OnDestroy, Input } from '@angular/core';
|
||||
import { Auth, DuplicateResult } from '../../../../common/adminInterfaces';
|
||||
import { Subscription } from '../../../../common/interfaces';
|
||||
import { AdminModel } from '../../../services/adminModel';
|
||||
import { faInfo, faLock, faTrash, faEyeSlash, faArrowRight } from '../../../../client/icons';
|
||||
|
||||
@Component({
|
||||
selector: 'auth-info-edit',
|
||||
templateUrl: 'auth-info-edit.pug',
|
||||
})
|
||||
export class AuthInfoEdit implements OnDestroy {
|
||||
readonly assignIcon = faArrowRight;
|
||||
readonly infoIcon = faInfo;
|
||||
readonly lockIcon = faLock;
|
||||
readonly trashIcon = faTrash;
|
||||
readonly eyeSlashIcon = faEyeSlash;
|
||||
@Input() duplicates?: DuplicateResult[];
|
||||
@Input() showName = false;
|
||||
auth?: Auth;
|
||||
private _authId?: string;
|
||||
private subscription?: Subscription;
|
||||
constructor(private model: AdminModel) {
|
||||
}
|
||||
get authId() {
|
||||
return this._authId;
|
||||
}
|
||||
@Input() set authId(value: string | undefined) {
|
||||
if (this.authId !== value) {
|
||||
this._authId = value;
|
||||
this.auth = undefined;
|
||||
this.subscription && this.subscription.unsubscribe();
|
||||
this.subscription = value ? this.model.auths.subscribe(value, auth => this.auth = auth) : undefined;
|
||||
}
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.subscription && this.subscription.unsubscribe();
|
||||
}
|
||||
removeAuth(auth: Auth | undefined) {
|
||||
if (auth && confirm('Are you sure?')) {
|
||||
this.model.removeAuth(auth._id);
|
||||
}
|
||||
}
|
||||
showAuthData(auth: Auth | undefined) {
|
||||
if (auth) {
|
||||
this.model.getAuth(auth._id)
|
||||
.then(x => console.log(x));
|
||||
}
|
||||
}
|
||||
toggleAuthDisabled(auth: Auth | undefined) {
|
||||
if (auth) {
|
||||
this.model.updateAuth(auth._id, { disabled: !auth.disabled });
|
||||
}
|
||||
}
|
||||
toggleAuthBanned(auth: Auth | undefined) {
|
||||
if (auth) {
|
||||
this.model.updateAuth(auth._id, { banned: !auth.banned });
|
||||
}
|
||||
}
|
||||
assignTo(accountId: string) {
|
||||
if (this.authId) {
|
||||
this.model.assignAuth(this.authId, accountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
auth-info([auth]="auth" [showName]="showName")
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Component, Input, OnDestroy } from '@angular/core';
|
||||
import { Subscription } from '../../../../common/interfaces';
|
||||
import { AdminModel } from '../../../services/adminModel';
|
||||
import { Auth } from '../../../../common/adminInterfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'auth-info-remote',
|
||||
templateUrl: 'auth-info-remote.pug',
|
||||
})
|
||||
export class AuthInfoRemote implements OnDestroy {
|
||||
@Input() showName = false;
|
||||
auth?: Auth;
|
||||
private _authId?: string;
|
||||
private subscription?: Subscription;
|
||||
constructor(private model: AdminModel) {
|
||||
}
|
||||
get authId() {
|
||||
return this._authId;
|
||||
}
|
||||
@Input() set authId(value: string | undefined) {
|
||||
if (this.authId !== value) {
|
||||
this._authId = value;
|
||||
this.auth = undefined;
|
||||
this.subscription && this.subscription.unsubscribe();
|
||||
this.subscription = value ? this.model.auths.subscribe(value, auth => this.auth = auth) : undefined;
|
||||
}
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.subscription && this.subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
a(*ngIf="auth?.url" [href]="auth?.url" target="_blank" rel="noopener noreferrer" [title]="name")
|
||||
fa-icon([icon]="icon" [fixedWidth]="true")
|
||||
span(*ngIf="!auth?.url" [title]="name")
|
||||
fa-icon([icon]="icon" [fixedWidth]="true")
|
||||
span(*ngIf="showName")
|
||||
span.ml-1(*ngIf="name")
|
||||
| {{name}}
|
||||
em.text-muted.ml-1
|
||||
| {{name | translit}}
|
||||
span.text-muted.ml-1(*ngIf="!name")
|
||||
| <no name>
|
||||
span.badge.badge-success.ml-1(*ngIf="pledged")
|
||||
| ${{pledged}}
|
||||
@@ -0,0 +1,4 @@
|
||||
fa-icon {
|
||||
font-size: 13px;
|
||||
margin: 0 1px;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { Auth } from '../../../../common/adminInterfaces';
|
||||
import { oauthIcons, faGlobe } from '../../../../client/icons';
|
||||
|
||||
@Component({
|
||||
selector: 'auth-info',
|
||||
templateUrl: 'auth-info.pug',
|
||||
styleUrls: ['auth-info.scss'],
|
||||
host: {
|
||||
'[class.deleted]': 'deleted',
|
||||
},
|
||||
})
|
||||
export class AuthInfo {
|
||||
@Input() auth?: Auth;
|
||||
@Input() showName = false;
|
||||
get deleted(): boolean {
|
||||
return !!(this.auth && (this.auth.disabled || this.auth.banned));
|
||||
}
|
||||
get name(): string {
|
||||
return this.auth && this.auth.name || '';
|
||||
}
|
||||
get icon() {
|
||||
return oauthIcons[this.auth && this.auth.provider || ''] || faGlobe;
|
||||
}
|
||||
get pledged() {
|
||||
return (this.auth && this.auth.pledged || 0) / 100;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
span([class.extended]="extended")
|
||||
span.text-muted(*ngIf="loading") ...
|
||||
auth-info-remote(*ngFor="let a of auths | slice:0:limit" [authId]="a" [showName]="extended")
|
||||
a.text-muted.ml-1.text-nowrap(*ngIf="auths.length > limit" (click)="limit = 9999")
|
||||
small ({{auths.length - limit}} more)
|
||||
@@ -0,0 +1,3 @@
|
||||
.extended > auth-info-remote {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Component, Input, OnDestroy } from '@angular/core';
|
||||
import { Subscription } from '../../../../common/interfaces';
|
||||
import { AdminModel } from '../../../services/adminModel';
|
||||
|
||||
@Component({
|
||||
selector: 'auth-list-remote',
|
||||
templateUrl: 'auth-list-remote.pug',
|
||||
styleUrls: ['auth-list-remote.scss'],
|
||||
})
|
||||
export class AuthListRemote implements OnDestroy {
|
||||
@Input() limit = 6;
|
||||
@Input() extended = false;
|
||||
auths: string[] = [];
|
||||
loading = false;
|
||||
private _accountId?: string;
|
||||
private subscription?: Subscription;
|
||||
constructor(private model: AdminModel) {
|
||||
}
|
||||
get accountId() {
|
||||
return this._accountId;
|
||||
}
|
||||
@Input() set accountId(value: string | undefined) {
|
||||
if (this.accountId !== value) {
|
||||
this._accountId = value;
|
||||
this.auths = [];
|
||||
this.loading = true;
|
||||
this.subscription && this.subscription.unsubscribe();
|
||||
this.subscription = value ? this.model.accountAuths.subscribe(value, auths => {
|
||||
this.auths = auths || [];
|
||||
this.loading = false;
|
||||
}) : undefined;
|
||||
}
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.subscription && this.subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
span([class.extended]="extended")
|
||||
auth-info(*ngFor="let a of fixedAuths | slice:0:limit" [auth]="a" [showName]="extended")
|
||||
a.text-muted.ml-1.text-nowrap(*ngIf="fixedAuths.length > limit" (click)="limit = 9999")
|
||||
small ({{fixedAuths.length - limit}} more)
|
||||
@@ -0,0 +1,7 @@
|
||||
.extended {
|
||||
display: block;
|
||||
|
||||
auth-info, span {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { Auth } from '../../../../common/adminInterfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'auth-list',
|
||||
templateUrl: 'auth-list.pug',
|
||||
styleUrls: ['auth-list.scss'],
|
||||
host: {
|
||||
'[class.extended]': 'extended',
|
||||
},
|
||||
})
|
||||
export class AuthList {
|
||||
@Input() limit = 6;
|
||||
@Input() extended = false;
|
||||
@Input() auths?: Auth[];
|
||||
get fixedAuths() {
|
||||
return this.auths || [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
ng-template(#timeoutTooltip)
|
||||
from-now([time]="value")
|
||||
|
||||
.dropdown(dropdown)
|
||||
a.icon-button.cursor-pointer(dropdownToggle [title]="type" [tooltip]="isTimedOut ? timeoutTooltip : null")
|
||||
fa-icon([icon]="icon" [fixedWidth]="true" [ngClass]="className")
|
||||
fa-icon.text-alert.ban-icon-timer(*ngIf="isTimedOut" [icon]="clockIcon" [fixedWidth]="true")
|
||||
|
||||
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
|
||||
a.dropdown-item((click)="clear()")
|
||||
| clear #[b {{type}}]
|
||||
a.dropdown-item((click)="perma()")
|
||||
| perma #[b {{type}}]
|
||||
.dropdown-divider
|
||||
a.dropdown-item(*ngFor="let t of timeouts" (click)="timeout(t.value)")
|
||||
| {{t.label}}
|
||||
@@ -0,0 +1,26 @@
|
||||
.icon-button {
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
filter: drop-shadow(0 0 2px #000);
|
||||
}
|
||||
}
|
||||
|
||||
.ban-icon-timer {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
i.ban-icon-timer {
|
||||
text-shadow: 0 0 1px #000;
|
||||
}
|
||||
|
||||
fa-icon.ban-icon-timer {
|
||||
filter: drop-shadow(0 0 1px #000);
|
||||
}
|
||||
|
||||
i, fa-icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Component, Input, EventEmitter, Output, ChangeDetectionStrategy, OnInit, OnDestroy, OnChanges, NgZone
|
||||
} from '@angular/core';
|
||||
import { TIMEOUTS } from '../../../../common/constants';
|
||||
import { BannedMuted } from '../../../../common/adminInterfaces';
|
||||
import { IntervalUpdateService } from '../../../services/intervalUpdateService';
|
||||
import { faClock, faMicrophoneSlash, faEyeSlash, faBan } from '../../../../client/icons';
|
||||
|
||||
const ICONS = {
|
||||
mute: faMicrophoneSlash,
|
||||
shadow: faEyeSlash,
|
||||
ban: faBan,
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'ban-icon',
|
||||
templateUrl: 'ban-icon.pug',
|
||||
styleUrls: ['ban-icon.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BanIcon implements OnInit, OnDestroy, OnChanges {
|
||||
readonly timeouts = TIMEOUTS;
|
||||
readonly clockIcon = faClock;
|
||||
@Input() type: keyof BannedMuted = 'ban';
|
||||
@Input() value = 0;
|
||||
@Output() toggle = new EventEmitter<number>();
|
||||
get icon() {
|
||||
return ICONS[this.type] || ICONS.ban;
|
||||
}
|
||||
get isPerma() {
|
||||
return this.value === -1;
|
||||
}
|
||||
get isTimedOut() {
|
||||
return this.value > Date.now();
|
||||
}
|
||||
get className() {
|
||||
if (this.isPerma) {
|
||||
return 'text-banned';
|
||||
} else if (this.isTimedOut) {
|
||||
return 'text-alert';
|
||||
} else {
|
||||
return 'text-muted';
|
||||
}
|
||||
}
|
||||
private timedOut = false;
|
||||
private toggleUpdate: (on: boolean) => void;
|
||||
constructor(zone: NgZone, updateService: IntervalUpdateService) {
|
||||
this.toggleUpdate = updateService.toggle(() => {
|
||||
if (this.timedOut !== this.isTimedOut) {
|
||||
zone.run(() => this.timedOut = this.isTimedOut);
|
||||
this.toggleUpdate(this.isTimedOut);
|
||||
}
|
||||
});
|
||||
}
|
||||
ngOnInit() {
|
||||
this.toggleUpdate(this.isTimedOut);
|
||||
}
|
||||
ngOnChanges() {
|
||||
this.toggleUpdate(this.isTimedOut);
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.toggleUpdate(false);
|
||||
}
|
||||
clear() {
|
||||
this.setValue(0);
|
||||
}
|
||||
perma() {
|
||||
this.setValue(-1);
|
||||
}
|
||||
timeout(value: number) {
|
||||
this.setValue(Date.now() + value);
|
||||
}
|
||||
private setValue(value: number) {
|
||||
this.value = value;
|
||||
this.toggle.emit(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
div(*ngIf="emails && emails.length")
|
||||
.account-email.text-muted(*ngFor="let e of emails | slice:0:limit")
|
||||
| {{e}}
|
||||
div(*ngIf="hasMore")
|
||||
a.text-muted.ml-1.text-nowrap((click)="showMore()")
|
||||
small ({{emails.length - limit}} more)
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'email-list',
|
||||
templateUrl: 'email-list.pug',
|
||||
})
|
||||
export class EmailList {
|
||||
@Input() emails?: string[];
|
||||
limit = 3;
|
||||
get hasMore() {
|
||||
return this.emails && this.emails.length > this.limit;
|
||||
}
|
||||
showMore() {
|
||||
this.limit = 9999;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
table.table.table-fixed.table-striped.table-hover.table-sm.table-ellipsis
|
||||
thead
|
||||
tr
|
||||
th.col-time created
|
||||
th.col-time updated
|
||||
th message
|
||||
th.col-account account
|
||||
th.col-auth auths
|
||||
th.col-pony pony
|
||||
th.col-origin origin
|
||||
th.col-actions actions
|
||||
tbody
|
||||
tr(*ngFor="let e of events" [class.deleted]="e.deleted")
|
||||
td.col-time
|
||||
time-field([time]="e.createdAt")
|
||||
td.col-time
|
||||
time-field(*ngIf="e.createdAt !== e.updatedAt" [time]="e.updatedAt")
|
||||
td
|
||||
span.badge.badge-admin([ngClass]="serverLabel(e)") {{e.server}}
|
||||
span.badge.badge-admin([ngClass]="'badge-' + e.type") {{e.type}}
|
||||
span.badge.badge-admin(*ngIf="e.count > 1" [ngClass]="e.count > 9 ? 'badge-danger' : 'badge-none'") {{e.count}}
|
||||
b {{e.message}}
|
||||
a.text-muted(*ngIf="e.account" (click)="copyToNotes(e, accountInfo.account)" title="copy to account notes")
|
||||
fa-icon([icon]="clipboardIcon")
|
||||
.text-muted.event-desc.pre-line(*ngIf="e.desc")
|
||||
span([innerHTML]="e.descHTML")
|
||||
a.text-muted.ml-1([href]="translateUrl(e)" target="_blank" title="translate")
|
||||
fa-icon([icon]="langIcon")
|
||||
td.col-account
|
||||
account-info-remote(#accountInfo [accountId]="e.account" [showDuplicates]="true")
|
||||
td.col-auth
|
||||
auth-list-remote([accountId]="e.account")
|
||||
td.col-pony
|
||||
pony-info-remote(*ngIf="e.pony" [ponyId]="e.pony")
|
||||
td.col-origin
|
||||
origin-info-remote([originIP]="e.origin?.ip")
|
||||
td.col-actions.text-nowrap
|
||||
button.btn.btn-xs.btn-danger.mr-1((click)="remove(e)" title="Remove event")
|
||||
fa-icon([icon]="trashIcon")
|
||||
.btn-group.dropdown(dropdown)
|
||||
button.btn.btn-xs.btn-default(
|
||||
(click)="onShowChat($event, e, accountInfo.account)" [disabled]="!e.account" title="Show chat log")
|
||||
fa-icon([icon]="commentIcon")
|
||||
button.btn.btn-xs.btn-default.dropdown-toggle(dropdownToggle)
|
||||
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
|
||||
a.dropdown-item((click)="removeAll(e)")
|
||||
fa-icon.mr-1([icon]="trashIcon" [fixedWidth]="true")
|
||||
| Remove all #[strong {{e.message}}]
|
||||
a.dropdown-item((click)="onAddChat(e, accountInfo.account)")
|
||||
fa-icon.mr-1([icon]="commentIcon" [fixedWidth]="true")
|
||||
| Add to chat log
|
||||
@@ -0,0 +1,8 @@
|
||||
.event-desc {
|
||||
word-wrap: break-word;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.col-actions {
|
||||
width: 90px;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { Account, Event, SERVER_LABELS, ChatEvent } from '../../../../common/adminInterfaces';
|
||||
import { AdminModel } from '../../../services/adminModel';
|
||||
import { faLanguage, faTrash, faComment, faClipboard } from '../../../../client/icons';
|
||||
import { getTranslationUrl } from '../../../../common/adminUtils';
|
||||
|
||||
@Component({
|
||||
selector: 'events-table',
|
||||
templateUrl: 'events-table.pug',
|
||||
styleUrls: ['events-table.scss'],
|
||||
})
|
||||
export class EventsTable {
|
||||
readonly clipboardIcon = faClipboard;
|
||||
readonly langIcon = faLanguage;
|
||||
readonly trashIcon = faTrash;
|
||||
readonly commentIcon = faComment;
|
||||
@Input() events!: Event[];
|
||||
@Output() showChat = new EventEmitter<ChatEvent>();
|
||||
@Output() addChat = new EventEmitter<ChatEvent>();
|
||||
@Output() removedEvent = new EventEmitter<Event>();
|
||||
constructor(private model: AdminModel) {
|
||||
}
|
||||
serverLabel(e: Event) {
|
||||
return SERVER_LABELS[e.server] || 'badge-none';
|
||||
}
|
||||
remove(e: Event) {
|
||||
this.model.removeEvent(e._id);
|
||||
this.removedEvent.emit(e);
|
||||
}
|
||||
removeAll(e: Event) {
|
||||
this.model.events
|
||||
.filter(x => x.message === e.message)
|
||||
.forEach(x => this.model.removeEvent(x._id));
|
||||
}
|
||||
copyToNotes(e: Event, account: Account | undefined) {
|
||||
if (account) {
|
||||
const desc = e.desc ? `: ${e.desc}` : '';
|
||||
const count = e.count > 1 ? `[${e.count}] ` : '';
|
||||
const note = `${(account.note || '')}\r\n[${e.server}]${count}${e.message}${desc}`;
|
||||
this.model.setNote(account._id, note.trim());
|
||||
}
|
||||
}
|
||||
translateUrl(e: Event) {
|
||||
return getTranslationUrl(e.desc);
|
||||
}
|
||||
onShowChat(e: MouseEvent, event: Event, account: Account | undefined) {
|
||||
if (e.shiftKey) {
|
||||
this.addChat.emit({ event, account });
|
||||
} else {
|
||||
this.showChat.emit({ event, account });
|
||||
}
|
||||
}
|
||||
onAddChat(event: Event, account: Account | undefined) {
|
||||
this.addChat.emit({ event, account });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Component, Input, OnInit, OnDestroy, OnChanges, ElementRef, ChangeDetectionStrategy } from '@angular/core';
|
||||
import * as moment from 'moment';
|
||||
import { IntervalUpdateService } from '../../services/intervalUpdateService';
|
||||
|
||||
@Component({
|
||||
selector: 'from-now',
|
||||
template: '<span></span>',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FromNow implements OnInit, OnDestroy, OnChanges {
|
||||
@Input() time?: any;
|
||||
private moment?: moment.Moment;
|
||||
private text?: string;
|
||||
private unsubscribe?: () => void;
|
||||
constructor(private element: ElementRef, private updateService: IntervalUpdateService) {
|
||||
}
|
||||
ngOnChanges() {
|
||||
this.moment = this.time ? moment(this.time) : undefined;
|
||||
this.update();
|
||||
}
|
||||
ngOnInit() {
|
||||
this.unsubscribe = this.updateService.subscribe(() => this.update());
|
||||
this.update();
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.unsubscribe && this.unsubscribe();
|
||||
}
|
||||
private update() {
|
||||
const text = this.moment ? this.moment.fromNow(true).replace('seconds', 'secs') : '';
|
||||
|
||||
if (this.text !== text) {
|
||||
this.text = text;
|
||||
(this.element.nativeElement as HTMLElement).children[0].textContent = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.btn-group.mb-1
|
||||
button.btn.btn-xs([ngClass]="on ? 'btn-default' : 'btn-danger active'" (click)="onToggle(false)" [disabled]="disabled")
|
||||
b {{offText}}
|
||||
button.btn.btn-xs([ngClass]="on ? 'btn-success active' : 'btn-default'" (click)="onToggle(true)" [disabled]="disabled")
|
||||
b {{onText}}
|
||||
|
||||
b.ml-2.mb-1.text-muted
|
||||
| {{label}}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'on-off-switch',
|
||||
templateUrl: 'on-off-switch.pug',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class OnOffSwitch {
|
||||
@Input() on = false;
|
||||
@Input() disabled = false;
|
||||
@Input() onText = 'ON';
|
||||
@Input() offText = 'OFF';
|
||||
@Input() label = '';
|
||||
@Output() toggle = new EventEmitter<boolean>();
|
||||
onToggle(value: boolean) {
|
||||
if (value !== this.on) {
|
||||
this.toggle.emit(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
origin-info([origin]="origin")
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Component, Input, OnDestroy } from '@angular/core';
|
||||
import { Subscription } from '../../../../common/interfaces';
|
||||
import { AdminModel } from '../../../services/adminModel';
|
||||
import { Origin } from '../../../../common/adminInterfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'origin-info-remote',
|
||||
templateUrl: 'origin-info-remote.pug',
|
||||
})
|
||||
export class OriginInfoRemote implements OnDestroy {
|
||||
@Input() showName = false;
|
||||
origin?: Origin;
|
||||
private _originIP?: string;
|
||||
private subscription?: Subscription;
|
||||
constructor(private model: AdminModel) {
|
||||
}
|
||||
get originIP() {
|
||||
return this._originIP;
|
||||
}
|
||||
@Input() set originIP(value: string | undefined) {
|
||||
if (this.originIP !== value) {
|
||||
this._originIP = value;
|
||||
this.origin = undefined;
|
||||
this.subscription && this.subscription.unsubscribe();
|
||||
this.subscription = value ? this.model.origins.subscribe(value, origin => this.origin = origin) : undefined;
|
||||
}
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.subscription && this.subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.d-flex.align-items-center(*ngIf="origin")
|
||||
a.origin-info-ip.mr-1([routerLink]="['/origins', origin.ip, origin.country]" [title]="origin.ip")
|
||||
| {{origin.ip}}
|
||||
span([title]="countryName") [{{origin.country}}]
|
||||
span.badge.badge-none.ml-1(*ngIf="origin.accountsCount > 1" title="accounts using this IP")
|
||||
| {{origin.accountsCount}}
|
||||
ban-icon((toggle)="toggleBan('mute', $event)" [value]="origin.mute" type="mute")
|
||||
ban-icon((toggle)="toggleBan('shadow', $event)" [value]="origin.shadow" type="shadow")
|
||||
ban-icon((toggle)="toggleBan('ban', $event)" [value]="origin.ban" type="ban")
|
||||
@@ -0,0 +1,17 @@
|
||||
.origin-info-ip {
|
||||
display: inline-block;
|
||||
max-width: 110px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: bottom;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 13px;
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
ban-icon {
|
||||
margin-left: 3px;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { Origin, BannedMuted } from '../../../../common/adminInterfaces';
|
||||
import { AdminModel } from '../../../services/adminModel';
|
||||
import { countryCodeToName } from '../../../../common/countries';
|
||||
|
||||
@Component({
|
||||
selector: 'origin-info',
|
||||
templateUrl: 'origin-info.pug',
|
||||
styleUrls: ['origin-info.scss'],
|
||||
})
|
||||
export class OriginInfo {
|
||||
@Input() origin?: Origin;
|
||||
constructor(private model: AdminModel) {
|
||||
}
|
||||
get countryName() {
|
||||
return countryCodeToName[this.origin && this.origin.country || '??'] || 'Unknown';
|
||||
}
|
||||
toggleBan(field: keyof BannedMuted, value: number) {
|
||||
if (this.origin) {
|
||||
this.model.updateOrigin({ ip: this.origin.ip, country: this.origin.country, [field]: value });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
div(*ngFor="let o of origins | slice:0:limit")
|
||||
origin-info-remote([originIP]="o.ip")
|
||||
a.text-muted(*ngIf="origins.length > limit" (click)="limit = 999999")
|
||||
small ({{origins.length - limit}} more)
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Component, Input, OnDestroy } from '@angular/core';
|
||||
import { Subscription } from '../../../../common/interfaces';
|
||||
import { AdminModel } from '../../../services/adminModel';
|
||||
import { OriginInfoBase } from '../../../../common/adminInterfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'origin-list-remote',
|
||||
templateUrl: 'origin-list-remote.pug',
|
||||
})
|
||||
export class OriginListRemote implements OnDestroy {
|
||||
@Input() limit = 2;
|
||||
@Input() extended = false;
|
||||
origins: OriginInfoBase[] = [];
|
||||
private _accountId?: string;
|
||||
private subscription?: Subscription;
|
||||
constructor(private model: AdminModel) {
|
||||
}
|
||||
get accountId() {
|
||||
return this._accountId;
|
||||
}
|
||||
@Input() set accountId(value: string | undefined) {
|
||||
if (this.accountId !== value) {
|
||||
this._accountId = value;
|
||||
this.origins = [];
|
||||
this.subscription && this.subscription.unsubscribe();
|
||||
this.subscription = value ? this.model.accountOrigins.subscribe(value, x => this.origins = x || []) : undefined;
|
||||
}
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.subscription && this.subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pony-info([pony]="pony" [highlight]="highlight")
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Component, Input, OnDestroy } from '@angular/core';
|
||||
import { Subscription } from '../../../../common/interfaces';
|
||||
import { AdminModel } from '../../../services/adminModel';
|
||||
import { Character } from '../../../../common/adminInterfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'pony-info-remote',
|
||||
templateUrl: 'pony-info-remote.pug',
|
||||
})
|
||||
export class PonyInfoRemote implements OnDestroy {
|
||||
@Input() highlight = false;
|
||||
@Input() showName = false;
|
||||
pony?: Character;
|
||||
private _ponyId?: string;
|
||||
private subscription?: Subscription;
|
||||
constructor(private model: AdminModel) {
|
||||
}
|
||||
get ponyId() {
|
||||
return this._ponyId;
|
||||
}
|
||||
@Input() set ponyId(value: string | undefined) {
|
||||
if (this.ponyId !== value) {
|
||||
this._ponyId = value;
|
||||
this.pony = undefined;
|
||||
this.subscription && this.subscription.unsubscribe();
|
||||
this.subscription = value ? this.model.ponies.subscribe(value, pony => this.pony = pony) : undefined;
|
||||
}
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.subscription && this.subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
ng-template(#tooltip)
|
||||
.character-tooltip(style="position: relative")
|
||||
character-preview([pony]="pony?.ponyInfo" [passive]="true")
|
||||
.character-tooltip-indicator(*ngIf="isBadCM") •
|
||||
.character-tooltip-date
|
||||
from-now([time]="pony?.lastUsed")
|
||||
span.ml-1(*ngIf="pony?.deleted") [deleted]
|
||||
|
||||
span.cursor-pointer.badge(
|
||||
[ngClass]="labelClass" [class.deleted]="pony?.deleted" [class.badge-highlight]="highlight" (click)="click()"
|
||||
[tooltip]="tooltip" (onShown)="onShown()" containerClass="tooltip-pony")
|
||||
| {{pony?.name || '...'}}
|
||||
@@ -0,0 +1,3 @@
|
||||
.badge-highlight {
|
||||
box-shadow: -3px 0 0 white;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Component, Input, OnChanges } from '@angular/core';
|
||||
import { hasFlag } from '../../../../common/utils';
|
||||
import { Character, CharacterFlags } from '../../../../common/adminInterfaces';
|
||||
import { isForbiddenName } from '../../../../common/security';
|
||||
import { AdminModel } from '../../../services/adminModel';
|
||||
|
||||
@Component({
|
||||
selector: 'pony-info',
|
||||
templateUrl: 'pony-info.pug',
|
||||
styleUrls: ['pony-info.scss'],
|
||||
})
|
||||
export class PonyInfo implements OnChanges {
|
||||
@Input() pony?: Character;
|
||||
@Input() highlight = false;
|
||||
labelClass = 'badge-none';
|
||||
private promise?: Promise<void>;
|
||||
constructor(private model: AdminModel) {
|
||||
}
|
||||
get isBadCM() {
|
||||
return !!this.pony && hasFlag(this.pony.flags, CharacterFlags.BadCM);
|
||||
}
|
||||
ngOnChanges() {
|
||||
if (this.pony) {
|
||||
if (isForbiddenName(this.pony.name)) {
|
||||
this.labelClass = 'badge-forbidden';
|
||||
} else if (hasFlag(this.pony.flags, CharacterFlags.BadCM)) {
|
||||
this.labelClass = 'badge-danger';
|
||||
} else {
|
||||
this.labelClass = 'badge-none';
|
||||
}
|
||||
}
|
||||
}
|
||||
onShown() {
|
||||
if (this.pony && !this.pony.ponyInfo && !this.promise) {
|
||||
this.promise = this.model.getPonyInfo(this.pony)
|
||||
.finally(() => this.promise = undefined);
|
||||
}
|
||||
}
|
||||
click() {
|
||||
console.log(this.pony);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
div([class.pony-list-expanded]="expanded" [class.pony-list-deletable]="deletable")
|
||||
span.text-muted(*ngIf="loading") ...
|
||||
span.pony-list-item.mr-1(*ngFor="let p of ponies | slice:0:limitTo")
|
||||
pony-info-remote(#info [ponyId]="p" [highlight]="highlight(info.pony)")
|
||||
a.ml-1.icon-button.cursor-pointer((click)="remove(p)" title="delete character")
|
||||
fa-icon.text-muted([icon]="trashIcon" [fixedWidth]="true")
|
||||
.btn-group.dropdown(dropdown *ngIf="duplicates")
|
||||
a.ml-1.icon-button.cursor-pointer(dropdownToggle title="Assign to another account")
|
||||
fa-icon.text-muted([icon]="assignIcon" [fixedWidth]="true")
|
||||
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
|
||||
button.dropdown-item(*ngFor="let d of duplicates" (click)="assignTo(p, d.account)")
|
||||
span.text-muted assign to
|
||||
account-info-remote.ml-1([accountId]="d.account" [basic]="true")
|
||||
a.text-muted.mr-1(*ngIf="!full && ponies.length > limit" (click)="toggleFull()")
|
||||
small ({{ponies.length - limit}} more)
|
||||
a.text-muted(*ngIf="full && ponies.length > limit" (click)="toggleFull()")
|
||||
small (less)
|
||||
@@ -0,0 +1,14 @@
|
||||
.pony-list-expanded {
|
||||
.pony-list-item {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
display: none;
|
||||
font-size: 12px;
|
||||
|
||||
.pony-list-deletable & {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Component, Input, OnDestroy } from '@angular/core';
|
||||
import { Subscription } from '../../../../common/interfaces';
|
||||
import { AdminModel } from '../../../services/adminModel';
|
||||
import { faTrash, faArrowRight } from '../../../../client/icons';
|
||||
import { Character, PonyIdDateName, DuplicateResult } from '../../../../common/adminInterfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'pony-list-remote',
|
||||
templateUrl: 'pony-list-remote.pug',
|
||||
styleUrls: ['pony-list-remote.scss'],
|
||||
})
|
||||
export class PonyListRemote implements OnDestroy {
|
||||
readonly trashIcon = faTrash;
|
||||
readonly assignIcon = faArrowRight;
|
||||
@Input() limit = 10;
|
||||
@Input() expanded = false;
|
||||
@Input() deletable = false;
|
||||
@Input() highlight: (pony: Character) => boolean = () => false;
|
||||
@Input() duplicates?: DuplicateResult[];
|
||||
full = false;
|
||||
ponies: string[] = [];
|
||||
loading = false;
|
||||
private ponyInfos: PonyIdDateName[] = [];
|
||||
private _accountId?: string;
|
||||
private subscription?: Subscription;
|
||||
constructor(private model: AdminModel) {
|
||||
}
|
||||
get limitTo() {
|
||||
return this.full ? 999999 : this.limit;
|
||||
}
|
||||
get accountId() {
|
||||
return this._accountId;
|
||||
}
|
||||
@Input() set accountId(value: string | undefined) {
|
||||
if (this.accountId !== value) {
|
||||
this._accountId = value;
|
||||
this.ponies = [];
|
||||
this.ponyInfos = [];
|
||||
this.loading = true;
|
||||
this.subscription && this.subscription.unsubscribe();
|
||||
this.subscription = value ? this.model.accountPonies.subscribe(value, (x = []) => {
|
||||
this.ponyInfos = x;
|
||||
this.updatePonies();
|
||||
this.loading = false;
|
||||
}) : undefined;
|
||||
}
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.subscription && this.subscription.unsubscribe();
|
||||
}
|
||||
remove(characterId: string) {
|
||||
if (confirm('Are you sure?')) {
|
||||
this.model.removePony(characterId);
|
||||
}
|
||||
}
|
||||
toggleFull() {
|
||||
this.full = !this.full;
|
||||
this.updatePonies();
|
||||
}
|
||||
assignTo(pony: string, account: string) {
|
||||
this.model.assignPony(pony, account);
|
||||
}
|
||||
private updatePonies() {
|
||||
const compare = this.full ? compareNames : compareDates;
|
||||
this.ponies = this.ponyInfos.sort(compare).map(p => p.id);
|
||||
}
|
||||
}
|
||||
|
||||
export function compareNames(a: { name: string }, b: { name: string }) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
export function compareDates(a: { date: number }, b: { date: number }) {
|
||||
return b.date - a.date;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
ng-template(#tooltip)
|
||||
div {{time | date:'mediumDate'}}
|
||||
.text-muted {{time | date:'mediumTime'}}
|
||||
|
||||
from-now([time]="time" [tooltip]="tooltip" [isDisabled]="!time")
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'time-field',
|
||||
templateUrl: 'time-field.pug',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TimeField {
|
||||
@Input() time: any;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
div(*ngIf="userAgent" [tooltip]="userAgent")
|
||||
fa-icon([icon]="browserClass" [fixedWidth]="true")
|
||||
b.ml-1 {{browserVersion}}
|
||||
fa-icon.ml-1([icon]="osClass" [fixedWidth]="true")
|
||||
b.ml-1 {{osVersion}}
|
||||
fa-icon.ml-1([icon]="deviceClass" [fixedWidth]="true")
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { uaIcons, faQuestionCircle, faGlobe, faDesktop } from '../../../../client/icons';
|
||||
|
||||
function icon(value: string | undefined, defaultValue: any): any {
|
||||
return value && uaIcons[value] || defaultValue;
|
||||
}
|
||||
|
||||
const extensions = {
|
||||
browser: [
|
||||
[/(Amigo|YaBrowser)\/([\w\.]+)/i], [UAParser.BROWSER.NAME, UAParser.BROWSER.VERSION]
|
||||
],
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'ua-info',
|
||||
templateUrl: 'ua-info.pug',
|
||||
styles: [`:host { display: inline-block; }`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class UAInfo {
|
||||
osClass?: string;
|
||||
osVersion?: string;
|
||||
browserClass?: string;
|
||||
browserVersion?: string;
|
||||
deviceClass?: string;
|
||||
private _userAgent?: string;
|
||||
@Input() set userAgent(value: string | undefined) {
|
||||
if (this._userAgent !== value) {
|
||||
this._userAgent = value;
|
||||
|
||||
const parser = new UAParser(value, extensions);
|
||||
const { os, browser, device } = parser.getResult();
|
||||
|
||||
this.osVersion = os.version;
|
||||
this.browserVersion = (browser.version || '').replace(/\..*$/, '');
|
||||
this.osClass = icon(os.name, faQuestionCircle);
|
||||
this.browserClass = icon(browser.name, faGlobe);
|
||||
this.deviceClass = icon(device.type, faDesktop);
|
||||
}
|
||||
}
|
||||
get userAgent() {
|
||||
return this._userAgent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
h1(focusTitle) About
|
||||
|
||||
.row.text-large
|
||||
.col-lg-7
|
||||
p.lead.
|
||||
A game of ponies building a town
|
||||
|
||||
h2 Contact
|
||||
p For all inquiries please send an email to #[b {{contactEmail}}]
|
||||
|
||||
//- div
|
||||
hr
|
||||
|
||||
h2.text-success Custom servers
|
||||
|
||||
p.
|
||||
You can #[a.text-success(href="...") download the files] and start your own custom {{title}} server.
|
||||
p.
|
||||
The package contains server files and instruction on steps required to start your own server.
|
||||
It also allows you to customize and modify the game to your liking.
|
||||
p.
|
||||
Feel free to use this to host a server for your friends, fandom, language group or just a general
|
||||
server for everyone.
|
||||
|
||||
a.d-block.mx-auto.my-4.btn.btn-lg.btn-outline-success.px-3(href="..." style="max-width: 400px;")
|
||||
| Download game files
|
||||
|
||||
p.
|
||||
All necessary instructions required to build and run the server are included in README.md file
|
||||
included in the package.
|
||||
p.
|
||||
Running the server requires basic knowledge of configuring and hosting web applications,
|
||||
it's not intended for beginner users.
|
||||
|
||||
hr
|
||||
|
||||
div(*ngIf="patreonLink")
|
||||
h2 Support {{title}}
|
||||
|
||||
p.
|
||||
If you'd like to help keep {{title}} running, and support future development of the project,
|
||||
please consider #[a([href]="patreonLink" target="_blank" rel="noopener noreferrer") supporting us on Patreon].
|
||||
|
||||
support-button.d-block.mx-auto.my-4(style="max-width: 400px;")
|
||||
|
||||
p.
|
||||
Patreon supporter rewards include the following:
|
||||
ul
|
||||
li(*ngFor="let r of rewards")
|
||||
| {{r}}
|
||||
p.
|
||||
Note that in order to receive any patreon rewards you need to connect your Patreon to your {{title}} account.
|
||||
|
||||
h2 Technology
|
||||
|
||||
p.
|
||||
The entire game is written in #[a(href="http://www.typescriptlang.org/") TypeScript],
|
||||
a typed superset of JavaScript that compiles to plain JavaScript.
|
||||
Server side code is running on #[a(href="https://nodejs.org/en/") Node.js]
|
||||
server with WebSockets for communication.
|
||||
User interface is built using #[a(href="https://angular.io/") Angular]
|
||||
framework and the game itself is using WebGL for rendering graphics.
|
||||
|
||||
div(*ngIf="credits.length")
|
||||
h2 The Team
|
||||
|
||||
.mb-4
|
||||
.row.team-members
|
||||
.col-md-6(*ngFor="let c of credits")
|
||||
.team-member
|
||||
.team-avatar.float-left(
|
||||
[style.background-image]="c.background"
|
||||
[style.background-position]="c.position"
|
||||
style="background-size: 328px 246px")
|
||||
b.text-truncate.sno {{c.name}}
|
||||
.text-muted {{c.title}}
|
||||
site-links([links]="c.links")
|
||||
|
||||
//- small.text-muted
|
||||
em avatars by #[a.text-muted(href="link") Name]
|
||||
|
||||
h2 Contributors
|
||||
|
||||
div(*ngFor="let g of contributors")
|
||||
h3 {{g.group}}
|
||||
p(*ngFor="let c of g.contributors")
|
||||
strong {{c.name}}
|
||||
span(*ngIf="c.links")
|
||||
| -
|
||||
site-links([links]="c.links")
|
||||
|
||||
.col-lg-5
|
||||
|
||||
h2 Changelog
|
||||
|
||||
div(*ngFor="let v of changelog")
|
||||
h4 {{v.version}}
|
||||
ul
|
||||
li(*ngFor="let c of v.changes" [innerHTML]="c")
|
||||
|
||||
.text-center.text-muted
|
||||
.tinyface
|
||||
@@ -0,0 +1,33 @@
|
||||
.team-members {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.team-member {
|
||||
b {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.team-avatar {
|
||||
background: white;
|
||||
position: relative;
|
||||
margin: 0 15px 20px 0;
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border-radius: 50%;
|
||||
border: solid 4px white;
|
||||
transform: scale(0.7);
|
||||
margin-right: 5px;
|
||||
margin-bottom: 10px;
|
||||
margin-top: -10px;
|
||||
|
||||
> img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { emojis } from '../../../client/emoji';
|
||||
import { getUrl } from '../../../client/rev';
|
||||
import { CREDITS, CONTRIBUTORS, Credit } from '../../../client/credits';
|
||||
import { CHANGELOG } from '../../../generated/changelog';
|
||||
import { SUPPORTER_REWARDS_LIST } from '../../../common/constants';
|
||||
import { supporterLink, contactEmail } from '../../../client/data';
|
||||
|
||||
function toCredit(credit: Credit) {
|
||||
return {
|
||||
...credit,
|
||||
background: `url(${getUrl('images/avatars.jpg')})`,
|
||||
position: `${(credit.avatarIndex % 4) * -82}px ${Math.floor(credit.avatarIndex / 4) * -82}px`,
|
||||
};
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'about',
|
||||
templateUrl: 'about.pug',
|
||||
styleUrls: ['about.scss'],
|
||||
})
|
||||
export class About {
|
||||
readonly title = document.title;
|
||||
readonly emotes = emojis;
|
||||
readonly credits = CREDITS.map(toCredit);
|
||||
readonly contributors = CONTRIBUTORS;
|
||||
readonly changelog = CHANGELOG;
|
||||
readonly rewards = SUPPORTER_REWARDS_LIST;
|
||||
readonly patreonLink = supporterLink;
|
||||
readonly contactEmail = contactEmail;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
h1(focusTitle) Account settings
|
||||
|
||||
.row.text-large
|
||||
.col-md-6
|
||||
form(name="form" (submit)="submit()" style="max-width: 400px; margin-bottom: 100px")
|
||||
.form-group
|
||||
h3 Account details
|
||||
.form-group
|
||||
label(for="account-id") Account ID
|
||||
input#account-id.form-control([value]="account?.id" readonly)
|
||||
.form-group
|
||||
label(for="account-name") Account Name
|
||||
input#account-name.form-control(
|
||||
type="text" name="name" [(ngModel)]="data.name" required [maxlength]="nameMaxLength")
|
||||
small.form-text.text-warning This name will be visible to other players
|
||||
.form-group
|
||||
label(for="birthdate")
|
||||
| Date of birth
|
||||
fa-icon.ml-1.text-danger(*ngIf="showAccountAlert" [icon]="alertIcon" title="Missing field value")
|
||||
date-picker([(date)]="data.birthdate")
|
||||
.form-group(*ngIf="accountError")
|
||||
.alert.alert-danger
|
||||
| {{accountError}}
|
||||
.form-group(*ngIf="accountSaved")
|
||||
.alert.alert-success
|
||||
| Changes saved
|
||||
.form-group.text-right
|
||||
button.btn.btn-default(type="submit" [disabled]="!canSubmit" style="min-width: 100px;")
|
||||
| Save
|
||||
|
||||
div(style="margin-bottom: 100px;")
|
||||
.form-group
|
||||
h3 Connected social accounts
|
||||
|
||||
.form-group
|
||||
p.text-muted.
|
||||
If you remove social account, you won't be able to use it to sign-in to the game anymore.
|
||||
|
||||
.form-group
|
||||
ul.list-unstyled
|
||||
li.item-connected-account.d-flex.justify-content-between(*ngFor="let s of sites")
|
||||
a([href]="s.url" target="_blank" rel="noopener noreferrer")
|
||||
fa-icon.mr-1([icon]="icon(s.icon)" [fixedWidth]="true")
|
||||
b #[span.sr-only {{s.icon}}] {{s.name}}
|
||||
button.btn.btn-sm.btn-danger(*ngIf="sites.length > 1" (click)="removeSite(s)" [disabled]="removingSite")
|
||||
| remove
|
||||
|
||||
.form-group
|
||||
p.text-danger.
|
||||
Please do NOT connect any social sites that #[b do not belong to you!]
|
||||
Connecting social sites belonging to other people may result in unexpected account merge
|
||||
and loss of one or both accounts.
|
||||
p.text-danger.
|
||||
Please contact our support email if you accidentally merge your account with someone else.
|
||||
.dropdown(dropdown)
|
||||
button.btn.btn-default.dropdown-toggle(dropdownToggle)
|
||||
| connect another social site to this account
|
||||
.dropdown-menu(*dropdownMenu)
|
||||
button.dropdown-item(*ngFor="let p of providers" (click)="connectSite(p)")
|
||||
fa-icon.mr-1([icon]="icon(p.id)" [fixedWidth]="true")
|
||||
| #[span.sr-only {{p.name}}] {{p.name}}
|
||||
|
||||
.form-group(*ngIf="mergeError")
|
||||
.alert.alert-danger
|
||||
| {{mergeError}}
|
||||
|
||||
.form-group(*ngIf="authError")
|
||||
.alert.alert-danger
|
||||
| {{authError}}
|
||||
|
||||
.form-group(*ngIf="mergedAccount")
|
||||
.alert.alert-success
|
||||
| Account connected successfully
|
||||
|
||||
.form-group(*ngIf="removedAccount")
|
||||
.alert.alert-success
|
||||
| Account removed successfully
|
||||
|
||||
.col-md-6
|
||||
div(style="margin-bottom: 100px;")
|
||||
.clearfix(*ngIf="showSupporter")
|
||||
.form-group
|
||||
h3
|
||||
fa-icon.mr-2([icon]="starIcon" [ngClass]="supporterClass")
|
||||
| Thank you for your support
|
||||
|
||||
supporter-pony.float-right(#pony (mouseenter)="pony.excite()" (mouseleave)="pony.reset()")
|
||||
|
||||
.form-group
|
||||
p Your reward tier: #[b {{supporterTitle}}]
|
||||
p Your rewards include:
|
||||
ul
|
||||
li(*ngFor="let r of supporterRewards")
|
||||
| {{r}}
|
||||
|
||||
p(*ngIf="supporter > 0")
|
||||
| Use <code>/ss hello</code> command to use supporter chat color.
|
||||
|
||||
p(*ngIf="supporter > 1")
|
||||
| Use <code>/s1 hello</code> and <code>/s2 hello</code> commands to use lower tier supporter chat colors.
|
||||
|
||||
.clearfix(*ngIf="showSupporterInfo")
|
||||
.form-group
|
||||
h3 Supporter info
|
||||
|
||||
supporter-pony.float-right(#pony (mouseenter)="pony.excite()" (mouseleave)="pony.reset()")
|
||||
|
||||
.form-group.text-muted
|
||||
p.
|
||||
You've successfully connected your Patreon account to Pony Town.
|
||||
If you support Pony Town on Patreon your supporter info will show up here.
|
||||
p.
|
||||
It should take no longer than 10 minutes to register your Patreon pledges.
|
||||
|
||||
div(style="margin-bottom: 100px;")
|
||||
.form-group
|
||||
.float-right
|
||||
span.text-muted Page: {{page + 1}}
|
||||
button.btn.btn-sm.btn-outline-secondary.ml-2((click)="page = page - 1; pageChanged()" [disabled]="!hides || page === 0")
|
||||
| Previous page
|
||||
button.btn.btn-sm.btn-outline-secondary.ml-1((click)="page = page + 1; pageChanged()" [disabled]="!hides || hides.length < hidesPerPage")
|
||||
| Next page
|
||||
h3 Hidden players
|
||||
|
||||
div(*ngIf="!hides")
|
||||
.text-muted.text-center.p-4 loading...
|
||||
|
||||
ul.list-unstyled(*ngIf="hides")
|
||||
.text-muted.text-center.p-4(*ngIf="!hides.length") no hidden players
|
||||
li.item-connected-account.d-flex.align-items-center(*ngFor="let h of hides")
|
||||
b.flex-grow-1 {{h.name}}
|
||||
span.text-muted.mr-2 hidden {{h.date}}
|
||||
button.btn.btn-sm.btn-danger((click)="unhidePlayer(h)")
|
||||
| unhide player
|
||||
@@ -0,0 +1,17 @@
|
||||
.item-connected-account {
|
||||
padding: 5px;
|
||||
min-height: 40px;
|
||||
|
||||
> a {
|
||||
position: relative;
|
||||
top: 6px;
|
||||
|
||||
> i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { ACCOUNT_NAME_MAX_LENGTH, ACCOUNT_NAME_MIN_LENGTH, HIDES_PER_PAGE } from '../../../common/constants';
|
||||
import { UpdateAccountData, SocialSiteInfo, OAuthProvider, HiddenPlayer } from '../../../common/interfaces';
|
||||
import {
|
||||
toSocialSiteInfo, cleanName, supporterTitle, supporterClass, isSupporterOrPastSupporter, supporterRewards
|
||||
} from '../../../client/clientUtils';
|
||||
import { oauthProviders } from '../../../client/data';
|
||||
import { Model } from '../../services/model';
|
||||
import { getProviderIcon } from '../../shared/sign-in-box/sign-in-box';
|
||||
import { faStar, faExclamationCircle, faSync } from '../../../client/icons';
|
||||
|
||||
@Component({
|
||||
selector: 'account',
|
||||
templateUrl: 'account.pug',
|
||||
styleUrls: ['account.scss'],
|
||||
})
|
||||
export class Account implements OnInit, OnDestroy {
|
||||
readonly refreshIcon = faSync;
|
||||
readonly starIcon = faStar;
|
||||
readonly alertIcon = faExclamationCircle;
|
||||
readonly providers = oauthProviders.filter(p => !p.disabled);
|
||||
readonly nameMinLength = ACCOUNT_NAME_MIN_LENGTH;
|
||||
readonly nameMaxLength = ACCOUNT_NAME_MAX_LENGTH;
|
||||
readonly hidesPerPage = HIDES_PER_PAGE;
|
||||
data: UpdateAccountData = {
|
||||
name: '',
|
||||
birthdate: '',
|
||||
};
|
||||
sites?: SocialSiteInfo[];
|
||||
password?: string;
|
||||
removingSite?: boolean;
|
||||
mergeError?: string;
|
||||
removedAccount?: boolean;
|
||||
accountError?: string;
|
||||
accountSaved = false;
|
||||
hides: HiddenPlayer[] | undefined = undefined;
|
||||
page = 0;
|
||||
constructor(private model: Model) {
|
||||
}
|
||||
ngOnInit() {
|
||||
const account = this.account!;
|
||||
this.sites = account.sites && account.sites.map(toSocialSiteInfo);
|
||||
this.data = {
|
||||
name: account.name,
|
||||
birthdate: account.birthdate,
|
||||
};
|
||||
|
||||
this.pageChanged();
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.model.mergedAccount = false;
|
||||
}
|
||||
pageChanged() {
|
||||
this.model.getHides(this.page)
|
||||
.then(result => this.hides = result);
|
||||
}
|
||||
get authError() {
|
||||
return this.model.authError;
|
||||
}
|
||||
get mergedAccount() {
|
||||
return this.model.mergedAccount;
|
||||
}
|
||||
get account() {
|
||||
return this.model.account;
|
||||
}
|
||||
get supporter() {
|
||||
return this.model.supporter;
|
||||
}
|
||||
get showSupporter() {
|
||||
return isSupporterOrPastSupporter(this.account);
|
||||
}
|
||||
get canSubmit() {
|
||||
return this.account && this.data.name && !!cleanName(this.data.name).length;
|
||||
}
|
||||
get supporterTitle() {
|
||||
return supporterTitle(this.account);
|
||||
}
|
||||
get supporterClass() {
|
||||
return supporterClass(this.account);
|
||||
}
|
||||
get supporterRewards() {
|
||||
return supporterRewards(this.account);
|
||||
}
|
||||
get showSupporterInfo() {
|
||||
const account = this.account;
|
||||
return !!(!this.supporter && account && account.sites && account.sites.some(s => s.provider === 'patreon'));
|
||||
}
|
||||
get showAccountAlert() {
|
||||
return this.model.missingBirthdate;
|
||||
}
|
||||
icon(id: string) {
|
||||
return getProviderIcon(id);
|
||||
}
|
||||
submit() {
|
||||
if (this.canSubmit) {
|
||||
this.resetAllMessages();
|
||||
this.data.name = cleanName(this.data.name).substr(0, ACCOUNT_NAME_MAX_LENGTH);
|
||||
this.model.updateAccount(this.data)
|
||||
.catch((e: Error) => this.accountError = e.message)
|
||||
.then(() => this.accountSaved = true);
|
||||
}
|
||||
}
|
||||
removeSite(site: SocialSiteInfo) {
|
||||
if (confirm('Are you sure you want to remove this social account ?')) {
|
||||
this.removingSite = true;
|
||||
this.resetAllMessages();
|
||||
this.model.removeSite(site.id)
|
||||
.then(() => this.sites = this.account!.sites!.map(toSocialSiteInfo))
|
||||
.then(() => this.removedAccount = true)
|
||||
.catch((e: Error) => this.mergeError = e.message)
|
||||
.then(() => this.removingSite = false);
|
||||
}
|
||||
}
|
||||
connectSite(provider: OAuthProvider) {
|
||||
this.model.connectSite(provider);
|
||||
}
|
||||
private resetAllMessages() {
|
||||
this.accountSaved = false;
|
||||
this.mergeError = undefined;
|
||||
this.accountError = undefined;
|
||||
this.removedAccount = false;
|
||||
this.model.authError = undefined;
|
||||
this.model.mergedAccount = false;
|
||||
}
|
||||
unhidePlayer(player: HiddenPlayer) {
|
||||
this.model.unhidePlayer(player.id)
|
||||
.then(() => this.pageChanged())
|
||||
.catch((e: Error) => console.error(e));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { NgModule, ErrorHandler } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||
import { PopoverModule } from 'ngx-bootstrap/popover';
|
||||
import { ButtonsModule } from 'ngx-bootstrap/buttons';
|
||||
import { TooltipModule } from 'ngx-bootstrap/tooltip';
|
||||
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
|
||||
import { App } from './app';
|
||||
import { Home } from './home/home';
|
||||
import { Help } from './help/help';
|
||||
import { About } from './about/about';
|
||||
import { Account } from './account/account';
|
||||
import { Character } from './character/character';
|
||||
import { EditorBox } from './editor-box/editor-box';
|
||||
|
||||
import { AuthGuard } from '../services/authGuard';
|
||||
import { RollbarService, rollbarFactory, RollbarErrorHandler } from '../services/rollbarErrorHandler';
|
||||
import { ErrorReporter } from '../services/errorReporter';
|
||||
import { RollbarErrorReporter } from '../services/rollbarErrorReporter';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', component: Home },
|
||||
{ path: 'help', component: Help },
|
||||
{ path: 'about', component: About },
|
||||
{ path: 'account', component: Account, canActivate: [AuthGuard] },
|
||||
{ path: 'character', component: Character, canActivate: [AuthGuard] },
|
||||
{ path: '**', redirectTo: '/', pathMatch: 'full' },
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
BrowserModule,
|
||||
RouterModule,
|
||||
FormsModule,
|
||||
HttpClientModule,
|
||||
PopoverModule.forRoot(),
|
||||
ButtonsModule.forRoot(),
|
||||
TooltipModule.forRoot(),
|
||||
// TypeaheadModule.forRoot(),
|
||||
SharedModule,
|
||||
RouterModule.forRoot(routes),
|
||||
FontAwesomeModule,
|
||||
],
|
||||
declarations: [
|
||||
App,
|
||||
Home,
|
||||
Help,
|
||||
About,
|
||||
Account,
|
||||
Character,
|
||||
EditorBox,
|
||||
],
|
||||
providers: [
|
||||
{ provide: RollbarService, useFactory: rollbarFactory },
|
||||
{ provide: ErrorHandler, useClass: RollbarErrorHandler },
|
||||
{ provide: ErrorReporter, useClass: RollbarErrorReporter },
|
||||
],
|
||||
bootstrap: [App],
|
||||
})
|
||||
export class AppModule {
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
#app-game.app-game([hidden]="!playing")
|
||||
canvas#canvas.unselectable.pixelart
|
||||
span#stats.d-none.d-sm-inline
|
||||
pony-box#pony-box(*ngIf="selected" [pony]="selected" (sendMessage)="messageToPony(chatBox, $event)")
|
||||
editor-box#editor(*hasFeature="'editor'; also playing && isMod")
|
||||
party-list#party-list
|
||||
settings-box#settings-box
|
||||
swap-box#swap-box
|
||||
notification-list#notifications([notifications]="notifications" [notificationsLength]="notifications.length")
|
||||
chat-log#chat-log(*ngIf="playing" (toggleType)="chatBox.setChatType($event)" (nameClick)="chatLogNameClick(chatBox, $event)")
|
||||
action-bar#action-bar(*ngIf="showActionBar" [blurred]="chatBox.isOpen" [editable]="editingActions")
|
||||
friends-box#friends-box((sendMessage)="messageToFriend(chatBox, $event)")
|
||||
chat-box#chat-box(#chatBox [disabled]="editingActions")
|
||||
#game-announcer(#announcer style="display: none")
|
||||
div(#announcerText aria-live="assertive")
|
||||
#touch-origin
|
||||
#touch-position
|
||||
#range-indicator
|
||||
div
|
||||
dropdown-outlet
|
||||
|
||||
.container([hidden]="playing")
|
||||
menu-bar(
|
||||
*ngIf="!playing" [logo]="true" [account]="account" [loading]="loading" (signIn)="signIn($event)" (signOut)="signOut()")
|
||||
menu-item(route="/" name="Home" [icon]="homeIcon")
|
||||
menu-item(route="/help" name="Help" [icon]="helpIcon")
|
||||
menu-item(route="/about" name="About" [icon]="aboutIcon")
|
||||
menu-item(route="/character" name="Characters" [icon]="charactersIcon" *ngIf="account")
|
||||
|
||||
main
|
||||
router-outlet
|
||||
|
||||
footer.text-muted.text-nowrap(*ngIf="!playing")
|
||||
.app-version
|
||||
| version #[b {{version}}]
|
||||
|
||||
.text-right
|
||||
.d-flex.flex-wrap
|
||||
//- .text-nowrap.mr-2
|
||||
| © 2016-{{date | date:'yyyy'}}
|
||||
| #[a.text-muted.mr-1([href]="twitterLink" target="_blank") {{copyright}}] |
|
||||
.text-nowrap
|
||||
a.text-muted.mr-2(*ngIf="twitterLink" [href]="twitterLink" target="_blank" title="Twitter")
|
||||
fa-icon([icon]="twitterIcon" [fixedWidth]="true")
|
||||
a.text-muted.mr-2(*ngIf="patreonLink" [href]="patreonLink" target="_blank" title="Patreon")
|
||||
fa-icon([icon]="patreonIcon" [fixedWidth]="true")
|
||||
a.text-muted.mr-2(*ngIf="contactEmail" href="mailto:{{contactEmail}}" target="_blank" title="Email")
|
||||
fa-icon([icon]="emailIcon" [fixedWidth]="true")
|
||||
div
|
||||
a.text-muted.privacy-policy(href="/privacypolicy.html" target="_blank" rel="noopener") privacy policy
|
||||
span.mx-2 |
|
||||
a.text-muted(href="/termsofservice.html" target="_blank" rel="noopener") terms of service
|
||||
|
||||
draggable-outlet
|
||||
|
||||
ng-template(#reloadModal)
|
||||
.modal-body
|
||||
iframe.modal-frame#reload-frame(src="/reload.html" frameBorder="0")
|
||||
.modal-footer
|
||||
button.btn.btn-outline-secondary((click)="cancelReloadModal()")
|
||||
| Cancel
|
||||
@@ -0,0 +1,204 @@
|
||||
@import '../../../styles/partials/variables';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
main {
|
||||
padding-top: 50px;
|
||||
padding-bottom: 100px;
|
||||
flex-grow: 1;
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
padding-top: 20px;
|
||||
padding-bottom: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 20px;
|
||||
|
||||
@include media-breakpoint-down(xs) {
|
||||
white-space: normal !important;
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
|
||||
.app-version {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
padding: 10px 10px;
|
||||
|
||||
&.privacy-policy {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-game {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#canvas {
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: fixed;
|
||||
touch-action: none;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#pony-box {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
$chat-pad: 5px;
|
||||
|
||||
#chat-box {
|
||||
position: fixed;
|
||||
left: $chat-pad;
|
||||
bottom: $chat-pad;
|
||||
right: $chat-pad;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
#chat-log {
|
||||
position: fixed;
|
||||
left: $chat-pad;
|
||||
bottom: $chat-pad + 42px;
|
||||
right: $chat-pad;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
#action-bar {
|
||||
position: fixed;
|
||||
left: $chat-pad + 45px;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
#party-list {
|
||||
position: fixed;
|
||||
top: 105px;
|
||||
left: 10px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
#editor {
|
||||
position: fixed;
|
||||
left: 5px;
|
||||
top: 5px;
|
||||
}
|
||||
|
||||
#settings-box {
|
||||
position: fixed;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
#friends-box {
|
||||
position: fixed;
|
||||
top: 5px;
|
||||
right: 45px;
|
||||
}
|
||||
|
||||
#swap-box {
|
||||
position: fixed;
|
||||
top: 5px;
|
||||
right: 86px;
|
||||
}
|
||||
|
||||
#notifications {
|
||||
position: fixed;
|
||||
top: 45px;
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
#stats {
|
||||
position: fixed;
|
||||
right: 5px;
|
||||
bottom: 0px;
|
||||
color: #222;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#touch-origin {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100px;
|
||||
width: 100px;
|
||||
display: none;
|
||||
border: 4px solid white;
|
||||
border-radius: 50%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
#touch-position {
|
||||
background: white;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 50px;
|
||||
width: 50px;
|
||||
display: none;
|
||||
border-radius: 50%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
#range-indicator {
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
border: solid 5px white;
|
||||
border-radius: 50%;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#game-announcer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
|
||||
> div {
|
||||
font-size: 16px;
|
||||
padding: 10px 15px;
|
||||
background: #222;
|
||||
color: $body-color;
|
||||
max-width: 400px;
|
||||
border-radius: 10px;
|
||||
margin-top: 100px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Component, OnInit, OnDestroy, HostListener, ViewChild, ElementRef, TemplateRef } from '@angular/core';
|
||||
import { Router, NavigationEnd, ActivatedRoute } from '@angular/router';
|
||||
import { TooltipConfig } from 'ngx-bootstrap/tooltip';
|
||||
import { PopoverConfig } from 'ngx-bootstrap/popover';
|
||||
import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { GameService } from '../services/gameService';
|
||||
import { Model, Friend } from '../services/model';
|
||||
import { version, host, contactEmail, supporterLink, twitterLink, copyrightName } from '../../client/data';
|
||||
import { PonyTownGame } from '../../client/game';
|
||||
import { faTwitter, faPatreon, faEnvelope, faCog, faHome, faGamepad, faInfoCircle, faHorseHead } from '../../client/icons';
|
||||
import { InstallService } from '../services/installService';
|
||||
import { OAuthProvider, Entity, FakeEntity, Pony } from '../../common/interfaces';
|
||||
import { registerServiceWorker, isBrowserOutdated, checkIframeKey } from '../../client/clientUtils';
|
||||
import { ErrorReporter } from '../services/errorReporter';
|
||||
import { SECOND, PONY_TYPE } from '../../common/constants';
|
||||
import { ChatBox } from '../shared/chat-box/chat-box';
|
||||
import { ChatLogMessage } from '../shared/chat-log/chat-log';
|
||||
import { isPony } from '../../common/pony';
|
||||
import { findEntityById } from '../../common/worldMap';
|
||||
import { isSelected } from '../../client/gameUtils';
|
||||
|
||||
export function tooltipConfig() {
|
||||
return Object.assign(new TooltipConfig(), { container: 'body' });
|
||||
}
|
||||
|
||||
export function popoverConfig() {
|
||||
return Object.assign(new PopoverConfig(), { container: 'body' });
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pony-town-app',
|
||||
templateUrl: 'app.pug',
|
||||
styleUrls: ['app.scss'],
|
||||
providers: [
|
||||
{ provide: TooltipConfig, useFactory: tooltipConfig },
|
||||
{ provide: PopoverConfig, useFactory: popoverConfig },
|
||||
]
|
||||
})
|
||||
export class App implements OnInit, OnDestroy {
|
||||
@ViewChild('announcer', { static: true }) announcer!: ElementRef;
|
||||
@ViewChild('announcerText', { static: true }) announcerText!: ElementRef;
|
||||
@ViewChild('reloadModal', { static: true }) reloadModal!: TemplateRef<any>;
|
||||
@ViewChild('signInModal', { static: true }) signInModal!: TemplateRef<any>;
|
||||
readonly version = version;
|
||||
readonly date = new Date();
|
||||
readonly emailIcon = faEnvelope;
|
||||
readonly twitterIcon = faTwitter;
|
||||
readonly patreonIcon = faPatreon;
|
||||
readonly cogIcon = faCog;
|
||||
readonly homeIcon = faHome;
|
||||
readonly helpIcon = faGamepad;
|
||||
readonly aboutIcon = faInfoCircle;
|
||||
readonly charactersIcon = faHorseHead;
|
||||
readonly contactEmail = contactEmail;
|
||||
readonly patreonLink = supporterLink;
|
||||
readonly twitterLink = twitterLink;
|
||||
readonly copyright = copyrightName;
|
||||
private url = location.pathname;
|
||||
private reloadModalRef?: BsModalRef;
|
||||
private reloadInterval?: any;
|
||||
private subscriptions: Subscription[] = [];
|
||||
constructor(
|
||||
private modalService: BsModalService,
|
||||
private gameService: GameService,
|
||||
private model: Model,
|
||||
private game: PonyTownGame,
|
||||
private router: Router,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private installService: InstallService,
|
||||
private errorReporter: ErrorReporter,
|
||||
) {
|
||||
}
|
||||
get canInstall() {
|
||||
return this.installService.canInstall;
|
||||
}
|
||||
get loading() {
|
||||
return this.model.loading;
|
||||
}
|
||||
get account() {
|
||||
return this.model.account;
|
||||
}
|
||||
get isMod() {
|
||||
return this.model.isMod;
|
||||
}
|
||||
get notifications() {
|
||||
return this.game.notifications;
|
||||
}
|
||||
get selected() {
|
||||
return this.gameService.selected;
|
||||
}
|
||||
get playing() {
|
||||
return this.gameService.playing;
|
||||
}
|
||||
get showActionBar() {
|
||||
return this.playing;
|
||||
}
|
||||
get editingActions() {
|
||||
return this.game.editingActions;
|
||||
}
|
||||
ngOnInit() {
|
||||
if (typeof ga !== 'undefined') {
|
||||
this.subscriptions.push(this.router.events.subscribe(event => {
|
||||
if (event instanceof NavigationEnd && this.url !== event.url) {
|
||||
ga('set', 'page', this.url = event.url);
|
||||
ga('send', 'pageview');
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if (isBrowserOutdated) {
|
||||
this.errorReporter.disable();
|
||||
}
|
||||
|
||||
if (!DEVELOPMENT) {
|
||||
registerServiceWorker(`${host}sw.js`, () => {
|
||||
this.model.updating = true;
|
||||
setTimeout(() => {
|
||||
this.model.updatingTakesLongTime = true;
|
||||
}, 20 * SECOND);
|
||||
});
|
||||
}
|
||||
|
||||
if (DEVELOPMENT) {
|
||||
this.subscriptions.push(this.game.announcements.subscribe(message => {
|
||||
(this.announcer.nativeElement as HTMLElement).style.display = 'flex';
|
||||
const announcerText = this.announcerText.nativeElement as HTMLElement;
|
||||
announcerText.textContent = '';
|
||||
setTimeout(() => announcerText.textContent = message, 100);
|
||||
}));
|
||||
}
|
||||
|
||||
this.activatedRoute.queryParams.subscribe(({ error, merged, alert }) => {
|
||||
this.model.authError = error;
|
||||
this.model.accountAlert = alert;
|
||||
this.model.mergedAccount = !!merged;
|
||||
});
|
||||
|
||||
this.subscriptions.push(this.model.protectionErrors.subscribe(() => {
|
||||
this.openReloadModal();
|
||||
}));
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.subscriptions.forEach(s => s.unsubscribe());
|
||||
}
|
||||
@HostListener('window:focus')
|
||||
focus() {
|
||||
this.model.verifyAccount();
|
||||
}
|
||||
signIn(provider: OAuthProvider) {
|
||||
this.model.signIn(provider);
|
||||
}
|
||||
signOut() {
|
||||
this.model.signOut();
|
||||
}
|
||||
openReloadModal() {
|
||||
if (!this.reloadModalRef) {
|
||||
this.reloadModalRef = this.modalService.show(
|
||||
this.reloadModal, { class: 'modal-lg', ignoreBackdropClick: true, keyboard: false });
|
||||
|
||||
this.reloadInterval = setInterval(() => {
|
||||
if (checkIframeKey('reload-frame', 'gep84r9jshge4g')) {
|
||||
this.cancelReloadModal();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
cancelReloadModal() {
|
||||
if (this.reloadModalRef) {
|
||||
this.reloadModalRef.hide();
|
||||
this.reloadModalRef = undefined;
|
||||
}
|
||||
|
||||
clearInterval(this.reloadInterval);
|
||||
}
|
||||
chatLogNameClick(chatBox: ChatBox, message: ChatLogMessage) {
|
||||
if (!message.entityId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let entity = findEntityById(this.game.map, message.entityId);
|
||||
|
||||
if (entity && (!isPony(entity) || entity === this.game.player)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entity) {
|
||||
entity = { fake: true, type: PONY_TYPE, id: message.entityId, name: message.name } as FakeEntity as any;
|
||||
}
|
||||
|
||||
if (isSelected(this.game, message.entityId)) {
|
||||
this.game.whisperTo = entity;
|
||||
chatBox.setChatType('whisper');
|
||||
} else {
|
||||
this.game.select(entity as Pony);
|
||||
}
|
||||
}
|
||||
messageToFriend(chatBox: ChatBox, friend: Friend) {
|
||||
if (friend.entityId) {
|
||||
const entity: any = { id: friend.entityId, name: friend.actualName || 'unknown' };
|
||||
this.messageToPony(chatBox, entity);
|
||||
}
|
||||
}
|
||||
messageToPony(chatBox: ChatBox, pony: Entity) {
|
||||
setTimeout(() => {
|
||||
this.game.whisperTo = pony;
|
||||
chatBox.setChatType('whisper');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
ng-template(#descriptionTooltip)
|
||||
.text-left
|
||||
p Description and tags help you organize and search for your ponies.
|
||||
p Use # tags to be able to quickly find specific ponies like #oc #winter or #unicorn
|
||||
p
|
||||
| Use @ tags to sort your ponies. @top to put them at the start of the list, @end at the end,
|
||||
| and @1 @2 @3 ... to order them manually.
|
||||
|
||||
page-loader(*ngIf="!playing")
|
||||
|
||||
.row(*ngIf="!playing && !loading")
|
||||
.col-lg-6.text-center
|
||||
h1.sr-only(focusTitle) Characters
|
||||
h2.sr-only Character selection
|
||||
.mx-auto(style="max-width: 400px;")
|
||||
support-button.mb-3
|
||||
install-button.mb-3
|
||||
character-select(
|
||||
[(error)]="error" [newButton]="true" [removeButton]="true" (change)="updateMuzzles()"
|
||||
(preview)="previewPony = $event")
|
||||
|
||||
.character-preview-box.mx-auto.my-3((fixToTop)="fixed = $event" [fixToTopOffset]="-40")
|
||||
character-preview(
|
||||
#characterPreview [pony]="previewInfo" [state]="state" [noBackground]="true" [noShadow]="fixed"
|
||||
[name]="previewName" [tag]="previewTag" [extra]="previewExtra" style="z-index: 100")
|
||||
|
||||
.form-group.text-center
|
||||
button.btn.btn-lg.btn-default([disabled]="!canDuplicate" (click)="duplicate()" aria-label="Duplicate character")
|
||||
| Duplicate
|
||||
button.btn.btn-lg.btn-default.ml-2([disabled]="!canRevert" (click)="revert()" aria-label="Revert changes")
|
||||
| Revert
|
||||
button.btn.btn-lg.btn-default.ml-2([disabled]="!canSave" (click)="save()" aria-label="Save character")
|
||||
| Save
|
||||
|
||||
play-box(label="Save & Play" [(error)]="error")
|
||||
play-notice
|
||||
|
||||
.col-lg-6.character-tabs([setOutlineHidden]="!customOutlines" [class.character-tab-warning]="updateWarning")
|
||||
h2.sr-only Character customization
|
||||
tabset(type="pills" *ngIf="loaded" saveActiveTab="character-active-tab" label="Character customization")
|
||||
tab(title="body")
|
||||
.character-tab(*tabContent)
|
||||
div(*ngIf="account?.supporter")
|
||||
.row.form-group
|
||||
label.col-sm-4.col-form-label.text-patreon
|
||||
| Supporter options
|
||||
.col-sm-8.text-muted.pt-2
|
||||
custom-checkbox([(checked)]="pony.hideSupport") hide supporter tag
|
||||
hr.my-2
|
||||
|
||||
.row.form-group
|
||||
label.col-sm-4.col-form-label Advanced options
|
||||
.col-sm-8.text-muted.pt-1
|
||||
custom-checkbox([(checked)]="pony.respawnAtSpawn")
|
||||
| always respawn at spawn point
|
||||
custom-checkbox([(checked)]="info.customOutlines")
|
||||
| allow custom outlines
|
||||
custom-checkbox([(checked)]="info.freeOutlines" (checkedChange)="freeOutlinesChanged($event)")
|
||||
| don't darken outlines
|
||||
custom-checkbox(
|
||||
[(checked)]="info.darkenLockedOutlines" [disabled]="!info.freeOutlines"
|
||||
(checkedChange)="darkenLockedOutlinesChanged($event)")
|
||||
| darken locked outlines
|
||||
|
||||
.row.form-group(*ngIf="debug")
|
||||
label.col-sm-4.col-form-label Debug options
|
||||
.col-sm-8
|
||||
button.btn.btn-sm.btn-default((click)="addBlush()") Add blush
|
||||
|
||||
.row.form-group
|
||||
label.col-sm-4.col-form-label Export/Import options
|
||||
.col-sm-8
|
||||
button.btn.btn-sm.btn-default((click)="exportPony()") Export pony
|
||||
button.btn.btn-sm.btn-default.ml-1((click)="exportPonies()") Export all ponies
|
||||
button.btn.btn-sm.btn-default.ml-1((click)="importFile.click()") Import ponies
|
||||
input.d-none(#importFile type="file" (change)="importPonies(importFile.files[0])")
|
||||
|
||||
.row.form-group
|
||||
label#social-site-label.col-sm-4.col-form-label Show social site
|
||||
.col-sm-8
|
||||
.dropdown.btn-group(dropdown)
|
||||
button.btn.btn-default.dropdown-toggle(
|
||||
dropdownToggle aria-labelledby="social-site-label" aria-describedby="social-site-help")
|
||||
fa-icon.mr-1(
|
||||
*ngIf="site?.icon" [icon]="icon(site.icon)" [fixedWidth]="true" [style.color]="site.color"
|
||||
size="lg" [attr.aria-label]="site.icon")
|
||||
strong {{site?.name}}
|
||||
.dropdown-menu(*dropdownMenu)
|
||||
a.dropdown-item(*ngFor="let s of sites" (click)="site = s")
|
||||
fa-icon.mr-1(
|
||||
*ngIf="s.icon" [icon]="icon(s.icon)" [fixedWidth]="true" [style.color]="s.color"
|
||||
size="lg" [attr.aria-label]="s.icon")
|
||||
strong {{s.name}}
|
||||
|
||||
.dropdown.btn-group.ml-1(dropdown *ngIf="tags.length > 1")
|
||||
button.btn.btn-default.dropdown-toggle(dropdownToggle aria-label="Show tag")
|
||||
span.badge(*ngIf="tag.id" [ngClass]="'badge-' + tag.className") {{tag.id}}
|
||||
strong(*ngIf="!tag.id") {{tag.name}}
|
||||
.dropdown-menu(*dropdownMenu)
|
||||
a.dropdown-item(*ngFor="let t of tags" (click)="tag = t")
|
||||
span.badge(*ngIf="t.id" [ngClass]="'badge-' + t.className") {{t.id}}
|
||||
strong(*ngIf="!t.id") {{t.name}}
|
||||
|
||||
#social-site-help.text-muted.mt-1
|
||||
| Shows social site next to your name to prove to others that it's your account.
|
||||
|
||||
.row.form-group
|
||||
label.col-sm-4.col-form-label(for="description")
|
||||
| Description
|
||||
fa-icon.text-muted.ml-2([icon]="infoIcon" [tooltip]="descriptionTooltip" containerClass="tooltip-desc")
|
||||
.col-sm-8
|
||||
input.form-control#description(
|
||||
[(ngModel)]="pony.desc" placeholder="example: with a hat #oc #winter" [maxlength]="maxDescLength")
|
||||
|
||||
.row.form-group
|
||||
.col-sm-4.col-lock-box
|
||||
label(for="frame-input") Animation
|
||||
check-box.lock-box([icon]="playIcon" [(checked)]="playAnimation" label="Play animation")
|
||||
.col-sm-8
|
||||
.input-group
|
||||
input.form-control#frame-input(
|
||||
type="number" [(ngModel)]="state.animationFrame" [disabled]="playAnimation" min="0"
|
||||
title="Animation frame" aria-label="Animation frame")
|
||||
|
||||
.input-group-append.dropdown(*ngIf="canSaveFiles" dropdown)
|
||||
button.btn.btn-default.dropdown-toggle(dropdownToggle *ngIf="canExport" aria-label="Export / Import")
|
||||
fa-icon([icon]="codeIcon")
|
||||
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
|
||||
a.dropdown-item((click)="export()") export all
|
||||
a.dropdown-item((click)="import()") import data
|
||||
a.dropdown-item((click)="testSize()") print compressed
|
||||
a.dropdown-item((click)="testJSON()") print raw
|
||||
|
||||
.input-group-append.dropdown(dropdown)
|
||||
button.btn.btn-default(
|
||||
*ngIf="canSaveFiles" (click)="export(activeAnimation)" title="Save as PNG" aria-label="Save as PNG")
|
||||
fa-icon([icon]="saveIcon")
|
||||
button.btn.btn-default.dropdown-toggle(dropdownToggle style="min-width: 80px")
|
||||
strong {{animations[activeAnimation]().name}}
|
||||
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
|
||||
a.dropdown-item(*ngFor="let a of animations; let i = index" (click)="setActiveAnimation(i)")
|
||||
| {{a().name}}
|
||||
|
||||
hr
|
||||
fill-outline(
|
||||
label="Body color" [(fill)]="info.coatFill" [(outline)]="info.coatOutline"
|
||||
[(outlineLocked)]="info.lockCoatOutline" [outlineHidden]="!customOutlines" (change)="changed()")
|
||||
hr
|
||||
set-selection(
|
||||
label="Horn" [base]="coatFill" [set]="info.horn" [sets]="horns" [compact]="true" (change)="changed()"
|
||||
[darken]="darken")
|
||||
hr
|
||||
.row.form-group
|
||||
label.col-sm-4.col-form-label Magic color
|
||||
.col-sm-8
|
||||
color-picker([(color)]="info.magicColor" label="Magic color")
|
||||
hr
|
||||
set-selection(
|
||||
label="Wings" [base]="coatFill" [set]="info.wings" [sets]="wings" [compact]="true" (change)="changed()"
|
||||
[darken]="darken")
|
||||
hr
|
||||
set-selection(
|
||||
label="Ears" [base]="coatFill" [set]="info.ears" [sets]="ears" [compact]="true" (change)="changed()"
|
||||
[darken]="darken")
|
||||
hr
|
||||
set-selection(
|
||||
label="Front hooves" [base]="coatFill" [set]="info.frontHooves" [sets]="frontHooves" [compact]="true"
|
||||
[darken]="darken" (change)="changed()")
|
||||
hr
|
||||
set-selection(
|
||||
label="Back hooves" [base]="hoovesFill" [set]="info.backHooves" [sets]="backHooves" [compact]="true"
|
||||
[darken]="darken" (change)="changed()")
|
||||
hr
|
||||
butt-mark-editor([info]="info" [state]="buttMarkState")
|
||||
|
||||
tab(title="mane")
|
||||
.character-tab(*tabContent)
|
||||
set-selection(
|
||||
label="Mane" [base]="maneFill" [set]="info.mane" [sets]="manes" [nonLockable]="true"
|
||||
[darken]="darken" (change)="changed()")
|
||||
hr
|
||||
set-selection(
|
||||
label="Back mane" [base]="maneFill" [set]="info.backMane" [sets]="backManes"
|
||||
[darken]="darken" (change)="changed()")
|
||||
|
||||
tab(title="tail")
|
||||
.character-tab(*tabContent)
|
||||
set-selection(
|
||||
label="Tail" [base]="maneFill" [set]="info.tail" [sets]="tails" [darken]="darken" (change)="changed()")
|
||||
.form-group.text-center
|
||||
button.btn.btn-sm.btn-default((click)="copyCoatColorToTail()" [disabled]="info.tail.lockFills[0]")
|
||||
| Use body color
|
||||
|
||||
tab(title="face")
|
||||
.character-tab(*tabContent)
|
||||
|
||||
.row.form-group
|
||||
.col-sm-4.col-lock-box
|
||||
label#eye-color Eye color
|
||||
check-box.lock-box(
|
||||
[(checked)]="info.lockEyeColor" [icon]="lockIcon" (checkedChange)="eyeColorLockChanged($event)"
|
||||
label="Lock color for both eyes" title="Lock color for both eyes")
|
||||
.col-sm-8
|
||||
color-picker([(color)]="info.eyeColorRight" (colorChange)="changed()" labelledBy="eye-color")
|
||||
.row.form-group(*ngIf="!info.lockEyeColor")
|
||||
label#eye-color-left.col-sm-4.col-form-label Eye color (left)
|
||||
.col-sm-8
|
||||
color-picker([(color)]="info.eyeColorLeft" labelledBy="eye-color-left")
|
||||
|
||||
.row.form-group
|
||||
.col-sm-4.col-lock-box
|
||||
label#eye-whites-label Eye whites
|
||||
check-box.lock-box(
|
||||
[(checked)]="lockEyeWhites" [icon]="lockIcon" (checkedChange)="eyeWhiteLockChanged($event)"
|
||||
label="Lock left eye white for both eyes" title="Lock left eye white for both eyes")
|
||||
.col-sm-8
|
||||
color-picker([(color)]="info.eyeWhites" (colorChange)="changed()" labelledBy="eye-whites-label")
|
||||
.row.form-group(*ngIf="info.unlockEyeWhites")
|
||||
label#eye-whites-label-left.col-sm-4.col-form-label Eye whites (left)
|
||||
.col-sm-8
|
||||
color-picker([(color)]="info.eyeWhitesLeft" labelledBy="eye-whites-label-left")
|
||||
|
||||
.row.form-group
|
||||
.col-sm-4.col-lock-box
|
||||
label#eyes {{info.lockEyes ? 'Eyes' : 'Right eye'}}
|
||||
check-box.lock-box(
|
||||
[(checked)]="info.lockEyes" [icon]="lockIcon" (checkedChange)="eyeOpennessChanged($event)"
|
||||
label="Lock both eyes" title="Lock both eyes")
|
||||
.col-sm-8
|
||||
sprite-selection(
|
||||
[(selected)]="info.eyeOpennessRight" [sprites]="eyesRight" [fill]="info.eyeColorRight"
|
||||
[circle]="info.coatFill" [reverseExtra]="true" [skip]="1" [limit]="9" [darken]="darken"
|
||||
(selectedChange)="eyeOpennessChanged(info.lockEyes)" aria-labelledby="eyes")
|
||||
.row.form-group(*ngIf="!info.lockEyes")
|
||||
.col-sm-4
|
||||
label#left-eye Left eye
|
||||
.col-sm-8
|
||||
sprite-selection(
|
||||
[(selected)]="info.eyeOpennessLeft" [sprites]="eyesLeft" [fill]="info.eyeColorLeft" [darken]="darken"
|
||||
[circle]="info.coatFill" [reverseExtra]="true" [skip]="1" [limit]="5" aria-labelledby="left-eye")
|
||||
|
||||
.row.form-group
|
||||
.col-sm-4.col-lock-box
|
||||
label Eyeshadow
|
||||
check-box.lock-box(
|
||||
[(checked)]="info.eyeshadow" (checkedChange)="characterPreview.blink()"
|
||||
label="Use eye shadow" title="Use eye shadow")
|
||||
.col-sm-8
|
||||
color-picker([(color)]="info.eyeshadowColor" [isDisabled]="!info.eyeshadow" label="Eyeshadow color")
|
||||
|
||||
.row.form-group
|
||||
label#eyelashes.col-sm-4.col-form-label Eyelashes
|
||||
.col-sm-8
|
||||
sprite-selection(
|
||||
[(selected)]="info.eyelashes" [sprites]="eyelashes" [fill]="info.eyeColorRight" [darken]="darken"
|
||||
[circle]="info.coatFill" [reverseExtra]="true" aria-labelledby="eyelashes")
|
||||
|
||||
.row.form-group
|
||||
.col-sm-4.col-lock-box
|
||||
label#eyelashes-label Eyelash color
|
||||
check-box.lock-box(
|
||||
[(checked)]="lockEyelashColor" [icon]="lockIcon" (checkedChange)="eyelashLockChanged($event)"
|
||||
label="Lock eyelash color for both eyes" title="Lock eyelash color for both eyes")
|
||||
.col-sm-8
|
||||
color-picker([(color)]="info.eyelashColor" (colorChange)="changed()" labelledBy="eyelashes-label")
|
||||
.row.form-group(*ngIf="!lockEyelashColor")
|
||||
label#eyelashes-label-left.col-sm-4.col-form-label Eyelash color (left)
|
||||
.col-sm-8
|
||||
color-picker([(color)]="info.eyelashColorLeft" labelledBy="eyelashes-label-left")
|
||||
|
||||
hr
|
||||
set-selection(
|
||||
label="Muzzle" base="info.coatFill" [set]="info.nose" [sets]="noses" [darken]="darken"
|
||||
[compact]="true" (change)="changed(); updateMuzzles()")
|
||||
hr
|
||||
set-selection(
|
||||
label="Markings" [base]="coatFill" [set]="info.head" [sets]="heads" [darken]="darken"
|
||||
[onlyPatterns]="true" (change)="changed()")
|
||||
hr
|
||||
.row.form-group
|
||||
label#fangs.col-sm-4.col-form-label Fangs
|
||||
.col-sm-8
|
||||
sprite-selection(
|
||||
[(selected)]="info.fangs" [sprites]="fangs" [fill]="info.nose.fills" [darken]="darken"
|
||||
[outline]="info.nose.outlines" [circle]="info.coatFill" aria-labelledby="fangs")
|
||||
.row.form-group
|
||||
label#expression.col-sm-4.col-form-label(for="#expression-selection") Expression
|
||||
.col-sm-8
|
||||
sprite-selection#expression-selection(
|
||||
[(selected)]="info.muzzle" [sprites]="muzzles" [fill]="info.nose.fills" [darken]="darken"
|
||||
[outline]="info.nose.outlines" [circle]="info.coatFill" [limit]="9" aria-labelledby="expression")
|
||||
hr
|
||||
set-selection(
|
||||
label="Facial hair" [base]="maneFill" [set]="info.facialHair" [sets]="facialHair" [darken]="darken"
|
||||
(change)="changed()")
|
||||
|
||||
tab(title="other")
|
||||
.character-tab(*tabContent)
|
||||
tabset.accessories-tabset(saveActiveTab="character-active-accessory-tab" label="Character accessories")
|
||||
tab(title="head")
|
||||
.accessory-tab(*tabContent)
|
||||
set-selection(
|
||||
label="Head accessories" [set]="info.headAccessory" [sets]="headAccessories" [darken]="darken"
|
||||
[nonLockable]="true" (change)="changed()")
|
||||
hr
|
||||
set-selection(
|
||||
label="Ear accessories" [set]="info.earAccessory" [sets]="earAccessories" [darken]="darken"
|
||||
[nonLockable]="true" (change)="changed()")
|
||||
hr
|
||||
set-selection(
|
||||
label="Face accessories" [set]="info.faceAccessory" [sets]="faceAccessories" [darken]="darken"
|
||||
[nonLockable]="true" (change)="changed()")
|
||||
|
||||
tab(title="neck")
|
||||
.accessory-tab(*tabContent)
|
||||
set-selection(
|
||||
label="Neck accessories" [set]="info.neckAccessory" [sets]="neckAccessories" [darken]="darken"
|
||||
[nonLockable]="true" (change)="changed()")
|
||||
|
||||
tab(title="legs")
|
||||
.accessory-tab(*tabContent)
|
||||
.row.form-group
|
||||
label.col-sm-4.col-form-label Use the same accessory
|
||||
.col-sm-8.text-muted.pt-1
|
||||
.mb-2
|
||||
custom-checkbox([(checked)]="info.lockBackLegAccessory")
|
||||
| for front and back legs
|
||||
.mb-2
|
||||
custom-checkbox([(checked)]="lockFrontLegAccessory")
|
||||
| for both front legs
|
||||
.mb-2
|
||||
custom-checkbox([(checked)]="lockBackLegAccessory" [disabled]="info.lockBackLegAccessory")
|
||||
| for both back legs
|
||||
|
||||
set-selection(
|
||||
[label]="info.lockFrontLegAccessories ? 'Front leg accessory' : 'Front leg accessory (left)'"
|
||||
[set]="info.frontLegAccessory" [sets]="frontLegAccessories" [darken]="darken"
|
||||
[nonLockable]="true" (change)="changed()")
|
||||
hr
|
||||
div(*ngIf="info.unlockFrontLegAccessory")
|
||||
set-selection(
|
||||
label="Front leg accessory (right)"
|
||||
[set]="info.frontLegAccessoryRight" [sets]="frontLegAccessories" [darken]="darken"
|
||||
[nonLockable]="true" (change)="changed()")
|
||||
hr
|
||||
//- .form-group.d-flex.justify-content-center
|
||||
custom-checkbox([(checked)]="info.lockBackLegAccessory") use the same accessory for back legs
|
||||
div(*ngIf="!info.lockBackLegAccessory")
|
||||
set-selection(
|
||||
[label]="info.lockBackLegAccessories ? 'Back leg accessory' : 'Back leg accessory (left)'"
|
||||
[set]="info.backLegAccessory" [sets]="backLegAccessories" [darken]="darken"
|
||||
[nonLockable]="true" (change)="changed()")
|
||||
hr
|
||||
div(*ngIf="!info.lockBackLegAccessory && info.unlockBackLegAccessory")
|
||||
set-selection(
|
||||
label="Back leg accessory (right)"
|
||||
[set]="info.backLegAccessoryRight" [sets]="backLegAccessories" [darken]="darken"
|
||||
[nonLockable]="true" (change)="changed()")
|
||||
|
||||
tab(title="chest")
|
||||
.accessory-tab(*tabContent)
|
||||
set-selection(
|
||||
label="Chest accessories" [set]="info.chestAccessory" [sets]="chestAccessories" [darken]="darken"
|
||||
[nonLockable]="true" (change)="changed()")
|
||||
set-selection(
|
||||
*ngIf="hasSleeves(info.chestAccessory.type)" label="Sleeves" [set]="info.sleeveAccessory" [darken]="darken"
|
||||
[sets]="sleeveAccessories" (change)="changed()")
|
||||
|
||||
tab(title="back")
|
||||
.accessory-tab(*tabContent)
|
||||
set-selection(
|
||||
label="Back accessories" [set]="info.backAccessory" [sets]="backAccessories" [darken]="darken"
|
||||
[nonLockable]="true" (change)="changed()")
|
||||
|
||||
tab(title="waist")
|
||||
.accessory-tab(*tabContent)
|
||||
set-selection(
|
||||
label="Waist accessories" [set]="info.waistAccessory" [sets]="waistAccessories" [darken]="darken"
|
||||
[nonLockable]="true" (change)="changed()")
|
||||
|
||||
tab(title="other")
|
||||
.accessory-tab(*tabContent)
|
||||
.form-group.d-flex.justify-content-center.pt-2
|
||||
custom-checkbox([(checked)]="previewExtra") show preview
|
||||
em.text-muted.ml-2
|
||||
| (you have to enable these accessories in-game)
|
||||
set-selection(
|
||||
label="Extra accessories" [set]="info.extraAccessory" [sets]="extraAccessories"
|
||||
[darken]="darken" (change)="changed()")
|
||||
@@ -0,0 +1,62 @@
|
||||
@import '../../../../styles/partials/variables';
|
||||
|
||||
.character-tabs {
|
||||
min-height: 500px;
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
|
||||
.character-tab {
|
||||
background: $dark-bg;
|
||||
margin-top: 10px;
|
||||
padding: 10px 15px;
|
||||
min-height: 900px;
|
||||
border-radius: $border-radius-sm;
|
||||
|
||||
.character-tab-warning & {
|
||||
box-shadow: 0 0 0 2px $warning;
|
||||
}
|
||||
}
|
||||
|
||||
.accessory-tab {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.character-preview-box {
|
||||
position: relative;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: $grass-color;
|
||||
border-radius: $border-radius-lg;
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
&.fixed-to-top {
|
||||
character-preview {
|
||||
position: fixed;
|
||||
top: -45px;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.col-lock-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
|
||||
> .lock-box {
|
||||
@include media-breakpoint-down(xs) {
|
||||
margin-right: 0;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
> label {
|
||||
margin: 0;
|
||||
padding-top: 6px;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user