Archive commit

This commit is contained in:
Erik McClure
2019-08-28 21:15:02 -07:00
commit 735845746e
1435 changed files with 140724 additions and 0 deletions
@@ -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;") &times;
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:&lt;account_name&gt; #[em - account name]
div note:&lt;note_text&gt; #[em - note contents]
div email:&lt;email_fragment&gt; #[em - email fragment]
div role:&lt;role_name&gt; #[em - role]
div exact:&lt;name&gt; #[em - exact account name]
div ignores:&lt;count&gt; #[em - amount of ignores (at least)]
div ponies:&lt;count&gt; #[em - amount of ponies (at least)]
div auths:&lt;count&gt; #[em - amount of auths (at least)]
div old:&lt;number_of_days&gt; #[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:&lt;number&gt;
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:&lt;name&gt; #[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);
}
}
+139
View File
@@ -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 {
}
+24
View File
@@ -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") &times;
| {{model.error}}
+14
View File
@@ -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;
}
+75
View File
@@ -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 = '/';
}
}
+100
View File
@@ -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;
}
}
}
+10
View File
@@ -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")
| &lt;no name&gt;
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") &bull;
.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;
}
}
+102
View File
@@ -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
+33
View File
@@ -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%;
}
}
+31
View File
@@ -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;
}
+134
View File
@@ -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);
}
}
+130
View File
@@ -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));
}
}
+66
View File
@@ -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 {
}
+61
View File
@@ -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
| &copy; 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
+204
View File
@@ -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;
}
}
+210
View File
@@ -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