0.2.7 - Profile viewer and many many bug fixes
This commit is contained in:
		
							parent
							
								
									cf015bd4b7
								
							
						
					
					
						commit
						ebf7cb43c5
					
				@ -34,6 +34,7 @@
 | 
			
		||||
    import {getKey} from '../chat/common';
 | 
			
		||||
    import {CoreBBCodeParser, urlRegex} from './core';
 | 
			
		||||
    import {defaultButtons, EditorButton, EditorSelection} from './editor';
 | 
			
		||||
    import {BBCodeParser} from './parser';
 | 
			
		||||
 | 
			
		||||
    @Component
 | 
			
		||||
    export default class Editor extends Vue {
 | 
			
		||||
@ -56,10 +57,14 @@
 | 
			
		||||
        element: HTMLTextAreaElement;
 | 
			
		||||
        maxHeight: number;
 | 
			
		||||
        minHeight: number;
 | 
			
		||||
        protected parser = new CoreBBCodeParser();
 | 
			
		||||
        protected parser: BBCodeParser;
 | 
			
		||||
        protected defaultButtons = defaultButtons;
 | 
			
		||||
        private isShiftPressed = false;
 | 
			
		||||
 | 
			
		||||
        created(): void {
 | 
			
		||||
            this.parser = new CoreBBCodeParser();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        mounted(): void {
 | 
			
		||||
            this.element = <HTMLTextAreaElement>this.$refs['input'];
 | 
			
		||||
            const $element = $(this.element);
 | 
			
		||||
 | 
			
		||||
@ -44,7 +44,7 @@ export class CoreBBCodeParser extends BBCodeParser {
 | 
			
		||||
            parent.appendChild(el);
 | 
			
		||||
            return el;
 | 
			
		||||
        }, (parser, element, _, param) => {
 | 
			
		||||
            const content = element.innerText.trim();
 | 
			
		||||
            const content = element.textContent!.trim();
 | 
			
		||||
            while(element.firstChild !== null) element.removeChild(element.firstChild);
 | 
			
		||||
 | 
			
		||||
            let url: string, display: string = content;
 | 
			
		||||
@ -54,23 +54,26 @@ export class CoreBBCodeParser extends BBCodeParser {
 | 
			
		||||
            } else if(content.length > 0) url = content;
 | 
			
		||||
            else {
 | 
			
		||||
                parser.warning('url tag contains no url.');
 | 
			
		||||
                element.innerText = ''; //Dafuq!?
 | 
			
		||||
                element.textContent = ''; //Dafuq!?
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // This fixes problems where content based urls are marked as invalid if they contain spaces.
 | 
			
		||||
            url = fixURL(url);
 | 
			
		||||
            if(!urlRegex.test(url)) {
 | 
			
		||||
                element.innerText = `[BAD URL] ${url}`;
 | 
			
		||||
                element.textContent = `[BAD URL] ${url}`;
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
            const fa = parser.createElement('i');
 | 
			
		||||
            fa.className = 'fa fa-link';
 | 
			
		||||
            element.appendChild(fa);
 | 
			
		||||
            const a = parser.createElement('a');
 | 
			
		||||
            a.href = url;
 | 
			
		||||
            a.rel = 'nofollow noreferrer noopener';
 | 
			
		||||
            a.target = '_blank';
 | 
			
		||||
            a.className = 'link-graphic';
 | 
			
		||||
            a.className = 'user-link';
 | 
			
		||||
            a.title = url;
 | 
			
		||||
            a.innerText = display;
 | 
			
		||||
            a.textContent = display;
 | 
			
		||||
            element.appendChild(a);
 | 
			
		||||
            const span = document.createElement('span');
 | 
			
		||||
            span.className = 'link-domain';
 | 
			
		||||
 | 
			
		||||
@ -48,7 +48,7 @@ export let defaultButtons: ReadonlyArray<EditorButton> = [
 | 
			
		||||
        tag: 'color',
 | 
			
		||||
        startText: '[color=]',
 | 
			
		||||
        icon: 'fa-eyedropper',
 | 
			
		||||
        key: 'q'
 | 
			
		||||
        key: 'd'
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        title: 'Superscript (Ctrl+↑)\n\nLifts text above the text baseline. Makes text slightly smaller. Cannot be nested.',
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								bbcode/interfaces.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								bbcode/interfaces.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1 @@
 | 
			
		||||
export const enum InlineDisplayMode {DISPLAY_ALL, DISPLAY_SFW, DISPLAY_NONE}
 | 
			
		||||
@ -18,10 +18,10 @@ export abstract class BBCodeTag {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    //tslint:disable-next-line:no-empty
 | 
			
		||||
    afterClose(_: BBCodeParser, __: HTMLElement, ___: HTMLElement, ____?: string): void {
 | 
			
		||||
    afterClose(_: BBCodeParser, __: HTMLElement, ___: HTMLElement | undefined, ____?: string): void {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    abstract createElement(parser: BBCodeParser, parent: HTMLElement, param: string): HTMLElement;
 | 
			
		||||
    abstract createElement(parser: BBCodeParser, parent: HTMLElement, param: string): HTMLElement  | undefined;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class BBCodeSimpleTag extends BBCodeTag {
 | 
			
		||||
@ -33,8 +33,8 @@ export class BBCodeSimpleTag extends BBCodeTag {
 | 
			
		||||
    createElement(parser: BBCodeParser, parent: HTMLElement, param: string): HTMLElement {
 | 
			
		||||
        if(param.length > 0)
 | 
			
		||||
            parser.warning('Unexpected parameter');
 | 
			
		||||
        const el = parser.createElement(this.elementName);
 | 
			
		||||
        if(this.classes !== undefined)
 | 
			
		||||
        const el = <HTMLElement>parser.createElement(this.elementName);
 | 
			
		||||
        if(this.classes !== undefined && this.classes.length > 0)
 | 
			
		||||
            el.className = this.classes.join(' ');
 | 
			
		||||
        parent.appendChild(el);
 | 
			
		||||
        /*tslint:disable-next-line:no-unsafe-any*/// false positive
 | 
			
		||||
@ -42,7 +42,7 @@ export class BBCodeSimpleTag extends BBCodeTag {
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type CustomElementCreator = (parser: BBCodeParser, parent: HTMLElement, param: string) => HTMLElement;
 | 
			
		||||
export type CustomElementCreator = (parser: BBCodeParser, parent: HTMLElement, param: string) => HTMLElement | undefined;
 | 
			
		||||
export type CustomCloser = (parser: BBCodeParser, current: HTMLElement, parent: HTMLElement, param: string) => void;
 | 
			
		||||
 | 
			
		||||
export class BBCodeCustomTag extends BBCodeTag {
 | 
			
		||||
@ -50,7 +50,7 @@ export class BBCodeCustomTag extends BBCodeTag {
 | 
			
		||||
        super(tag, tagList);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    createElement(parser: BBCodeParser, parent: HTMLElement, param: string): HTMLElement {
 | 
			
		||||
    createElement(parser: BBCodeParser, parent: HTMLElement, param: string): HTMLElement | undefined {
 | 
			
		||||
        return this.customCreator(parser, parent, param);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -63,7 +63,7 @@ export class BBCodeCustomTag extends BBCodeTag {
 | 
			
		||||
enum BufferType { Raw, Tag }
 | 
			
		||||
 | 
			
		||||
class ParserTag {
 | 
			
		||||
    constructor(public tag: string, public param: string, public element: HTMLElement, public parent: HTMLElement,
 | 
			
		||||
    constructor(public tag: string, public param: string, public element: HTMLElement, public parent: HTMLElement | undefined,
 | 
			
		||||
                public line: number, public column: number) {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -155,8 +155,7 @@ export class BBCodeParser {
 | 
			
		||||
 | 
			
		||||
        let curType: BufferType = BufferType.Raw;
 | 
			
		||||
        // Root tag collects output.
 | 
			
		||||
        const root = this.createElement('span');
 | 
			
		||||
        const rootTag = new ParserTag('<root>', '', root, root, 1, 1);
 | 
			
		||||
        const rootTag = new ParserTag('<root>', '', this.createElement('span'), undefined, 1, 1);
 | 
			
		||||
        stack.push(rootTag);
 | 
			
		||||
        this._currentTag = rootTag;
 | 
			
		||||
        let paramStart = -1;
 | 
			
		||||
@ -207,13 +206,18 @@ export class BBCodeParser {
 | 
			
		||||
                                if(!allowed)
 | 
			
		||||
                                    break;
 | 
			
		||||
                            }
 | 
			
		||||
                            const tag = this._tags[tagKey]!;
 | 
			
		||||
                            if(!allowed) {
 | 
			
		||||
                                ignoreNextClosingTag(tagKey);
 | 
			
		||||
                                quickReset(i);
 | 
			
		||||
                                continue;
 | 
			
		||||
                            }
 | 
			
		||||
                            const parent = stackTop().element;
 | 
			
		||||
                            const el = this._tags[tagKey]!.createElement(this, parent, param);
 | 
			
		||||
                            const el: HTMLElement | undefined = tag.createElement(this, parent, param);
 | 
			
		||||
                            if(el === undefined) {
 | 
			
		||||
                                quickReset(i);
 | 
			
		||||
                                continue;
 | 
			
		||||
                            }
 | 
			
		||||
                            if(!this._tags[tagKey]!.noClosingTag)
 | 
			
		||||
                                stack.push(new ParserTag(tagKey, param, el, parent, this._line, this._column));
 | 
			
		||||
                        } else if(ignoreClosing[tagKey] > 0) {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										215
									
								
								bbcode/standard.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										215
									
								
								bbcode/standard.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,215 @@
 | 
			
		||||
import * as $ from 'jquery';
 | 
			
		||||
import {CoreBBCodeParser} from './core';
 | 
			
		||||
import {InlineDisplayMode} from './interfaces';
 | 
			
		||||
import {BBCodeCustomTag, BBCodeSimpleTag} from './parser';
 | 
			
		||||
 | 
			
		||||
interface InlineImage {
 | 
			
		||||
    id: number
 | 
			
		||||
    hash: string
 | 
			
		||||
    extension: string
 | 
			
		||||
    nsfw: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface StandardParserSettings {
 | 
			
		||||
    siteDomain: string
 | 
			
		||||
    staticDomain: string
 | 
			
		||||
    animatedIcons: boolean
 | 
			
		||||
    inlineDisplayMode: InlineDisplayMode
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const usernameRegex = /^[a-zA-Z0-9_\-\s]+$/;
 | 
			
		||||
 | 
			
		||||
export class StandardBBCodeParser extends CoreBBCodeParser {
 | 
			
		||||
    allowInlines = true;
 | 
			
		||||
    inlines: {[key: string]: InlineImage | undefined} | undefined;
 | 
			
		||||
 | 
			
		||||
    constructor(public settings: StandardParserSettings) {
 | 
			
		||||
        super();
 | 
			
		||||
        const hrTag = new BBCodeSimpleTag('hr', 'hr', [], []);
 | 
			
		||||
        hrTag.noClosingTag = true;
 | 
			
		||||
        this.addTag('hr', hrTag);
 | 
			
		||||
        this.addTag('quote', new BBCodeCustomTag('quote', (parser, parent, param) => {
 | 
			
		||||
            if(param !== '')
 | 
			
		||||
                parser.warning('Unexpected paramter on quote tag.');
 | 
			
		||||
            const element = parser.createElement('blockquote');
 | 
			
		||||
            const innerElement = parser.createElement('div');
 | 
			
		||||
            innerElement.className = 'quoteHeader';
 | 
			
		||||
            innerElement.appendChild(document.createTextNode('Quote:'));
 | 
			
		||||
            element.appendChild(innerElement);
 | 
			
		||||
            parent.appendChild(element);
 | 
			
		||||
            return element;
 | 
			
		||||
        }));
 | 
			
		||||
        this.addTag('left', new BBCodeSimpleTag('left', 'span', ['leftText']));
 | 
			
		||||
        this.addTag('right', new BBCodeSimpleTag('right', 'span', ['rightText']));
 | 
			
		||||
        this.addTag('center', new BBCodeSimpleTag('center', 'span', ['centerText']));
 | 
			
		||||
        this.addTag('justify', new BBCodeSimpleTag('justify', 'span', ['justifyText']));
 | 
			
		||||
        this.addTag('big', new BBCodeSimpleTag('big', 'span', ['bigText'], ['url', 'i', 'u', 'b', 'color', 's']));
 | 
			
		||||
        this.addTag('small', new BBCodeSimpleTag('small', 'span', ['smallText'], ['url', 'i', 'u', 'b', 'color', 's']));
 | 
			
		||||
        this.addTag('indent', new BBCodeSimpleTag('indent', 'div', ['indentText']));
 | 
			
		||||
        this.addTag('heading', new BBCodeSimpleTag('heading', 'h2', [], ['url', 'i', 'u', 'b', 'color', 's']));
 | 
			
		||||
        this.addTag('collapse', new BBCodeCustomTag('collapse', (parser, parent, param) => {
 | 
			
		||||
            if(param === '') { //tslint:disable-line:curly
 | 
			
		||||
                parser.warning('title parameter is required.');
 | 
			
		||||
                // HACK: Compatability fix with old site. Titles are not trimmed on old site, so empty collapse titles need to be allowed.
 | 
			
		||||
                //return null;
 | 
			
		||||
            }
 | 
			
		||||
            const outer = parser.createElement('div');
 | 
			
		||||
            outer.className = 'collapseHeader';
 | 
			
		||||
            const headerText = parser.createElement('div');
 | 
			
		||||
            headerText.className = 'collapseHeaderText';
 | 
			
		||||
            outer.appendChild(headerText);
 | 
			
		||||
            const innerText = parser.createElement('span');
 | 
			
		||||
            innerText.appendChild(document.createTextNode(param));
 | 
			
		||||
            headerText.appendChild(innerText);
 | 
			
		||||
            const body = parser.createElement('div');
 | 
			
		||||
            body.className = 'collapseBlock';
 | 
			
		||||
            outer.appendChild(body);
 | 
			
		||||
            parent.appendChild(outer);
 | 
			
		||||
            return body;
 | 
			
		||||
        }));
 | 
			
		||||
        this.addTag('user', new BBCodeCustomTag('user', (parser, parent, _) => {
 | 
			
		||||
            const el = parser.createElement('span');
 | 
			
		||||
            parent.appendChild(el);
 | 
			
		||||
            return el;
 | 
			
		||||
        }, (parser, element, parent, param) => {
 | 
			
		||||
            if(param !== '')
 | 
			
		||||
                parser.warning('Unexpected parameter on user tag.');
 | 
			
		||||
            const content = element.innerText;
 | 
			
		||||
            if(!usernameRegex.test(content))
 | 
			
		||||
                return;
 | 
			
		||||
            const a = parser.createElement('a');
 | 
			
		||||
            a.href = `${this.settings.siteDomain}c/${content}`;
 | 
			
		||||
            a.target = '_blank';
 | 
			
		||||
            a.className = 'character-link';
 | 
			
		||||
            a.appendChild(document.createTextNode(content));
 | 
			
		||||
            parent.replaceChild(a, element);
 | 
			
		||||
        }, []));
 | 
			
		||||
        this.addTag('icon', new BBCodeCustomTag('icon', (parser, parent, _) => {
 | 
			
		||||
            const el = parser.createElement('span');
 | 
			
		||||
            parent.appendChild(el);
 | 
			
		||||
            return el;
 | 
			
		||||
        }, (parser, element, parent, param) => {
 | 
			
		||||
            if(param !== '')
 | 
			
		||||
                parser.warning('Unexpected parameter on icon tag.');
 | 
			
		||||
            const content = element.innerText;
 | 
			
		||||
            if(!usernameRegex.test(content))
 | 
			
		||||
                return;
 | 
			
		||||
            const a = parser.createElement('a');
 | 
			
		||||
            a.href = `${this.settings.siteDomain}c/${content}`;
 | 
			
		||||
            a.target = '_blank';
 | 
			
		||||
            const img = parser.createElement('img');
 | 
			
		||||
            img.src = `${this.settings.staticDomain}images/avatar/${content.toLowerCase()}.png`;
 | 
			
		||||
            img.className = 'character-avatar icon';
 | 
			
		||||
            a.appendChild(img);
 | 
			
		||||
            parent.replaceChild(a, element);
 | 
			
		||||
        }, []));
 | 
			
		||||
        this.addTag('eicon', new BBCodeCustomTag('eicon', (parser, parent, _) => {
 | 
			
		||||
            const el = parser.createElement('span');
 | 
			
		||||
            parent.appendChild(el);
 | 
			
		||||
            return el;
 | 
			
		||||
        }, (parser, element, parent, param) => {
 | 
			
		||||
            if(param !== '')
 | 
			
		||||
                parser.warning('Unexpected parameter on eicon tag.');
 | 
			
		||||
            const content = element.innerText;
 | 
			
		||||
 | 
			
		||||
            if(!usernameRegex.test(content))
 | 
			
		||||
                return;
 | 
			
		||||
            let extension = '.gif';
 | 
			
		||||
            if(!this.settings.animatedIcons)
 | 
			
		||||
                extension = '.png';
 | 
			
		||||
            const img = parser.createElement('img');
 | 
			
		||||
            img.src = `${this.settings.staticDomain}images/eicon/${content.toLowerCase()}${extension}`;
 | 
			
		||||
            img.className = 'character-avatar icon';
 | 
			
		||||
            parent.replaceChild(img, element);
 | 
			
		||||
        }, []));
 | 
			
		||||
        this.addTag('img', new BBCodeCustomTag('img', (p, parent, param) => {
 | 
			
		||||
            const parser = <StandardBBCodeParser>p;
 | 
			
		||||
            if(!this.allowInlines) {
 | 
			
		||||
                parser.warning('Inline images are not allowed here.');
 | 
			
		||||
                return undefined;
 | 
			
		||||
            }
 | 
			
		||||
            if(typeof parser.inlines === 'undefined') {
 | 
			
		||||
                parser.warning('This page does not support inline images.');
 | 
			
		||||
                return undefined;
 | 
			
		||||
            }
 | 
			
		||||
            let p1: string, p2: string, inline;
 | 
			
		||||
            const displayMode = this.settings.inlineDisplayMode;
 | 
			
		||||
            if(!/^\d+$/.test(param)) {
 | 
			
		||||
                parser.warning('img tag parameters must be numbers.');
 | 
			
		||||
                return undefined;
 | 
			
		||||
            }
 | 
			
		||||
            if(typeof parser.inlines[param] !== 'object') {
 | 
			
		||||
                parser.warning(`Could not find an inline image with id ${param} It will not be visible.`);
 | 
			
		||||
                return undefined;
 | 
			
		||||
            }
 | 
			
		||||
            inline = parser.inlines[param]!;
 | 
			
		||||
            p1 = inline.hash.substr(0, 2);
 | 
			
		||||
            p2 = inline.hash.substr(2, 2);
 | 
			
		||||
 | 
			
		||||
            if(displayMode === InlineDisplayMode.DISPLAY_NONE || (displayMode === InlineDisplayMode.DISPLAY_SFW && inline.nsfw)) {
 | 
			
		||||
                const el = parser.createElement('a');
 | 
			
		||||
                el.className = 'unloadedInline';
 | 
			
		||||
                el.href = '#';
 | 
			
		||||
                el.dataset.inlineId = param;
 | 
			
		||||
                el.onclick = () => {
 | 
			
		||||
                    $('.unloadedInline').each((_, element) => {
 | 
			
		||||
                        const inlineId = $(element).data('inline-id');
 | 
			
		||||
                        if(typeof parser.inlines![inlineId] !== 'object')
 | 
			
		||||
                            return;
 | 
			
		||||
                        const showInline = parser.inlines![inlineId]!;
 | 
			
		||||
                        const showP1 = showInline.hash.substr(0, 2);
 | 
			
		||||
                        const showP2 = showInline.hash.substr(2, 2);
 | 
			
		||||
                        //tslint:disable-next-line:max-line-length
 | 
			
		||||
                        $(element).replaceWith(`<div><img class="imageBlock" src="${this.settings.staticDomain}images/charinline/${showP1}/${showP2}/${showInline.hash}.${showInline.extension}"/></div>`);
 | 
			
		||||
                    });
 | 
			
		||||
                    return false;
 | 
			
		||||
                };
 | 
			
		||||
                const prefix = inline.nsfw ? '[NSFW Inline] ' : '[Inline] ';
 | 
			
		||||
                el.appendChild(document.createTextNode(prefix));
 | 
			
		||||
                parent.appendChild(el);
 | 
			
		||||
                return el;
 | 
			
		||||
            } else {
 | 
			
		||||
                const outerEl = parser.createElement('div');
 | 
			
		||||
                const el = parser.createElement('img');
 | 
			
		||||
                el.className = 'imageBlock';
 | 
			
		||||
                el.src = `${this.settings.staticDomain}images/charinline/${p1}/${p2}/${inline.hash}.${inline.extension}`;
 | 
			
		||||
                outerEl.appendChild(el);
 | 
			
		||||
                parent.appendChild(outerEl);
 | 
			
		||||
                return el;
 | 
			
		||||
            }
 | 
			
		||||
        }, (_, element, __, ___) => {
 | 
			
		||||
            // Need to remove any appended contents, because this is a total hack job.
 | 
			
		||||
            if(element.className !== 'imageBlock')
 | 
			
		||||
                return;
 | 
			
		||||
            while(element.firstChild !== null)
 | 
			
		||||
                element.removeChild(element.firstChild);
 | 
			
		||||
        }, []));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function initCollapse(): void {
 | 
			
		||||
    $('.collapseHeader[data-bound!=true]').each((_, element) => {
 | 
			
		||||
        const $element = $(element);
 | 
			
		||||
        const $body = $element.children('.collapseBlock');
 | 
			
		||||
        $element.children('.collapseHeaderText').on('click', () => {
 | 
			
		||||
            if($element.hasClass('expandedHeader')) {
 | 
			
		||||
                $body.css('max-height', '0');
 | 
			
		||||
                $element.removeClass('expandedHeader');
 | 
			
		||||
            } else {
 | 
			
		||||
                $body.css('max-height', 'none');
 | 
			
		||||
                const height = $body.outerHeight();
 | 
			
		||||
                $body.css('max-height', '0');
 | 
			
		||||
                $element.addClass('expandedHeader');
 | 
			
		||||
                setTimeout(() => $body.css('max-height', height!), 1);
 | 
			
		||||
                setTimeout(() => $body.css('max-height', 'none'), 250);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
    $('.collapseHeader').attr('data-bound', 'true');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export let standardParser: StandardBBCodeParser;
 | 
			
		||||
 | 
			
		||||
export function initParser(settings: StandardParserSettings): void {
 | 
			
		||||
    standardParser = new StandardBBCodeParser(settings);
 | 
			
		||||
}
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <modal :buttons="false" :action="l('chat.channels')">
 | 
			
		||||
    <modal :buttons="false" :action="l('chat.channels')" @close="closed">
 | 
			
		||||
        <div style="display: flex; flex-direction: column;">
 | 
			
		||||
            <ul class="nav nav-tabs">
 | 
			
		||||
                <li role="presentation" :class="{active: !privateTabShown}">
 | 
			
		||||
@ -72,7 +72,7 @@
 | 
			
		||||
        applyFilter(list: {[key: string]: Channel.ListItem | undefined}): ReadonlyArray<Channel.ListItem> {
 | 
			
		||||
            const channels: Channel.ListItem[] = [];
 | 
			
		||||
            if(this.filter.length > 0) {
 | 
			
		||||
                const search = new RegExp(this.filter.replace(/[^\w]/, '\\$&'), 'i');
 | 
			
		||||
                const search = new RegExp(this.filter.replace(/[^\w]/gi, '\\$&'), 'i');
 | 
			
		||||
                //tslint:disable-next-line:forin
 | 
			
		||||
                for(const key in list) {
 | 
			
		||||
                    const item = list[key]!;
 | 
			
		||||
@ -89,6 +89,10 @@
 | 
			
		||||
            this.hide();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        closed(): void {
 | 
			
		||||
            this.createName = '';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        setJoined(channel: ListItem): void {
 | 
			
		||||
            channel.isJoined ? core.channels.leave(channel.id) : core.channels.join(channel.id);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <modal :action="l('characterSearch.action')" @submit.prevent="submit" :disabled="!data.kinks.length"
 | 
			
		||||
    <modal :action="l('characterSearch.action')" @submit.prevent="submit"
 | 
			
		||||
        :buttonText="results ? l('characterSearch.again') : undefined" class="character-search">
 | 
			
		||||
        <div v-if="options && !results">
 | 
			
		||||
            <div v-show="error" class="alert alert-danger">{{error}}</div>
 | 
			
		||||
@ -10,7 +10,6 @@
 | 
			
		||||
            <filterable-select v-for="item in ['genders', 'orientations', 'languages', 'furryprefs', 'roles', 'positions']" :multiple="true"
 | 
			
		||||
                v-model="data[item]" :placeholder="l('filter')" :title="l('characterSearch.' + item)" :options="options[item]" :key="item">
 | 
			
		||||
            </filterable-select>
 | 
			
		||||
            <div v-show="!data.kinks.length" class="alert alert-warning">{{l('characterSearch.kinkNotice')}}</div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div v-else-if="results" class="results">
 | 
			
		||||
            <h4>{{l('characterSearch.results')}}</h4>
 | 
			
		||||
 | 
			
		||||
@ -74,6 +74,7 @@
 | 
			
		||||
            core.connection.onError((e) => {
 | 
			
		||||
                this.error = errorToString(e);
 | 
			
		||||
                this.connecting = false;
 | 
			
		||||
                this.connected = false;
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -85,7 +86,7 @@
 | 
			
		||||
        connect(): void {
 | 
			
		||||
            this.connecting = true;
 | 
			
		||||
            core.connection.connect(this.selectedCharacter).catch((e) => {
 | 
			
		||||
                if(e.request !== undefined) this.error = l('login.connectError'); //catch axios network errors
 | 
			
		||||
                if((<Error & {request?: object}>e).request !== undefined) this.error = l('login.connectError'); //catch axios network errors
 | 
			
		||||
                else throw e;
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div style="height:100%; display: flex; position: relative;" @click="$refs['userMenu'].handleEvent($event)"
 | 
			
		||||
    <div style="height:100%; display: flex; position: relative;" id="chatView" @click="$refs['userMenu'].handleEvent($event)"
 | 
			
		||||
        @contextmenu="$refs['userMenu'].handleEvent($event)" @touchstart="$refs['userMenu'].handleEvent($event)"
 | 
			
		||||
        @touchend="$refs['userMenu'].handleEvent($event)">
 | 
			
		||||
        <div class="sidebar sidebar-left" id="sidebar">
 | 
			
		||||
@ -38,17 +38,18 @@
 | 
			
		||||
                    {{l('chat.pms')}}
 | 
			
		||||
                    <div class="list-group conversation-nav" ref="privateConversations">
 | 
			
		||||
                        <a v-for="conversation in conversations.privateConversations" href="#" @click.prevent="conversation.show()"
 | 
			
		||||
                            :class="getClasses(conversation)"
 | 
			
		||||
                            :class="getClasses(conversation)" :data-character="conversation.character.name"
 | 
			
		||||
                            class="list-group-item list-group-item-action item-private" :key="conversation.key">
 | 
			
		||||
                            <img :src="characterImage(conversation.character.name)" v-if="showAvatars"/>
 | 
			
		||||
                            <div class="name">
 | 
			
		||||
                                <span>{{conversation.character.name}}</span>
 | 
			
		||||
                                <div style="text-align:right;line-height:0">
 | 
			
		||||
                                <span class="fa"
 | 
			
		||||
                                    :class="{'fa-commenting': conversation.typingStatus == 'typing', 'fa-comment': conversation.typingStatus == 'paused'}"
 | 
			
		||||
                                ></span><span class="pin fa fa-thumb-tack" :class="{'active': conversation.isPinned}"
 | 
			
		||||
                                    @click.stop.prevent="conversation.isPinned = !conversation.isPinned" @mousedown.stop.prevent
 | 
			
		||||
                                ></span><span class="fa fa-times leave" @click.stop="conversation.close()"></span>
 | 
			
		||||
                                    <span class="fa"
 | 
			
		||||
                                        :class="{'fa-commenting': conversation.typingStatus == 'typing', 'fa-comment': conversation.typingStatus == 'paused'}"
 | 
			
		||||
                                    ></span><span class="pin fa fa-thumb-tack" :class="{'active': conversation.isPinned}" @mousedown.prevent
 | 
			
		||||
                                    @click.stop="conversation.isPinned = !conversation.isPinned" :aria-label="l('chat.pinTab')"></span>
 | 
			
		||||
                                    <span class="fa fa-times leave" @click.stop="conversation.close()"
 | 
			
		||||
                                        :aria-label="l('chat.closeTab')"></span>
 | 
			
		||||
                                </div>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </a>
 | 
			
		||||
@ -61,8 +62,9 @@
 | 
			
		||||
                        <a v-for="conversation in conversations.channelConversations" href="#" @click.prevent="conversation.show()"
 | 
			
		||||
                            :class="getClasses(conversation)" class="list-group-item list-group-item-action item-channel"
 | 
			
		||||
                            :key="conversation.key"><span class="name">{{conversation.name}}</span><span><span class="pin fa fa-thumb-tack"
 | 
			
		||||
                            :class="{'active': conversation.isPinned}" @click.stop.prevent="conversation.isPinned = !conversation.isPinned"
 | 
			
		||||
                            @mousedown.stop.prevent></span><span class="fa fa-times leave" @click.stop="conversation.close()"></span></span>
 | 
			
		||||
                            :class="{'active': conversation.isPinned}" @click.stop="conversation.isPinned = !conversation.isPinned"
 | 
			
		||||
                            :aria-label="l('chat.pinTab')" @mousedown.prevent></span><span class="fa fa-times leave"
 | 
			
		||||
                            @click.stop="conversation.close()" :aria-label="l('chat.closeTab')"></span></span>
 | 
			
		||||
                        </a>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
@ -102,7 +104,7 @@
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import ChannelList from './ChannelList.vue';
 | 
			
		||||
    import CharacterSearch from './CharacterSearch.vue';
 | 
			
		||||
    import {characterImage} from './common';
 | 
			
		||||
    import {characterImage, getKey} from './common';
 | 
			
		||||
    import ConversationView from './ConversationView.vue';
 | 
			
		||||
    import core from './core';
 | 
			
		||||
    import {Character, Connection, Conversation} from './interfaces';
 | 
			
		||||
@ -134,8 +136,12 @@
 | 
			
		||||
        characterImage = characterImage;
 | 
			
		||||
        conversations = core.conversations;
 | 
			
		||||
        getStatusIcon = getStatusIcon;
 | 
			
		||||
        keydownListener: (e: KeyboardEvent) => void;
 | 
			
		||||
 | 
			
		||||
        mounted(): void {
 | 
			
		||||
            this.keydownListener = (e: KeyboardEvent) => this.onKeyDown(e);
 | 
			
		||||
            document.addEventListener('keydown', this.keydownListener);
 | 
			
		||||
            this.setFontSize(core.state.settings.fontSize);
 | 
			
		||||
            Sortable.create(this.$refs['privateConversations'], {
 | 
			
		||||
                animation: 50,
 | 
			
		||||
                onEnd: (e: {oldIndex: number, newIndex: number}) => core.conversations.privateConversations[e.oldIndex].sort(e.newIndex)
 | 
			
		||||
@ -175,6 +181,67 @@
 | 
			
		||||
                    idleTimer = undefined;
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
            core.watch<number>(function(): number {
 | 
			
		||||
                return this.state.settings.fontSize;
 | 
			
		||||
            }, (value) => {
 | 
			
		||||
                this.setFontSize(value);
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        destroyed(): void {
 | 
			
		||||
            document.removeEventListener('keydown', this.keydownListener);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        onKeyDown(e: KeyboardEvent): void {
 | 
			
		||||
            const selected = this.conversations.selectedConversation;
 | 
			
		||||
            const pms = this.conversations.privateConversations;
 | 
			
		||||
            const channels = this.conversations.channelConversations;
 | 
			
		||||
            const console = this.conversations.consoleTab;
 | 
			
		||||
            if(getKey(e) === 'ArrowUp' && e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
 | 
			
		||||
                if(selected === console) return;
 | 
			
		||||
                if(Conversation.isPrivate(selected)) {
 | 
			
		||||
                    const index = pms.indexOf(selected);
 | 
			
		||||
                    if(index === 0) console.show();
 | 
			
		||||
                    else pms[index - 1].show();
 | 
			
		||||
                } else {
 | 
			
		||||
                    const index = channels.indexOf(<Conversation.ChannelConversation>selected);
 | 
			
		||||
                    if(index === 0)
 | 
			
		||||
                        if(pms.length > 0) pms[pms.length - 1].show();
 | 
			
		||||
                        else console.show();
 | 
			
		||||
                    else channels[index - 1].show();
 | 
			
		||||
                }
 | 
			
		||||
            } else if(getKey(e) === 'ArrowDown' && e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey)
 | 
			
		||||
                if(selected === console) { //tslint:disable-line:curly - false positive
 | 
			
		||||
                    if(pms.length > 0) pms[0].show();
 | 
			
		||||
                    else if(channels.length > 0) channels[0].show();
 | 
			
		||||
                } else if(Conversation.isPrivate(selected)) {
 | 
			
		||||
                    const index = pms.indexOf(selected);
 | 
			
		||||
                    if(index === pms.length - 1) {
 | 
			
		||||
                        if(channels.length > 0) channels[0].show();
 | 
			
		||||
                    } else pms[index + 1].show();
 | 
			
		||||
                } else {
 | 
			
		||||
                    const index = channels.indexOf(<Conversation.ChannelConversation>selected);
 | 
			
		||||
                    if(index !== channels.length - 1) channels[index + 1].show();
 | 
			
		||||
                }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        setFontSize(fontSize: number): void {
 | 
			
		||||
            let overrideEl = <HTMLStyleElement | null>document.getElementById('overrideFontSize');
 | 
			
		||||
            if(overrideEl !== null)
 | 
			
		||||
                document.body.removeChild(overrideEl);
 | 
			
		||||
            overrideEl = document.createElement('style');
 | 
			
		||||
            overrideEl.id = 'overrideFontSize';
 | 
			
		||||
            document.body.appendChild(overrideEl);
 | 
			
		||||
            const sheet = <CSSStyleSheet>overrideEl.sheet;
 | 
			
		||||
            const selectorList = ['#chatView', '.btn', '.form-control'];
 | 
			
		||||
            for(const selector of selectorList)
 | 
			
		||||
                sheet.insertRule(`${selector} { font-size: ${fontSize}px; }`, sheet.cssRules.length);
 | 
			
		||||
 | 
			
		||||
            const lineHeightBase = 1.428571429;
 | 
			
		||||
            const lineHeight = Math.floor(fontSize * 1.428571429);
 | 
			
		||||
            const formHeight = (lineHeight + (6 * 2) + 2);
 | 
			
		||||
            sheet.insertRule(`.form-control { line-height: ${lineHeightBase}; height: ${formHeight}px; }`, sheet.cssRules.length);
 | 
			
		||||
            sheet.insertRule(`select.form-control { line-height: ${lineHeightBase}; height: ${formHeight}px; }`, sheet.cssRules.length);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        logOut(): void {
 | 
			
		||||
 | 
			
		||||
@ -43,7 +43,7 @@
 | 
			
		||||
 | 
			
		||||
        get filteredCommands(): ReadonlyArray<CommandItem> {
 | 
			
		||||
            if(this.filter.length === 0) return this.commands;
 | 
			
		||||
            const filter = new RegExp(this.filter.replace(/[^\w]/, '\\$&'), 'i');
 | 
			
		||||
            const filter = new RegExp(this.filter.replace(/[^\w]/gi, '\\$&'), 'i');
 | 
			
		||||
            return this.commands.filter((x) => filter.test(x.name));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -52,7 +52,8 @@
 | 
			
		||||
            //tslint:disable-next-line:forin
 | 
			
		||||
            for(const key in commands) {
 | 
			
		||||
                const command = commands[key]!;
 | 
			
		||||
                if(command.documented !== undefined || command.permission !== undefined && (command.permission & permissions) === 0) continue;
 | 
			
		||||
                if(command.documented !== undefined ||
 | 
			
		||||
                    command.permission !== undefined && command.permission > 0 && (command.permission & permissions) === 0) continue;
 | 
			
		||||
                const params = [];
 | 
			
		||||
                let syntax = `/${key} `;
 | 
			
		||||
                if(command.params !== undefined)
 | 
			
		||||
 | 
			
		||||
@ -81,7 +81,7 @@
 | 
			
		||||
            </div>
 | 
			
		||||
            <div style="position:relative; margin-top:5px;">
 | 
			
		||||
                <div class="overlay-disable" v-show="adCountdown">{{adCountdown}}</div>
 | 
			
		||||
                <bbcode-editor v-model="conversation.enteredText" @keydown="onKeyDown" @keypress="onKeyPress" :extras="extraButtons"
 | 
			
		||||
                <bbcode-editor v-model="conversation.enteredText" @keydown="onKeyDown" :extras="extraButtons" @input="onInput"
 | 
			
		||||
                    classes="form-control chat-text-box" :disabled="adCountdown" ref="textBox" style="position:relative;"
 | 
			
		||||
                    :maxlength="conversation.maxMessageLength">
 | 
			
		||||
                    <div style="float:right;text-align:right;display:flex;align-items:center">
 | 
			
		||||
@ -197,15 +197,13 @@
 | 
			
		||||
            if(oldValue === 'clear') this.keepScroll();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        onKeyPress(e: KeyboardEvent): void {
 | 
			
		||||
        onInput(): void {
 | 
			
		||||
            const messageView = <HTMLElement>this.$refs['messages'];
 | 
			
		||||
            const oldHeight = messageView.offsetHeight;
 | 
			
		||||
            setTimeout(() => messageView.scrollTop += oldHeight - messageView.offsetHeight);
 | 
			
		||||
            if(getKey(e) === 'Enter') {
 | 
			
		||||
                if(e.shiftKey) return;
 | 
			
		||||
                e.preventDefault();
 | 
			
		||||
                this.conversation.send();
 | 
			
		||||
            }
 | 
			
		||||
            if(messageView.scrollTop + messageView.offsetHeight >= messageView.scrollHeight - 15)
 | 
			
		||||
                setTimeout(() => {
 | 
			
		||||
                    if(oldHeight > messageView.offsetHeight) messageView.scrollTop += oldHeight - messageView.offsetHeight;
 | 
			
		||||
                });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        onKeyDown(e: KeyboardEvent): void {
 | 
			
		||||
@ -222,7 +220,7 @@
 | 
			
		||||
                        selection.text = editor.text.substring(selection.start, selection.end);
 | 
			
		||||
                        if(selection.text.length === 0) return;
 | 
			
		||||
                    }
 | 
			
		||||
                    const search = new RegExp(`^${selection.text.replace(/[^\w]/, '\\$&')}`, 'i');
 | 
			
		||||
                    const search = new RegExp(`^${selection.text.replace(/[^\w]/gi, '\\$&')}`, 'i');
 | 
			
		||||
                    const c = (<Conversation.PrivateConversation>this.conversation);
 | 
			
		||||
                    let options: ReadonlyArray<{character: Character}>;
 | 
			
		||||
                    options = Conversation.isChannel(this.conversation) ? this.conversation.channel.sortedMembers :
 | 
			
		||||
@ -246,6 +244,11 @@
 | 
			
		||||
                if(this.tabOptions !== undefined) this.tabOptions = undefined;
 | 
			
		||||
                if(getKey(e) === 'ArrowUp' && this.conversation.enteredText.length === 0)
 | 
			
		||||
                    this.conversation.loadLastSent();
 | 
			
		||||
                else if(getKey(e) === 'Enter') {
 | 
			
		||||
                    if(e.shiftKey) return;
 | 
			
		||||
                    e.preventDefault();
 | 
			
		||||
                    this.conversation.send();
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -70,7 +70,7 @@
 | 
			
		||||
 | 
			
		||||
        get filteredMessages(): ReadonlyArray<Conversation.Message> {
 | 
			
		||||
            if(this.filter.length === 0) return this.messages;
 | 
			
		||||
            const filter = new RegExp(this.filter, 'i');
 | 
			
		||||
            const filter = new RegExp(this.filter.replace(/[^\w]/gi, '\\$&'), 'i');
 | 
			
		||||
            return this.messages.filter(
 | 
			
		||||
                (x) => filter.test(x.text) || x.type !== Conversation.Message.Type.Event && filter.test(x.sender.name));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <modal :action="l('settings.action')" @submit="submit" @close="init()" id="settings">
 | 
			
		||||
        <ul class="nav nav-tabs">
 | 
			
		||||
        <ul class="nav nav-tabs" style="flex-shrink:0;margin-bottom:10px">
 | 
			
		||||
            <li role="presentation" v-for="tab in tabs" :class="{active: tab == selectedTab}">
 | 
			
		||||
                <a href="#" @click.prevent="selectedTab = tab">{{l('settings.tabs.' + tab)}}</a>
 | 
			
		||||
            </li>
 | 
			
		||||
@ -50,6 +50,10 @@
 | 
			
		||||
                    {{l('settings.logAds')}}
 | 
			
		||||
                </label>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="form-group">
 | 
			
		||||
                <label class="control-label" for="fontSize">{{l('settings.fontSize')}}</label>
 | 
			
		||||
                <input id="fontSize" type="number" min="10" max="24" number class="form-control" v-model="fontSize"/>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div v-show="selectedTab == 'notifications'">
 | 
			
		||||
            <div class="form-group">
 | 
			
		||||
@ -135,6 +139,7 @@
 | 
			
		||||
        alwaysNotify: boolean;
 | 
			
		||||
        logMessages: boolean;
 | 
			
		||||
        logAds: boolean;
 | 
			
		||||
        fontSize: number;
 | 
			
		||||
 | 
			
		||||
        constructor() {
 | 
			
		||||
            super();
 | 
			
		||||
@ -163,6 +168,7 @@
 | 
			
		||||
            this.alwaysNotify = settings.alwaysNotify;
 | 
			
		||||
            this.logMessages = settings.logMessages;
 | 
			
		||||
            this.logAds = settings.logAds;
 | 
			
		||||
            this.fontSize = settings.fontSize;
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        async doImport(): Promise<void> {
 | 
			
		||||
@ -199,7 +205,8 @@
 | 
			
		||||
                joinMessages: this.joinMessages,
 | 
			
		||||
                alwaysNotify: this.alwaysNotify,
 | 
			
		||||
                logMessages: this.logMessages,
 | 
			
		||||
                logAds: this.logAds
 | 
			
		||||
                logAds: this.logAds,
 | 
			
		||||
                fontSize: this.fontSize
 | 
			
		||||
            };
 | 
			
		||||
            if(this.notifications) await requestNotificationsPermission();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -25,7 +25,7 @@
 | 
			
		||||
            </div>
 | 
			
		||||
            <div v-if="channel" v-show="memberTabShown" class="users" style="padding: 5px;">
 | 
			
		||||
                <div v-for="member in channel.sortedMembers" :key="member.character.name">
 | 
			
		||||
                    <user :character="member.character" :channel="channel"></user>
 | 
			
		||||
                    <user :character="member.character" :channel="channel" :showStatus="true"></user>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
@ -146,19 +146,21 @@
 | 
			
		||||
 | 
			
		||||
        handleEvent(e: MouseEvent | TouchEvent): void {
 | 
			
		||||
            const touch = e instanceof TouchEvent ? e.changedTouches[0] : e;
 | 
			
		||||
            let node = <Node & {character?: Character, channel?: Channel}>touch.target;
 | 
			
		||||
            let node = <HTMLElement & {character?: Character, channel?: Channel}>touch.target;
 | 
			
		||||
            while(node !== document.body) {
 | 
			
		||||
                if(e.type === 'touchstart' && node === this.$refs['menu']) return;
 | 
			
		||||
                if(node.character !== undefined || node.parentNode === null) break;
 | 
			
		||||
                node = node.parentNode;
 | 
			
		||||
            }
 | 
			
		||||
            if(node.character === undefined) {
 | 
			
		||||
                this.showContextMenu = false;
 | 
			
		||||
                return;
 | 
			
		||||
                if(e.type !== 'click' && node === this.$refs['menu']) return;
 | 
			
		||||
                if(node.character !== undefined || node.dataset['character'] !== undefined || node.parentNode === null) break;
 | 
			
		||||
                node = node.parentElement!;
 | 
			
		||||
            }
 | 
			
		||||
            if(node.character === undefined)
 | 
			
		||||
                if(node.dataset['character'] !== undefined) node.character = core.characters.get(node.dataset['character']!);
 | 
			
		||||
                else {
 | 
			
		||||
                    this.showContextMenu = false;
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
            switch(e.type) {
 | 
			
		||||
                case 'click':
 | 
			
		||||
                    this.onClick(node.character);
 | 
			
		||||
                    if(node.dataset['character'] === undefined) this.onClick(node.character);
 | 
			
		||||
                    break;
 | 
			
		||||
                case 'touchstart':
 | 
			
		||||
                    this.touchTimer = window.setTimeout(() => {
 | 
			
		||||
@ -170,7 +172,7 @@
 | 
			
		||||
                    if(this.touchTimer !== undefined) {
 | 
			
		||||
                        clearTimeout(this.touchTimer);
 | 
			
		||||
                        this.touchTimer = undefined;
 | 
			
		||||
                        this.onClick(node.character);
 | 
			
		||||
                        if(node.dataset['character'] === undefined) this.onClick(node.character);
 | 
			
		||||
                    }
 | 
			
		||||
                    break;
 | 
			
		||||
                case 'contextmenu':
 | 
			
		||||
 | 
			
		||||
@ -72,7 +72,7 @@ export default class BBCodeParser extends CoreBBCodeParser {
 | 
			
		||||
            const img = parser.createElement('img');
 | 
			
		||||
            img.src = characterImage(content);
 | 
			
		||||
            img.style.cursor = 'pointer';
 | 
			
		||||
            img.className = 'characterAvatarIcon';
 | 
			
		||||
            img.className = 'character-avatar icon';
 | 
			
		||||
            img.title = img.alt = content;
 | 
			
		||||
            (<HTMLImageElement & {character: Character}>img).character = core.characters.get(content);
 | 
			
		||||
            parent.replaceChild(img, element);
 | 
			
		||||
@ -92,7 +92,7 @@ export default class BBCodeParser extends CoreBBCodeParser {
 | 
			
		||||
            const img = parser.createElement('img');
 | 
			
		||||
            img.src = `https://static.f-list.net/images/eicon/${content.toLowerCase()}.${extension}`;
 | 
			
		||||
            img.title = img.alt = content;
 | 
			
		||||
            img.className = 'characterAvatarIcon';
 | 
			
		||||
            img.className = 'character-avatar icon';
 | 
			
		||||
            parent.replaceChild(img, element);
 | 
			
		||||
        }, []));
 | 
			
		||||
        this.addTag('session', new BBCodeCustomTag('session', (parser, parent) => {
 | 
			
		||||
 | 
			
		||||
@ -39,6 +39,7 @@ export class Settings implements ISettings {
 | 
			
		||||
    alwaysNotify = false;
 | 
			
		||||
    logMessages = true;
 | 
			
		||||
    logAds = false;
 | 
			
		||||
    fontSize = 14;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class ConversationSettings implements Conversation.Settings {
 | 
			
		||||
@ -64,7 +65,7 @@ export function messageToString(this: void | never, msg: Conversation.Message, t
 | 
			
		||||
 | 
			
		||||
export function getKey(e: KeyboardEvent): string {
 | 
			
		||||
    /*tslint:disable-next-line:strict-boolean-expressions no-any*///because of old browsers.
 | 
			
		||||
    return e.key || (<any>e).keyIdentifier;
 | 
			
		||||
    return e.key || (<KeyboardEvent & {keyIdentifier: string}>e).keyIdentifier;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*tslint:disable:no-any no-unsafe-any*///because errors can be any
 | 
			
		||||
 | 
			
		||||
@ -148,7 +148,7 @@ class PrivateConversation extends Conversation implements Interfaces.PrivateConv
 | 
			
		||||
        this.safeAddMessage(message);
 | 
			
		||||
        if(message.type !== Interfaces.Message.Type.Event) {
 | 
			
		||||
            if(core.state.settings.logMessages) this.logPromise.then(() => core.logs.logMessage(this, message));
 | 
			
		||||
            if(this.settings.notify !== Interfaces.Setting.False)
 | 
			
		||||
            if(this.settings.notify !== Interfaces.Setting.False && message.sender !== core.characters.ownCharacter)
 | 
			
		||||
                core.notifications.notify(this, message.sender.name, message.text, characterImage(message.sender.name), 'attention');
 | 
			
		||||
            if(this !== state.selectedConversation)
 | 
			
		||||
                this.unread = Interfaces.UnreadState.Mention;
 | 
			
		||||
@ -214,7 +214,7 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
 | 
			
		||||
        core.watch<Channel.Mode | undefined>(function(): Channel.Mode | undefined {
 | 
			
		||||
            const c = this.channels.getChannel(channel.id);
 | 
			
		||||
            return c !== undefined ? c.mode : undefined;
 | 
			
		||||
        }, (value) => {
 | 
			
		||||
        }, (value: Channel.Mode | undefined) => {
 | 
			
		||||
            if(value === undefined) return;
 | 
			
		||||
            this.mode = value;
 | 
			
		||||
            if(value !== 'both') this.isSendingAds = value === 'ads';
 | 
			
		||||
@ -256,9 +256,11 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    addMessage(message: Interfaces.Message): void {
 | 
			
		||||
        if((message.type === MessageType.Message || message.type === MessageType.Ad) && message.text.match(/^\/warn\b/) !== null
 | 
			
		||||
            && (this.channel.members[message.sender.name]!.rank > Channel.Rank.Member || message.sender.isChatOp))
 | 
			
		||||
            message = new Message(MessageType.Warn, message.sender, message.text.substr(6), message.time);
 | 
			
		||||
        if((message.type === MessageType.Message || message.type === MessageType.Ad) && message.text.match(/^\/warn\b/) !== null) {
 | 
			
		||||
            const member = this.channel.members[message.sender.name];
 | 
			
		||||
            if(member !== undefined && member.rank > Channel.Rank.Member || message.sender.isChatOp)
 | 
			
		||||
                message = new Message(MessageType.Warn, message.sender, message.text.substr(6), message.time);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if(message.type === MessageType.Ad) {
 | 
			
		||||
            this.addModeMessage('ads', message);
 | 
			
		||||
@ -365,19 +367,18 @@ class State implements Interfaces.State {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    addRecent(conversation: Conversation): void {
 | 
			
		||||
        /*tslint:disable-next-line:no-any*///TS isn't smart enough for this
 | 
			
		||||
        const remove = (predicate: (item: any) => boolean) => {
 | 
			
		||||
        const remove = <T extends Interfaces.RecentConversation>(predicate: (item: T) => boolean) => {
 | 
			
		||||
            for(let i = 0; i < this.recent.length; ++i)
 | 
			
		||||
                if(predicate(this.recent[i])) {
 | 
			
		||||
                if(predicate(<T>this.recent[i])) {
 | 
			
		||||
                    this.recent.splice(i, 1);
 | 
			
		||||
                    break;
 | 
			
		||||
                }
 | 
			
		||||
        };
 | 
			
		||||
        if(Interfaces.isChannel(conversation)) {
 | 
			
		||||
            remove((c) => c.channel === conversation.channel.id);
 | 
			
		||||
            remove<Interfaces.RecentChannelConversation>((c) => c.channel === conversation.channel.id);
 | 
			
		||||
            this.recent.unshift({channel: conversation.channel.id, name: conversation.channel.name});
 | 
			
		||||
        } else {
 | 
			
		||||
            remove((c) => c.character === conversation.name);
 | 
			
		||||
            remove<Interfaces.RecentPrivateConversation>((c) => c.character === conversation.name);
 | 
			
		||||
            state.recent.unshift({character: conversation.name});
 | 
			
		||||
        }
 | 
			
		||||
        if(this.recent.length >= 50) this.recent.pop();
 | 
			
		||||
@ -430,7 +431,11 @@ export default function(this: void): Interfaces.State {
 | 
			
		||||
    connection.onEvent('connecting', async(isReconnect) => {
 | 
			
		||||
        state.channelConversations = [];
 | 
			
		||||
        state.channelMap = {};
 | 
			
		||||
        if(!isReconnect) state.consoleTab = new ConsoleConversation();
 | 
			
		||||
        if(!isReconnect) {
 | 
			
		||||
            state.consoleTab = new ConsoleConversation();
 | 
			
		||||
            state.privateConversations = [];
 | 
			
		||||
            state.privateMap = {};
 | 
			
		||||
        } else state.consoleTab.unread = Interfaces.UnreadState.None;
 | 
			
		||||
        state.selectedConversation = state.consoleTab;
 | 
			
		||||
        await state.reloadSettings();
 | 
			
		||||
    });
 | 
			
		||||
@ -440,11 +445,10 @@ export default function(this: void): Interfaces.State {
 | 
			
		||||
        queuedJoin(state.pinned.channels.slice());
 | 
			
		||||
    });
 | 
			
		||||
    core.channels.onEvent((type, channel, member) => {
 | 
			
		||||
        const key = channel.id.toLowerCase();
 | 
			
		||||
        if(type === 'join')
 | 
			
		||||
            if(member === undefined) {
 | 
			
		||||
                const conv = new ChannelConversation(channel);
 | 
			
		||||
                state.channelMap[key] = conv;
 | 
			
		||||
                state.channelMap[channel.id] = conv;
 | 
			
		||||
                state.channelConversations.push(conv);
 | 
			
		||||
                state.addRecent(conv);
 | 
			
		||||
            } else {
 | 
			
		||||
@ -455,9 +459,9 @@ export default function(this: void): Interfaces.State {
 | 
			
		||||
                conv.addMessage(new EventMessage(text));
 | 
			
		||||
            }
 | 
			
		||||
        else if(member === undefined) {
 | 
			
		||||
            const conv = state.channelMap[key]!;
 | 
			
		||||
            const conv = state.channelMap[channel.id]!;
 | 
			
		||||
            state.channelConversations.splice(state.channelConversations.indexOf(conv), 1);
 | 
			
		||||
            delete state.channelMap[key];
 | 
			
		||||
            delete state.channelMap[channel.id];
 | 
			
		||||
            state.savePinned();
 | 
			
		||||
            if(state.selectedConversation === conv) state.show(state.consoleTab);
 | 
			
		||||
        } else {
 | 
			
		||||
@ -479,7 +483,8 @@ export default function(this: void): Interfaces.State {
 | 
			
		||||
    connection.onMessage('MSG', (data, time) => {
 | 
			
		||||
        const char = core.characters.get(data.character);
 | 
			
		||||
        if(char.isIgnored) return;
 | 
			
		||||
        const conversation = state.channelMap[data.channel.toLowerCase()]!;
 | 
			
		||||
        const conversation = state.channelMap[data.channel.toLowerCase()];
 | 
			
		||||
        if(conversation === undefined) return core.channels.leave(data.channel);
 | 
			
		||||
        const message = createMessage(MessageType.Message, char, decodeHTML(data.message), time);
 | 
			
		||||
        conversation.addMessage(message);
 | 
			
		||||
 | 
			
		||||
@ -501,7 +506,8 @@ export default function(this: void): Interfaces.State {
 | 
			
		||||
    connection.onMessage('LRP', (data, time) => {
 | 
			
		||||
        const char = core.characters.get(data.character);
 | 
			
		||||
        if(char.isIgnored) return;
 | 
			
		||||
        const conv = state.channelMap[data.channel.toLowerCase()]!;
 | 
			
		||||
        const conv = state.channelMap[data.channel.toLowerCase()];
 | 
			
		||||
        if(conv === undefined) return core.channels.leave(data.channel);
 | 
			
		||||
        conv.addMessage(new Message(MessageType.Ad, char, decodeHTML(data.message), time));
 | 
			
		||||
    });
 | 
			
		||||
    connection.onMessage('RLL', (data, time) => {
 | 
			
		||||
@ -516,7 +522,9 @@ export default function(this: void): Interfaces.State {
 | 
			
		||||
        }
 | 
			
		||||
        const message = new Message(MessageType.Roll, sender, text, time);
 | 
			
		||||
        if('channel' in data) {
 | 
			
		||||
            const conversation = state.channelMap[(<{channel: string}>data).channel.toLowerCase()]!;
 | 
			
		||||
            const channel = (<{channel: string}>data).channel.toLowerCase();
 | 
			
		||||
            const conversation = state.channelMap[channel];
 | 
			
		||||
            if(conversation === undefined) return core.channels.leave(channel);
 | 
			
		||||
            conversation.addMessage(message);
 | 
			
		||||
            if(data.type === 'bottle' && data.target === core.connection.character)
 | 
			
		||||
                core.notifications.notify(conversation, conversation.name, messageToString(message),
 | 
			
		||||
@ -549,17 +557,23 @@ export default function(this: void): Interfaces.State {
 | 
			
		||||
    });
 | 
			
		||||
    connection.onMessage('CBU', (data, time) => {
 | 
			
		||||
        const text = l('events.ban', data.channel, data.character, data.operator);
 | 
			
		||||
        state.channelMap[data.channel.toLowerCase()]!.infoText = text;
 | 
			
		||||
        const conv = state.channelMap[data.channel.toLowerCase()];
 | 
			
		||||
        if(conv === undefined) return core.channels.leave(data.channel);
 | 
			
		||||
        conv.infoText = text;
 | 
			
		||||
        addEventMessage(new EventMessage(text, time));
 | 
			
		||||
    });
 | 
			
		||||
    connection.onMessage('CKU', (data, time) => {
 | 
			
		||||
        const text = l('events.kick', data.channel, data.character, data.operator);
 | 
			
		||||
        state.channelMap[data.channel.toLowerCase()]!.infoText = text;
 | 
			
		||||
        const conv = state.channelMap[data.channel.toLowerCase()];
 | 
			
		||||
        if(conv === undefined) return core.channels.leave(data.channel);
 | 
			
		||||
        conv.infoText = text;
 | 
			
		||||
        addEventMessage(new EventMessage(text, time));
 | 
			
		||||
    });
 | 
			
		||||
    connection.onMessage('CTU', (data, time) => {
 | 
			
		||||
        const text = l('events.timeout', data.channel, data.character, data.operator, data.length.toString());
 | 
			
		||||
        state.channelMap[data.channel.toLowerCase()]!.infoText = text;
 | 
			
		||||
        const conv = state.channelMap[data.channel.toLowerCase()];
 | 
			
		||||
        if(conv === undefined) return core.channels.leave(data.channel);
 | 
			
		||||
        conv.infoText = text;
 | 
			
		||||
        addEventMessage(new EventMessage(text, time));
 | 
			
		||||
    });
 | 
			
		||||
    connection.onMessage('HLO', (data, time) => addEventMessage(new EventMessage(data.message, time)));
 | 
			
		||||
 | 
			
		||||
@ -41,7 +41,9 @@ export namespace Conversation {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    export type RecentConversation = {readonly channel: string, readonly name: string} | {readonly character: string};
 | 
			
		||||
    export type RecentChannelConversation = {readonly channel: string, readonly name: string};
 | 
			
		||||
    export type RecentPrivateConversation = {readonly character: string};
 | 
			
		||||
    export type RecentConversation = RecentChannelConversation | RecentPrivateConversation;
 | 
			
		||||
 | 
			
		||||
    export type TypingStatus = 'typing' | 'paused' | 'clear';
 | 
			
		||||
 | 
			
		||||
@ -164,6 +166,7 @@ export namespace Settings {
 | 
			
		||||
        readonly alwaysNotify: boolean;
 | 
			
		||||
        readonly logMessages: boolean;
 | 
			
		||||
        readonly logAds: boolean;
 | 
			
		||||
        readonly fontSize: number;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -14,7 +14,9 @@ const strings: {[key: string]: string | undefined} = {
 | 
			
		||||
    'action.cancel': 'Cancel',
 | 
			
		||||
    'consoleWarning.head': 'THIS IS THE DANGER ZONE.',
 | 
			
		||||
    'consoleWarning.body': `ANYTHING YOU WRITE OR PASTE IN HERE COULD BE USED TO STEAL YOUR PASSWORDS OR TAKE OVER YOUR ENTIRE COMPUTER. This is where happiness goes to die. If you aren't a developer or a special kind of daredevil, please get out of here!`,
 | 
			
		||||
    'help': 'Help',
 | 
			
		||||
    'help.fchat': 'FChat 3.0 Help and Changelog',
 | 
			
		||||
    'help.feedback': 'Report a Bug / Suggest Something',
 | 
			
		||||
    'help.rules': 'F-List Rules',
 | 
			
		||||
    'help.faq': 'F-List FAQ',
 | 
			
		||||
    'help.report': 'How to report a user',
 | 
			
		||||
@ -47,6 +49,8 @@ const strings: {[key: string]: string | undefined} = {
 | 
			
		||||
    'chat.channels': 'Channels',
 | 
			
		||||
    'chat.pms': 'PMs',
 | 
			
		||||
    'chat.consoleTab': 'Console',
 | 
			
		||||
    'chat.pinTab': 'Pin this tab',
 | 
			
		||||
    'chat.closeTab': 'Close this tab',
 | 
			
		||||
    'chat.confirmLeave': 'You are still connected to chat. Would you like to disconnect?',
 | 
			
		||||
    'chat.highlight': 'mentioned {0} in {1}:\n{2}',
 | 
			
		||||
    'chat.roll': 'rolls {0}: {1}',
 | 
			
		||||
@ -122,17 +126,19 @@ Are you sure?`,
 | 
			
		||||
    'settings.animatedEicons': 'Animate [eicon]s',
 | 
			
		||||
    'settings.idleTimer': 'Idle timer (minutes, clear to disable)',
 | 
			
		||||
    'settings.messageSeparators': 'Display separators between messages',
 | 
			
		||||
    'settings.eventMessages': 'Also display console messages in current tab',
 | 
			
		||||
    'settings.eventMessages': 'Also display console messages (like login/logout, status changes) in current tab',
 | 
			
		||||
    'settings.joinMessages': 'Display join/leave messages in channels',
 | 
			
		||||
    'settings.alwaysNotify': 'Always notify for PMs/highlights, even when looking at the tab',
 | 
			
		||||
    'settings.closeToTray': 'Close to tray',
 | 
			
		||||
    'settings.spellcheck': 'Spellcheck',
 | 
			
		||||
    'settings.spellcheck.disabled': 'Disabled',
 | 
			
		||||
    'settings.theme': 'Theme',
 | 
			
		||||
    'settings.profileViewer': 'Use profile viewer',
 | 
			
		||||
    'settings.logMessages': 'Log messages',
 | 
			
		||||
    'settings.logAds': 'Log ads',
 | 
			
		||||
    'settings.fontSize': 'Font size (experimental)',
 | 
			
		||||
    'settings.defaultHighlights': 'Use global highlight words',
 | 
			
		||||
    'conversationSettings.title': 'Settings',
 | 
			
		||||
    'conversationSettings.title': 'Tab Settings',
 | 
			
		||||
    'conversationSettings.action': 'Edit settings for {0}',
 | 
			
		||||
    'conversationSettings.default': 'Default',
 | 
			
		||||
    'conversationSettings.true': 'Yes',
 | 
			
		||||
@ -157,7 +163,6 @@ Are you sure?`,
 | 
			
		||||
    'characterSearch.again': 'Start another search',
 | 
			
		||||
    'characterSearch.results': 'Results',
 | 
			
		||||
    'characterSearch.kinks': 'Kinks',
 | 
			
		||||
    'characterSearch.kinkNotice': 'Must select at least one kink.',
 | 
			
		||||
    'characterSearch.genders': 'Genders',
 | 
			
		||||
    'characterSearch.orientations': 'Orientations',
 | 
			
		||||
    'characterSearch.languages': 'Languages',
 | 
			
		||||
@ -350,7 +355,8 @@ Are you sure?`,
 | 
			
		||||
    'importer.importGeneral': 'slimCat data has been detected on your computer.\nWould you like to import general settings?',
 | 
			
		||||
    'importer.importCharacter': 'slimCat data for this character has been detected on your computer.\nWould you like to import settings and logs?\nThis may take a while.\nAny existing FChat 3.0 data for this character will be overwritten.',
 | 
			
		||||
    'importer.importing': 'Importing data',
 | 
			
		||||
    'importer.importingNote': 'Importing logs. This may take a couple of minutes. Please do not close the application, even if it appears to hang for a while, as you may end up with incomplete logs.'
 | 
			
		||||
    'importer.importingNote': 'Importing logs. This may take a couple of minutes. Please do not close the application, even if it appears to hang for a while, as you may end up with incomplete logs.',
 | 
			
		||||
    'importer.error': 'There was an error importing your settings. The defaults will be used.'
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default function l(key: string, ...args: string[]): string {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										172
									
								
								chat/profile_api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								chat/profile_api.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,172 @@
 | 
			
		||||
import Axios from 'axios';
 | 
			
		||||
import Vue from 'vue';
 | 
			
		||||
import {InlineDisplayMode} from '../bbcode/interfaces';
 | 
			
		||||
import {initParser, standardParser} from '../bbcode/standard';
 | 
			
		||||
import {registerMethod, Store} from '../site/character_page/data_store';
 | 
			
		||||
import {
 | 
			
		||||
    Character, CharacterCustom, CharacterFriend, CharacterImage, CharacterImageOld, CharacterInfo, CharacterInfotag, CharacterSettings,
 | 
			
		||||
    GuestbookState, KinkChoiceFull, SharedKinks
 | 
			
		||||
} from '../site/character_page/interfaces';
 | 
			
		||||
import '../site/directives/vue-select'; //tslint:disable-line:no-import-side-effect
 | 
			
		||||
import * as Utils from '../site/utils';
 | 
			
		||||
import core from './core';
 | 
			
		||||
 | 
			
		||||
async function characterData(name: string | undefined): Promise<Character> {
 | 
			
		||||
    const data = await core.connection.queryApi('character-data.php', {name}) as CharacterInfo & {
 | 
			
		||||
        badges: string[]
 | 
			
		||||
        customs_first: boolean
 | 
			
		||||
        custom_kinks: {[key: number]: {choice: 'fave' | 'yes' | 'maybe' | 'no', name: string, description: string, children: number[]}}
 | 
			
		||||
        custom_title: string
 | 
			
		||||
        infotags: {[key: string]: string}
 | 
			
		||||
        settings: CharacterSettings
 | 
			
		||||
    };
 | 
			
		||||
    const newKinks: {[key: string]: KinkChoiceFull} = {};
 | 
			
		||||
    for(const key in data.kinks)
 | 
			
		||||
        newKinks[key] = <KinkChoiceFull>(data.kinks[key] === 'fave' ? 'favorite' : data.kinks[key]);
 | 
			
		||||
    const newCustoms: CharacterCustom[] = [];
 | 
			
		||||
    for(const key in data.custom_kinks) {
 | 
			
		||||
        const custom = data.custom_kinks[key];
 | 
			
		||||
        newCustoms.push({
 | 
			
		||||
            id: parseInt(key, 10),
 | 
			
		||||
            choice: custom.choice === 'fave' ? 'favorite' : custom.choice,
 | 
			
		||||
            name: custom.name,
 | 
			
		||||
            description: custom.description
 | 
			
		||||
        });
 | 
			
		||||
        for(const childId of custom.children)
 | 
			
		||||
            if(data.kinks[childId] !== undefined)
 | 
			
		||||
                newKinks[childId] = parseInt(key, 10);
 | 
			
		||||
    }
 | 
			
		||||
    const newInfotags: {[key: string]: CharacterInfotag} = {};
 | 
			
		||||
    for(const key in data.infotags) {
 | 
			
		||||
        const characterInfotag = data.infotags[key];
 | 
			
		||||
        const infotag = Store.kinks.infotags[key];
 | 
			
		||||
        if(infotag === undefined) continue;
 | 
			
		||||
 | 
			
		||||
        newInfotags[key] = infotag.type === 'list' ? {list: parseInt(characterInfotag, 10)} : {string: characterInfotag};
 | 
			
		||||
    }
 | 
			
		||||
    return {
 | 
			
		||||
        is_self: false,
 | 
			
		||||
        character: {
 | 
			
		||||
            id: data.id,
 | 
			
		||||
            name: data.name,
 | 
			
		||||
            title: data.custom_title,
 | 
			
		||||
            description: data.description,
 | 
			
		||||
            created_at: data.created_at,
 | 
			
		||||
            updated_at: data.updated_at,
 | 
			
		||||
            views: data.views,
 | 
			
		||||
            image_count: data.images!.length,
 | 
			
		||||
            inlines: data.inlines,
 | 
			
		||||
            kinks: newKinks,
 | 
			
		||||
            customs: newCustoms,
 | 
			
		||||
            infotags: newInfotags,
 | 
			
		||||
            online_chat: false
 | 
			
		||||
        },
 | 
			
		||||
        badges: data.badges,
 | 
			
		||||
        settings: data.settings,
 | 
			
		||||
        bookmarked: false,
 | 
			
		||||
        self_staff: false
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function contactMethodIconUrl(name: string): string {
 | 
			
		||||
    return `${Utils.staticDomain}images/social/${name}.png`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function fieldsGet(): Promise<void> {
 | 
			
		||||
    if(Store.kinks !== undefined) return; //tslint:disable-line:strict-type-predicates
 | 
			
		||||
    try {
 | 
			
		||||
        const fields = (await(Axios.get(`${Utils.siteDomain}json/api/mapping-list.php`))).data as SharedKinks & {
 | 
			
		||||
            kinks: {[key: string]: {group_id: number}}
 | 
			
		||||
            infotags: {[key: string]: {list: string, group_id: string}}
 | 
			
		||||
        };
 | 
			
		||||
        const kinks: SharedKinks = {kinks: {}, kink_groups: {}, infotags: {}, infotag_groups: {}, listitems: {}};
 | 
			
		||||
        for(const id in fields.kinks) {
 | 
			
		||||
            const oldKink = fields.kinks[id];
 | 
			
		||||
            kinks.kinks[oldKink.id] = {
 | 
			
		||||
                id: oldKink.id,
 | 
			
		||||
                name: oldKink.name,
 | 
			
		||||
                description: oldKink.description,
 | 
			
		||||
                kink_group: oldKink.group_id
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
        for(const id in fields.kink_groups) {
 | 
			
		||||
            const oldGroup = fields.kink_groups[id]!;
 | 
			
		||||
            kinks.kink_groups[oldGroup.id] = {
 | 
			
		||||
                id: oldGroup.id,
 | 
			
		||||
                name: oldGroup.name,
 | 
			
		||||
                description: '',
 | 
			
		||||
                sort_order: oldGroup.id
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
        for(const id in fields.infotags) {
 | 
			
		||||
            const oldInfotag = fields.infotags[id];
 | 
			
		||||
            kinks.infotags[oldInfotag.id] = {
 | 
			
		||||
                id: oldInfotag.id,
 | 
			
		||||
                name: oldInfotag.name,
 | 
			
		||||
                type: oldInfotag.type,
 | 
			
		||||
                validator: oldInfotag.list,
 | 
			
		||||
                search_field: '',
 | 
			
		||||
                allow_legacy: true,
 | 
			
		||||
                infotag_group: parseInt(oldInfotag.group_id, 10)
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
        for(const id in fields.listitems) {
 | 
			
		||||
            const oldListItem = fields.listitems[id]!;
 | 
			
		||||
            kinks.listitems[oldListItem.id] = {
 | 
			
		||||
                id: oldListItem.id,
 | 
			
		||||
                name: oldListItem.name,
 | 
			
		||||
                value: oldListItem.value,
 | 
			
		||||
                sort_order: oldListItem.id
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
        for(const id in fields.infotag_groups) {
 | 
			
		||||
            const oldGroup = fields.infotag_groups[id]!;
 | 
			
		||||
            kinks.infotag_groups[oldGroup.id] = {
 | 
			
		||||
                id: oldGroup.id,
 | 
			
		||||
                name: oldGroup.name,
 | 
			
		||||
                description: oldGroup.description,
 | 
			
		||||
                sort_order: oldGroup.id
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
        Store.kinks = kinks;
 | 
			
		||||
    } catch(e) {
 | 
			
		||||
        Utils.ajaxError(e, 'Error loading character fields');
 | 
			
		||||
        throw e;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function friendsGet(id: number): Promise<CharacterFriend[]> {
 | 
			
		||||
    return (await core.connection.queryApi<{friends: CharacterFriend[]}>('character-friends.php', {id})).friends;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function imagesGet(id: number): Promise<CharacterImage[]> {
 | 
			
		||||
    return (await core.connection.queryApi<{images: CharacterImage[]}>('character-images.php', {id})).images;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function guestbookGet(id: number, page: number): Promise<GuestbookState> {
 | 
			
		||||
    return core.connection.queryApi<GuestbookState>('character-guestbook.php', {id, page: page - 1});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function init(): void {
 | 
			
		||||
    Utils.setDomains('https://www.f-list.net/', 'https://static.f-list.net/');
 | 
			
		||||
    initParser({
 | 
			
		||||
        siteDomain: Utils.siteDomain,
 | 
			
		||||
        staticDomain: Utils.staticDomain,
 | 
			
		||||
        animatedIcons: false,
 | 
			
		||||
        inlineDisplayMode: InlineDisplayMode.DISPLAY_ALL
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    Vue.directive('bbcode', (el, binding) => {
 | 
			
		||||
        while(el.firstChild !== null)
 | 
			
		||||
            el.removeChild(el.firstChild);
 | 
			
		||||
        el.appendChild(standardParser.parseEverything(<string>binding.value));
 | 
			
		||||
    });
 | 
			
		||||
    registerMethod('characterData', characterData);
 | 
			
		||||
    registerMethod('contactMethodIconUrl', contactMethodIconUrl);
 | 
			
		||||
    registerMethod('fieldsGet', fieldsGet);
 | 
			
		||||
    registerMethod('friendsGet', friendsGet);
 | 
			
		||||
    registerMethod('imagesGet', imagesGet);
 | 
			
		||||
    registerMethod('guestbookPageGet', guestbookGet);
 | 
			
		||||
    registerMethod('imageUrl', (image: CharacterImageOld) => image.url);
 | 
			
		||||
    registerMethod('imageThumbUrl', (image: CharacterImage) => `${Utils.staticDomain}images/charthumb/${image.id}.${image.extension}`);
 | 
			
		||||
}
 | 
			
		||||
@ -21,7 +21,7 @@ export function getStatusIcon(status: Character.Status): string {
 | 
			
		||||
        case 'busy':
 | 
			
		||||
            return 'fa-cog';
 | 
			
		||||
        case 'idle':
 | 
			
		||||
            return 'fa-hourglass';
 | 
			
		||||
            return 'fa-clock-o';
 | 
			
		||||
        case 'crown':
 | 
			
		||||
            return 'fa-birthday-cake';
 | 
			
		||||
    }
 | 
			
		||||
@ -40,7 +40,7 @@ const UserView = Vue.extend({
 | 
			
		||||
            const member = props.channel.members[character.name];
 | 
			
		||||
            if(member !== undefined)
 | 
			
		||||
                rankIcon = member.rank === Channel.Rank.Owner ? 'fa-asterisk' :
 | 
			
		||||
                    member.rank === Channel.Rank.Op ? (props.channel.id.substr(0, 4) === 'adh-' ? 'fa-at' : 'fa-play') : '';
 | 
			
		||||
                    member.rank === Channel.Rank.Op ? (props.channel.id.substr(0, 4) === 'adh-' ? 'fa-at' : 'fa-star') : '';
 | 
			
		||||
            else rankIcon = '';
 | 
			
		||||
        } else rankIcon = '';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -3,6 +3,7 @@ import Vue from 'vue';
 | 
			
		||||
 | 
			
		||||
/*tslint:disable:no-unsafe-any no-any*///hack
 | 
			
		||||
function formatComponentName(vm: any): string {
 | 
			
		||||
    if(vm === undefined) return 'undefined';
 | 
			
		||||
    if(vm.$root === vm) return '<root instance>';
 | 
			
		||||
    const name = vm._isVue
 | 
			
		||||
        ? vm.$options.name || vm.$options._componentTag
 | 
			
		||||
 | 
			
		||||
@ -85,7 +85,7 @@
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get filterRegex(): RegExp {
 | 
			
		||||
            return new RegExp(this.filter.replace(/[^\w]/, '\\$&'), 'i');
 | 
			
		||||
            return new RegExp(this.filter.replace(/[^\w]/gi, '\\$&'), 'i');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
@ -7,7 +7,7 @@
 | 
			
		||||
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">×</button>
 | 
			
		||||
                    <h4 class="modal-title">{{action}}</h4>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="modal-body form-horizontal" style="overflow: auto; display: flex; flex-direction: column">
 | 
			
		||||
                <div class="modal-body" style="overflow: auto; display: flex; flex-direction: column">
 | 
			
		||||
                    <slot></slot>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="modal-footer" v-if="buttons">
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										35
									
								
								components/character_link.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								components/character_link.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,35 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <span :class="linkClasses" v-if="character">
 | 
			
		||||
        <slot v-if="deleted">[Deleted] {{ name }}</slot>
 | 
			
		||||
        <a :href="characterUrl" class="characterLinkLink" v-else><slot>{{ name }}</slot></a>
 | 
			
		||||
    </span>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import Vue from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import {Prop} from 'vue-property-decorator';
 | 
			
		||||
    import * as Utils from '../site/utils';
 | 
			
		||||
 | 
			
		||||
    @Component
 | 
			
		||||
    export default class CharacterLink extends Vue {
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        readonly character: {name: string, id: number, deleted: boolean} | string;
 | 
			
		||||
 | 
			
		||||
        get deleted(): boolean {
 | 
			
		||||
            return typeof(this.character) === 'string' ? false : this.character.deleted;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get linkClasses(): string {
 | 
			
		||||
            return this.deleted ? 'characterLinkDeleted' : 'characterLink';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get characterUrl(): string {
 | 
			
		||||
            return Utils.characterURL(this.name);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get name(): string {
 | 
			
		||||
            return typeof(this.character) === 'string' ? this.character : this.character.name;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										35
									
								
								components/date_display.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								components/date_display.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,35 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <span class="localizable-date" :title="secondary">{{primary}}</span>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import {distanceInWordsToNow, format} from 'date-fns';
 | 
			
		||||
    import Vue, {ComponentOptions} from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import {Prop} from 'vue-property-decorator';
 | 
			
		||||
    import {Settings} from '../site/utils';
 | 
			
		||||
 | 
			
		||||
    @Component
 | 
			
		||||
    export default class DateDisplay extends Vue {
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        readonly time: string | null | number;
 | 
			
		||||
        primary: string;
 | 
			
		||||
        secondary: string;
 | 
			
		||||
 | 
			
		||||
        constructor(options?: ComponentOptions<Vue>) {
 | 
			
		||||
            super(options);
 | 
			
		||||
            if(this.time === null || this.time === 0)
 | 
			
		||||
                return;
 | 
			
		||||
            const date = isNaN(+this.time) ? new Date(`${this.time}+00:00`) : new Date(+this.time * 1000);
 | 
			
		||||
            const absolute = format(date, 'YYYY-MM-DD HH:mm');
 | 
			
		||||
            const relative = distanceInWordsToNow(date, {addSuffix: true});
 | 
			
		||||
            if(Settings.fuzzyDates) {
 | 
			
		||||
                this.primary = relative;
 | 
			
		||||
                this.secondary = absolute;
 | 
			
		||||
            } else {
 | 
			
		||||
                this.primary = absolute;
 | 
			
		||||
                this.secondary = relative;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										46
									
								
								components/form_errors.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								components/form_errors.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,46 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div class="form-group" :class="allClasses">
 | 
			
		||||
        <slot></slot>
 | 
			
		||||
        <div :class="classes" v-if="hasErrors">
 | 
			
		||||
            <ul>
 | 
			
		||||
                <li v-for="error in errorList">{{ error }}</li>
 | 
			
		||||
            </ul>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import Vue from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import {Prop} from 'vue-property-decorator';
 | 
			
		||||
 | 
			
		||||
    @Component
 | 
			
		||||
    export default class FormErrors extends Vue {
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        readonly errors: {[key: string]: string[] | undefined};
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        readonly field: string;
 | 
			
		||||
        @Prop({default: 'col-xs-3'})
 | 
			
		||||
        readonly classes: string;
 | 
			
		||||
        @Prop()
 | 
			
		||||
        readonly extraClasses?: {[key: string]: boolean};
 | 
			
		||||
 | 
			
		||||
        get hasErrors(): boolean {
 | 
			
		||||
            return typeof this.errors[this.field] !== 'undefined';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get errorList(): string[] {
 | 
			
		||||
            return this.errors[this.field] !== undefined ? this.errors[this.field]! : [];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get allClasses(): {[key: string]: boolean} {
 | 
			
		||||
            const classes: {[key: string]: boolean} = {'hash-error': this.hasErrors};
 | 
			
		||||
            if(this.extraClasses === undefined) return classes;
 | 
			
		||||
            for(const key in this.extraClasses)
 | 
			
		||||
                classes[key] = this.extraClasses[key];
 | 
			
		||||
 | 
			
		||||
            return classes;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
@ -41,6 +41,9 @@
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <chat v-else :ownCharacters="characters" :defaultCharacter="defaultCharacter" ref="chat"></chat>
 | 
			
		||||
        <modal action="Profile" :buttons="false" ref="profileViewer" dialogClass="profile-viewer">
 | 
			
		||||
            <character-page :authenticated="false" :hideGroups="true" :name="profileName"></character-page>
 | 
			
		||||
        </modal>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
@ -53,9 +56,11 @@
 | 
			
		||||
    import Chat from '../chat/Chat.vue';
 | 
			
		||||
    import core, {init as initCore} from '../chat/core';
 | 
			
		||||
    import l from '../chat/localize';
 | 
			
		||||
    import {init as profileApiInit} from '../chat/profile_api';
 | 
			
		||||
    import Socket from '../chat/WebSocket';
 | 
			
		||||
    import Modal from '../components/Modal.vue';
 | 
			
		||||
    import Connection from '../fchat/connection';
 | 
			
		||||
    import CharacterPage from '../site/character_page/character_page.vue';
 | 
			
		||||
    import {GeneralSettings, getGeneralSettings, Logs, setGeneralSettings, SettingsStore} from './filesystem';
 | 
			
		||||
    import Notifications from './notifications';
 | 
			
		||||
 | 
			
		||||
@ -63,8 +68,10 @@
 | 
			
		||||
        if(confirm(l('chat.confirmLeave'))) (<Navigator & {app: {exitApp(): void}}>navigator).app.exitApp();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    profileApiInit();
 | 
			
		||||
 | 
			
		||||
    @Component({
 | 
			
		||||
        components: {chat: Chat, modal: Modal}
 | 
			
		||||
        components: {chat: Chat, modal: Modal, characterPage: CharacterPage}
 | 
			
		||||
    })
 | 
			
		||||
    export default class Index extends Vue {
 | 
			
		||||
        //tslint:disable:no-null-keyword
 | 
			
		||||
@ -78,8 +85,19 @@
 | 
			
		||||
        l = l;
 | 
			
		||||
        settings: GeneralSettings | null = null;
 | 
			
		||||
        importProgress = 0;
 | 
			
		||||
        profileName = '';
 | 
			
		||||
 | 
			
		||||
        async created(): Promise<void> {
 | 
			
		||||
            const oldOpen = window.open.bind(window);
 | 
			
		||||
            window.open = (url?: string, target?: string, features?: string, replace?: boolean) => {
 | 
			
		||||
                const profileMatch = url !== undefined ? url.match(/^https?:\/\/(www\.)?f-list.net\/c\/(.+)/) : null;
 | 
			
		||||
                if(profileMatch !== null) {
 | 
			
		||||
                    const profileViewer = <Modal>this.$refs['profileViewer'];
 | 
			
		||||
                    this.profileName = profileMatch[2];
 | 
			
		||||
                    profileViewer.show();
 | 
			
		||||
                    return null;
 | 
			
		||||
                } else return oldOpen(url, target, features, replace);
 | 
			
		||||
            };
 | 
			
		||||
            let settings = await getGeneralSettings();
 | 
			
		||||
            if(settings === undefined) settings = new GeneralSettings();
 | 
			
		||||
            if(settings.account.length > 0) this.saveLogin = true;
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,6 @@
 | 
			
		||||
    "lib": [
 | 
			
		||||
      "dom",
 | 
			
		||||
      "es5",
 | 
			
		||||
      "scripthost",
 | 
			
		||||
      "es2015.iterable",
 | 
			
		||||
      "es2015.promise"
 | 
			
		||||
    ],
 | 
			
		||||
@ -18,7 +17,6 @@
 | 
			
		||||
    "importHelpers": true,
 | 
			
		||||
    "forceConsistentCasingInFileNames": true,
 | 
			
		||||
    "strict": true,
 | 
			
		||||
    "noImplicitReturns": true,
 | 
			
		||||
    "noUnusedLocals": true,
 | 
			
		||||
    "noUnusedParameters": true
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
@ -25,7 +25,7 @@
 | 
			
		||||
                <div class="form-group">
 | 
			
		||||
                    <label for="save"><input type="checkbox" id="save" v-model="saveLogin"/> {{l('login.save')}}</label>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="form-group">
 | 
			
		||||
                <div class="form-group" style="margin:0">
 | 
			
		||||
                    <button class="btn btn-primary" @click="login" :disabled="loggingIn">
 | 
			
		||||
                        {{l(loggingIn ? 'login.working' : 'login.submit')}}
 | 
			
		||||
                    </button>
 | 
			
		||||
@ -40,6 +40,9 @@
 | 
			
		||||
                <div class="progress-bar" :style="{width: importProgress * 100 + '%'}"></div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </modal>
 | 
			
		||||
        <modal action="Profile" :buttons="false" ref="profileViewer" dialogClass="profile-viewer">
 | 
			
		||||
            <character-page :authenticated="false" :hideGroups="true" :name="profileName"></character-page>
 | 
			
		||||
        </modal>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
@ -57,9 +60,11 @@
 | 
			
		||||
    import {Settings} from '../chat/common';
 | 
			
		||||
    import core, {init as initCore} from '../chat/core';
 | 
			
		||||
    import l from '../chat/localize';
 | 
			
		||||
    import {init as profileApiInit} from '../chat/profile_api';
 | 
			
		||||
    import Socket from '../chat/WebSocket';
 | 
			
		||||
    import Modal from '../components/Modal.vue';
 | 
			
		||||
    import Connection from '../fchat/connection';
 | 
			
		||||
    import CharacterPage from '../site/character_page/character_page.vue';
 | 
			
		||||
    import {nativeRequire} from './common';
 | 
			
		||||
    import {GeneralSettings, getGeneralSettings, Logs, setGeneralSettings, SettingsStore} from './filesystem';
 | 
			
		||||
    import * as SlimcatImporter from './importer';
 | 
			
		||||
@ -68,8 +73,8 @@
 | 
			
		||||
    import * as spellchecker from './spellchecker';
 | 
			
		||||
 | 
			
		||||
    const webContents = electron.remote.getCurrentWebContents();
 | 
			
		||||
    webContents.on('context-menu', (_, props: Electron.ContextMenuParams & {editFlags: {[key: string]: boolean}}) => {
 | 
			
		||||
        const menuTemplate = createContextMenu(props);
 | 
			
		||||
    webContents.on('context-menu', (_, props) => {
 | 
			
		||||
        const menuTemplate = createContextMenu(<Electron.ContextMenuParams & {editFlags: {[key: string]: boolean}}>props);
 | 
			
		||||
        if(props.misspelledWord !== '') {
 | 
			
		||||
            const corrections = spellchecker.getCorrections(props.misspelledWord);
 | 
			
		||||
            if(corrections.length > 0) {
 | 
			
		||||
@ -99,7 +104,7 @@
 | 
			
		||||
    let trayMenu = electron.remote.Menu.buildFromTemplate(defaultTrayMenu);
 | 
			
		||||
 | 
			
		||||
    let isClosing = false;
 | 
			
		||||
    let mainWindow: Electron.BrowserWindow | undefined = electron.remote.getCurrentWindow(); //TODO
 | 
			
		||||
    let mainWindow: Electron.BrowserWindow | undefined = electron.remote.getCurrentWindow();
 | 
			
		||||
    //tslint:disable-next-line:no-require-imports
 | 
			
		||||
    const tray = new electron.remote.Tray(path.join(__dirname, <string>require('./build/tray.png')));
 | 
			
		||||
    tray.setToolTip(l('title'));
 | 
			
		||||
@ -116,8 +121,10 @@
 | 
			
		||||
    for(const key in keyStore) keyStore[key] = promisify(<(...args: any[]) => any>keyStore[key].bind(keyStore, 'fchat'));
 | 
			
		||||
    //tslint:enable
 | 
			
		||||
 | 
			
		||||
    profileApiInit();
 | 
			
		||||
 | 
			
		||||
    @Component({
 | 
			
		||||
        components: {chat: Chat, modal: Modal}
 | 
			
		||||
        components: {chat: Chat, modal: Modal, characterPage: CharacterPage}
 | 
			
		||||
    })
 | 
			
		||||
    export default class Index extends Vue {
 | 
			
		||||
        //tslint:disable:no-null-keyword
 | 
			
		||||
@ -135,13 +142,18 @@
 | 
			
		||||
        currentSettings: GeneralSettings;
 | 
			
		||||
        isConnected = false;
 | 
			
		||||
        importProgress = 0;
 | 
			
		||||
        profileName = '';
 | 
			
		||||
 | 
			
		||||
        constructor(options?: ComponentOptions<Index>) {
 | 
			
		||||
            super(options);
 | 
			
		||||
            let settings = getGeneralSettings();
 | 
			
		||||
            if(settings === undefined) {
 | 
			
		||||
                if(SlimcatImporter.canImportGeneral() && confirm(l('importer.importGeneral')))
 | 
			
		||||
                    settings = SlimcatImporter.importGeneral();
 | 
			
		||||
                try {
 | 
			
		||||
                    if(SlimcatImporter.canImportGeneral() && confirm(l('importer.importGeneral')))
 | 
			
		||||
                        settings = SlimcatImporter.importGeneral();
 | 
			
		||||
                } catch {
 | 
			
		||||
                    alert(l('importer.error'));
 | 
			
		||||
                }
 | 
			
		||||
                settings = settings !== undefined ? settings : new GeneralSettings();
 | 
			
		||||
            }
 | 
			
		||||
            this.account = settings.account;
 | 
			
		||||
@ -187,6 +199,12 @@
 | 
			
		||||
                        this.currentSettings.closeToTray = item.checked;
 | 
			
		||||
                        setGeneralSettings(this.currentSettings);
 | 
			
		||||
                    }
 | 
			
		||||
                }, {
 | 
			
		||||
                    label: l('settings.profileViewer'), type: 'checkbox', checked: this.currentSettings.profileViewer,
 | 
			
		||||
                    click: (item: Electron.MenuItem) => {
 | 
			
		||||
                        this.currentSettings.profileViewer = item.checked;
 | 
			
		||||
                        setGeneralSettings(this.currentSettings);
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                {label: l('settings.spellcheck'), submenu: spellcheckerMenu},
 | 
			
		||||
                {
 | 
			
		||||
@ -200,7 +218,8 @@
 | 
			
		||||
                },
 | 
			
		||||
                {type: 'separator'},
 | 
			
		||||
                {role: 'minimize'},
 | 
			
		||||
                process.platform === 'darwin' ? {role: 'quit'} : {
 | 
			
		||||
                {
 | 
			
		||||
                    accelerator: process.platform === 'darwin' ? 'Cmd+Q' : undefined,
 | 
			
		||||
                    label: l('action.quit'),
 | 
			
		||||
                    click(): void {
 | 
			
		||||
                        isClosing = true;
 | 
			
		||||
@ -232,6 +251,13 @@
 | 
			
		||||
                }));
 | 
			
		||||
                electron.remote.Menu.setApplicationMenu(menu);
 | 
			
		||||
            });
 | 
			
		||||
            electron.ipcRenderer.on('open-profile', (_: Event, name: string) => {
 | 
			
		||||
                if(this.currentSettings.profileViewer) {
 | 
			
		||||
                    const profileViewer = <Modal>this.$refs['profileViewer'];
 | 
			
		||||
                    this.profileName = name;
 | 
			
		||||
                    profileViewer.show();
 | 
			
		||||
                } else electron.remote.shell.openExternal(`https://www.f-list.net/c/${name}`);
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        async addSpellcheckerItems(menu: Electron.Menu): Promise<void> {
 | 
			
		||||
@ -293,6 +319,7 @@
 | 
			
		||||
                    Raven.setUserContext({username: core.connection.character});
 | 
			
		||||
                    trayMenu.insert(0, new electron.remote.MenuItem({label: core.connection.character, enabled: false}));
 | 
			
		||||
                    trayMenu.insert(1, new electron.remote.MenuItem({type: 'separator'}));
 | 
			
		||||
                    tray.setContextMenu(trayMenu);
 | 
			
		||||
                });
 | 
			
		||||
                connection.onEvent('closed', () => {
 | 
			
		||||
                    this.isConnected = false;
 | 
			
		||||
@ -332,7 +359,7 @@
 | 
			
		||||
            try {
 | 
			
		||||
                return `<style>${fs.readFileSync(path.join(__dirname, `themes/${this.currentSettings.theme}.css`))}</style>`;
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                if(e.code === 'ENOENT' && this.currentSettings.theme !== 'default') {
 | 
			
		||||
                if((<Error & {code: string}>e).code === 'ENOENT' && this.currentSettings.theme !== 'default') {
 | 
			
		||||
                    this.currentSettings.theme = 'default';
 | 
			
		||||
                    return this.styling;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "fchat",
 | 
			
		||||
  "version": "0.2.4",
 | 
			
		||||
  "version": "0.2.7",
 | 
			
		||||
  "author": "The F-List Team",
 | 
			
		||||
  "description": "F-List.net Chat Client",
 | 
			
		||||
  "main": "main.js",
 | 
			
		||||
 | 
			
		||||
@ -29,8 +29,11 @@
 | 
			
		||||
 * @version 3.0
 | 
			
		||||
 * @see {@link https://github.com/f-list/exported|GitHub repo}
 | 
			
		||||
 */
 | 
			
		||||
import 'bootstrap/js/collapse.js';
 | 
			
		||||
import 'bootstrap/js/dropdown.js';
 | 
			
		||||
import 'bootstrap/js/modal.js';
 | 
			
		||||
import 'bootstrap/js/tab.js';
 | 
			
		||||
import 'bootstrap/js/transition.js';
 | 
			
		||||
import * as electron from 'electron';
 | 
			
		||||
import * as Raven from 'raven-js';
 | 
			
		||||
import Vue from 'vue';
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
import {addMinutes} from 'date-fns';
 | 
			
		||||
import * as electron from 'electron';
 | 
			
		||||
import * as fs from 'fs';
 | 
			
		||||
import * as path from 'path';
 | 
			
		||||
import {promisify} from 'util';
 | 
			
		||||
import {Message as MessageImpl} from '../chat/common';
 | 
			
		||||
import core from '../chat/core';
 | 
			
		||||
import {Conversation, Logs as Logging, Settings} from '../chat/interfaces';
 | 
			
		||||
@ -10,18 +10,13 @@ import {mkdir} from './common';
 | 
			
		||||
const dayMs = 86400000;
 | 
			
		||||
const baseDir = path.join(electron.remote.app.getPath('userData'), 'data');
 | 
			
		||||
mkdir(baseDir);
 | 
			
		||||
const readFile = promisify(fs.readFile);
 | 
			
		||||
const writeFile = promisify(fs.writeFile);
 | 
			
		||||
const readdir = promisify(fs.readdir);
 | 
			
		||||
const open = promisify(fs.open);
 | 
			
		||||
const fstat = promisify(fs.fstat);
 | 
			
		||||
const read = promisify(fs.read);
 | 
			
		||||
 | 
			
		||||
const noAssert = process.env.NODE_ENV === 'production';
 | 
			
		||||
 | 
			
		||||
export class GeneralSettings {
 | 
			
		||||
    account = '';
 | 
			
		||||
    closeToTray = true;
 | 
			
		||||
    profileViewer = true;
 | 
			
		||||
    host = 'wss://chat.f-list.net:9799';
 | 
			
		||||
    spellcheckLang: string | undefined = 'en-GB';
 | 
			
		||||
    theme = 'default';
 | 
			
		||||
@ -127,7 +122,7 @@ export class Logs implements Logging.Persistent {
 | 
			
		||||
                    for(; offset < content.length; offset += 7) {
 | 
			
		||||
                        const key = content.readUInt16LE(offset);
 | 
			
		||||
                        item.index[key] = item.offsets.length;
 | 
			
		||||
                        item.offsets.push(content.readUIntLE(offset + 2, 5));
 | 
			
		||||
                        item.offsets.push(content.readUIntLE(offset + 2, 5, noAssert));
 | 
			
		||||
                    }
 | 
			
		||||
                    this.index[file.slice(0, -4).toLowerCase()] = item;
 | 
			
		||||
                }
 | 
			
		||||
@ -139,14 +134,14 @@ export class Logs implements Logging.Persistent {
 | 
			
		||||
        if(!fs.existsSync(file)) return [];
 | 
			
		||||
        let count = 20;
 | 
			
		||||
        let messages = new Array<Conversation.Message>(count);
 | 
			
		||||
        const fd = await open(file, 'r');
 | 
			
		||||
        let pos = (await fstat(fd)).size;
 | 
			
		||||
        const fd = fs.openSync(file, 'r');
 | 
			
		||||
        let pos = fs.fstatSync(fd).size;
 | 
			
		||||
        const buffer = Buffer.allocUnsafe(65536);
 | 
			
		||||
        while(pos > 0 && count > 0) {
 | 
			
		||||
            await read(fd, buffer, 0, 2, pos - 2);
 | 
			
		||||
            fs.readSync(fd, buffer, 0, 2, pos - 2);
 | 
			
		||||
            const length = buffer.readUInt16LE(0);
 | 
			
		||||
            pos = pos - length - 2;
 | 
			
		||||
            await read(fd, buffer, 0, length, pos);
 | 
			
		||||
            fs.readSync(fd, buffer, 0, length, pos);
 | 
			
		||||
            messages[--count] = deserializeMessage(buffer).message;
 | 
			
		||||
        }
 | 
			
		||||
        if(count !== 0) messages = messages.slice(count);
 | 
			
		||||
@ -156,9 +151,11 @@ export class Logs implements Logging.Persistent {
 | 
			
		||||
    getLogDates(key: string): ReadonlyArray<Date> {
 | 
			
		||||
        const entry = this.index[key];
 | 
			
		||||
        if(entry === undefined) return [];
 | 
			
		||||
        const dayOffset = new Date().getTimezoneOffset() * 60000;
 | 
			
		||||
        const dates = [];
 | 
			
		||||
        for(const date in entry.index) dates.push(new Date(parseInt(date, 10) * dayMs + dayOffset));
 | 
			
		||||
        for(const item in entry.index) { //tslint:disable:forin
 | 
			
		||||
            const date = new Date(parseInt(item, 10) * dayMs);
 | 
			
		||||
            dates.push(addMinutes(date, date.getTimezoneOffset()));
 | 
			
		||||
        }
 | 
			
		||||
        return dates;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -170,11 +167,11 @@ export class Logs implements Logging.Persistent {
 | 
			
		||||
        const buffer = Buffer.allocUnsafe(50100);
 | 
			
		||||
        const messages: Conversation.Message[] = [];
 | 
			
		||||
        const file = getLogFile(key);
 | 
			
		||||
        const fd = await open(file, 'r');
 | 
			
		||||
        const fd = fs.openSync(file, 'r');
 | 
			
		||||
        let pos = index.offsets[dateOffset];
 | 
			
		||||
        const size = dateOffset + 1 < index.offsets.length ? index.offsets[dateOffset + 1] : (await fstat(fd)).size;
 | 
			
		||||
        const size = dateOffset + 1 < index.offsets.length ? index.offsets[dateOffset + 1] : (fs.fstatSync(fd)).size;
 | 
			
		||||
        while(pos < size) {
 | 
			
		||||
            await read(fd, buffer, 0, 50100, pos);
 | 
			
		||||
            fs.readSync(fd, buffer, 0, 50100, pos);
 | 
			
		||||
            const deserialized = deserializeMessage(buffer);
 | 
			
		||||
            messages.push(deserialized.message);
 | 
			
		||||
            pos += deserialized.end;
 | 
			
		||||
@ -220,14 +217,14 @@ export class SettingsStore implements Settings.Store {
 | 
			
		||||
    async get<K extends keyof Settings.Keys>(key: K, character?: string): Promise<Settings.Keys[K] | undefined> {
 | 
			
		||||
        const file = path.join(getSettingsDir(character), key);
 | 
			
		||||
        if(!fs.existsSync(file)) return undefined;
 | 
			
		||||
        return <Settings.Keys[K]>JSON.parse(await readFile(file, 'utf8'));
 | 
			
		||||
        return <Settings.Keys[K]>JSON.parse(fs.readFileSync(file, 'utf8'));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getAvailableCharacters(): Promise<ReadonlyArray<string>> {
 | 
			
		||||
        return (await readdir(baseDir)).filter((x) => fs.lstatSync(path.join(baseDir, x)).isDirectory());
 | 
			
		||||
        return (fs.readdirSync(baseDir)).filter((x) => fs.lstatSync(path.join(baseDir, x)).isDirectory());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async set<K extends keyof Settings.Keys>(key: K, value: Settings.Keys[K]): Promise<void> {
 | 
			
		||||
        await writeFile(path.join(getSettingsDir(), key), JSON.stringify(value));
 | 
			
		||||
        fs.writeFileSync(path.join(getSettingsDir(), key), JSON.stringify(value));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -125,7 +125,9 @@ function createMessage(line: string, ownCharacter: string, name: string, isChann
 | 
			
		||||
async function importSettings(dir: string): Promise<void> {
 | 
			
		||||
    const settings = new Settings();
 | 
			
		||||
    const settingsStore = new SettingsStore();
 | 
			
		||||
    const buffer = fs.readFileSync(path.join(dir, 'Global', '!settings.xml'));
 | 
			
		||||
    const settingsFile = path.join(dir, 'Global', '!settings.xml');
 | 
			
		||||
    if(!fs.existsSync(settingsFile)) return;
 | 
			
		||||
    const buffer = fs.readFileSync(settingsFile);
 | 
			
		||||
    const content = buffer.toString('utf8', (buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) ? 3 : 0);
 | 
			
		||||
    const config = new DOMParser().parseFromString(content, 'application/xml').firstElementChild;
 | 
			
		||||
    if(config === null) return;
 | 
			
		||||
 | 
			
		||||
@ -30,7 +30,7 @@
 | 
			
		||||
 * @see {@link https://github.com/f-list/exported|GitHub repo}
 | 
			
		||||
 */
 | 
			
		||||
import * as electron from 'electron';
 | 
			
		||||
import log from 'electron-log';
 | 
			
		||||
import log from 'electron-log'; //tslint:disable-line:match-default-export-name
 | 
			
		||||
import {autoUpdater} from 'electron-updater';
 | 
			
		||||
import * as path from 'path';
 | 
			
		||||
import * as url from 'url';
 | 
			
		||||
@ -44,7 +44,7 @@ if(datadir.length > 0) app.setPath('userData', datadir[0].substr('--datadir='.le
 | 
			
		||||
 | 
			
		||||
// Keep a global reference of the window object, if you don't, the window will
 | 
			
		||||
// be closed automatically when the JavaScript object is garbage collected.
 | 
			
		||||
let mainWindow: Electron.BrowserWindow | undefined;
 | 
			
		||||
const windows: Electron.BrowserWindow[] = [];
 | 
			
		||||
 | 
			
		||||
const baseDir = app.getPath('userData');
 | 
			
		||||
mkdir(baseDir);
 | 
			
		||||
@ -57,7 +57,7 @@ log.info('Starting application.');
 | 
			
		||||
 | 
			
		||||
function sendUpdaterStatusToWindow(status: string, progress?: object): void {
 | 
			
		||||
    log.info(status);
 | 
			
		||||
    mainWindow!.webContents.send('updater-status', status, progress);
 | 
			
		||||
    for(const window of windows) window.webContents.send('updater-status', status, progress);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const updaterEvents = ['checking-for-update', 'update-available', 'update-not-available', 'error', 'update-downloaded'];
 | 
			
		||||
@ -71,27 +71,25 @@ autoUpdater.on('download-progress', (_, progress: object) => {
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function runUpdater(): void {
 | 
			
		||||
    //tslint:disable-next-line:no-floating-promises
 | 
			
		||||
    autoUpdater.checkForUpdates();
 | 
			
		||||
    //tslint:disable-next-line:no-floating-promises
 | 
			
		||||
    setInterval(() => { autoUpdater.checkForUpdates(); }, 3600000);
 | 
			
		||||
    electron.ipcMain.on('install-update', () => {
 | 
			
		||||
        autoUpdater.quitAndInstall(false, true);
 | 
			
		||||
    });
 | 
			
		||||
    autoUpdater.checkForUpdates(); //tslint:disable-line:no-floating-promises
 | 
			
		||||
    setInterval(async() => autoUpdater.checkForUpdates(), 3600000);
 | 
			
		||||
    electron.ipcMain.on('install-update', () => autoUpdater.quitAndInstall(false, true));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function bindWindowEvents(window: Electron.BrowserWindow): void {
 | 
			
		||||
    // Prevent page navigation by opening links in an external browser.
 | 
			
		||||
    const openLinkExternally = (e: Event, linkUrl: string) => {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        electron.shell.openExternal(linkUrl);
 | 
			
		||||
        const profileMatch = linkUrl.match(/^https?:\/\/(www\.)?f-list.net\/c\/(.+)/);
 | 
			
		||||
        if(profileMatch !== null) window.webContents.send('open-profile', decodeURIComponent(profileMatch[2]));
 | 
			
		||||
        else electron.shell.openExternal(linkUrl);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    window.webContents.on('will-navigate', openLinkExternally);
 | 
			
		||||
    window.webContents.on('new-window', openLinkExternally);
 | 
			
		||||
    // Fix focus events not properly propagating down to the document.
 | 
			
		||||
    window.on('focus', () => mainWindow!.webContents.send('focus', true));
 | 
			
		||||
    window.on('blur', () => mainWindow!.webContents.send('focus', false));
 | 
			
		||||
    window.on('focus', () => window.webContents.send('focus', true));
 | 
			
		||||
    window.on('blur', () => window.webContents.send('focus', false));
 | 
			
		||||
 | 
			
		||||
    // Save window state when it is being closed.
 | 
			
		||||
    window.on('close', () => windowState.setSavedWindowState(window));
 | 
			
		||||
@ -100,51 +98,25 @@ function bindWindowEvents(window: Electron.BrowserWindow): void {
 | 
			
		||||
function createWindow(): void {
 | 
			
		||||
    const lastState = windowState.getSavedWindowState();
 | 
			
		||||
    const windowProperties = {...lastState, center: lastState.x === undefined};
 | 
			
		||||
    // Create the browser window.
 | 
			
		||||
    mainWindow = new electron.BrowserWindow(windowProperties);
 | 
			
		||||
    if(lastState.maximized)
 | 
			
		||||
        mainWindow.maximize();
 | 
			
		||||
    const window = new electron.BrowserWindow(windowProperties);
 | 
			
		||||
    windows.push(window);
 | 
			
		||||
    if(lastState.maximized) window.maximize();
 | 
			
		||||
 | 
			
		||||
    // and load the index.html of the app.
 | 
			
		||||
    mainWindow.loadURL(url.format({
 | 
			
		||||
    window.loadURL(url.format({
 | 
			
		||||
        pathname: path.join(__dirname, 'index.html'),
 | 
			
		||||
        protocol: 'file:',
 | 
			
		||||
        slashes: true
 | 
			
		||||
    }));
 | 
			
		||||
 | 
			
		||||
    bindWindowEvents(mainWindow);
 | 
			
		||||
    bindWindowEvents(window);
 | 
			
		||||
 | 
			
		||||
    // Open the DevTools.
 | 
			
		||||
    // mainWindow.webContents.openDevTools()
 | 
			
		||||
 | 
			
		||||
    // Emitted when the window is closed.
 | 
			
		||||
    mainWindow.on('closed', () => {
 | 
			
		||||
        // Dereference the window object, usually you would store windows
 | 
			
		||||
        // in an array if your app supports multi windows, this is the time
 | 
			
		||||
        // when you should delete the corresponding element.
 | 
			
		||||
        mainWindow = undefined;
 | 
			
		||||
    });
 | 
			
		||||
    window.on('closed', () => windows.splice(windows.indexOf(window), 1));
 | 
			
		||||
 | 
			
		||||
    if(process.env.NODE_ENV === 'production') runUpdater();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// This method will be called when Electron has finished
 | 
			
		||||
// initialization and is ready to create browser windows.
 | 
			
		||||
// Some APIs can only be used after this event occurs.
 | 
			
		||||
app.on('ready', createWindow);
 | 
			
		||||
 | 
			
		||||
// Quit when all windows are closed.
 | 
			
		||||
app.on('window-all-closed', () => {
 | 
			
		||||
    // On OS X it is common for applications and their menu bar
 | 
			
		||||
    // to stay active until the user quits explicitly with Cmd + Q
 | 
			
		||||
    if(process.platform !== 'darwin') app.quit();
 | 
			
		||||
app.makeSingleInstance(() => {
 | 
			
		||||
    if(windows.length < 3) createWindow();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
app.on('activate', () => {
 | 
			
		||||
    // On OS X it's common to re-create a window in the app when the
 | 
			
		||||
    // dock icon is clicked and there are no other windows open.
 | 
			
		||||
    if(mainWindow === undefined) createWindow();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// In this file you can include the rest of your app's specific main process
 | 
			
		||||
// code. You can also put them in separate files and require them here.
 | 
			
		||||
app.on('window-all-closed', () => app.quit());
 | 
			
		||||
@ -42,7 +42,7 @@ export function createContextMenu(props: Electron.ContextMenuParams & {editFlags
 | 
			
		||||
 | 
			
		||||
export function createAppMenu(): Electron.MenuItemConstructorOptions[] {
 | 
			
		||||
    const viewItem = {
 | 
			
		||||
        label: l('action.view'),
 | 
			
		||||
        label: `&${l('action.view')}`,
 | 
			
		||||
        submenu: [
 | 
			
		||||
            {role: 'resetzoom'},
 | 
			
		||||
            {role: 'zoomin'},
 | 
			
		||||
@ -53,9 +53,9 @@ export function createAppMenu(): Electron.MenuItemConstructorOptions[] {
 | 
			
		||||
    };
 | 
			
		||||
    const menu: Electron.MenuItemConstructorOptions[] = [
 | 
			
		||||
        {
 | 
			
		||||
            label: l('title')
 | 
			
		||||
            label: `&${l('title')}`
 | 
			
		||||
        }, {
 | 
			
		||||
            label: l('action.edit'),
 | 
			
		||||
            label: `&${l('action.edit')}`,
 | 
			
		||||
            submenu: [
 | 
			
		||||
                {role: 'undo'},
 | 
			
		||||
                {role: 'redo'},
 | 
			
		||||
@ -66,12 +66,16 @@ export function createAppMenu(): Electron.MenuItemConstructorOptions[] {
 | 
			
		||||
                {role: 'selectall'}
 | 
			
		||||
            ]
 | 
			
		||||
        }, viewItem, {
 | 
			
		||||
            role: 'help',
 | 
			
		||||
            label: `&${l('help')}`,
 | 
			
		||||
            submenu: [
 | 
			
		||||
                {
 | 
			
		||||
                    label: l('help.fchat'),
 | 
			
		||||
                    click: () => electron.shell.openExternal('https://wiki.f-list.net/F-Chat_3.0')
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    label: l('help.feedback'),
 | 
			
		||||
                    click: () => electron.shell.openExternal('https://goo.gl/forms/WnLt3Qm3TPt64jQt2')
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    label: l('help.rules'),
 | 
			
		||||
                    click: () => electron.shell.openExternal('https://wiki.f-list.net/Rules')
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
{
 | 
			
		||||
    {
 | 
			
		||||
    "name": "fchat",
 | 
			
		||||
    "version": "3.0.0",
 | 
			
		||||
    "author": "The F-List Team",
 | 
			
		||||
@ -34,6 +34,10 @@
 | 
			
		||||
            "node_modules/**/*.node"
 | 
			
		||||
        ],
 | 
			
		||||
        "asar": false,
 | 
			
		||||
        "nsis": {
 | 
			
		||||
            "oneClick": false,
 | 
			
		||||
            "allowToChangeInstallationDirectory": true
 | 
			
		||||
        },
 | 
			
		||||
        "linux": {
 | 
			
		||||
            "category": "Network"
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
  "compilerOptions": {
 | 
			
		||||
    "target": "es6",
 | 
			
		||||
    "allowSyntheticDefaultImports": true,
 | 
			
		||||
    "module": "commonjs",
 | 
			
		||||
    "sourceMap": true,
 | 
			
		||||
    "experimentalDecorators": true,
 | 
			
		||||
@ -11,7 +10,6 @@
 | 
			
		||||
    "importHelpers": true,
 | 
			
		||||
    "forceConsistentCasingInFileNames": true,
 | 
			
		||||
    "strict": true,
 | 
			
		||||
    "noImplicitReturns": true,
 | 
			
		||||
    "noUnusedLocals": true,
 | 
			
		||||
    "noUnusedParameters": true
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
import {app, screen} from 'electron';
 | 
			
		||||
import log from 'electron-log';
 | 
			
		||||
import log from 'electron-log'; //tslint:disable-line:match-default-export-name
 | 
			
		||||
import * as fs from 'fs';
 | 
			
		||||
import * as path from 'path';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -113,7 +113,7 @@ export default function(this: void, connection: Connection, characters: Characte
 | 
			
		||||
    let getChannelTimer: NodeJS.Timer | undefined;
 | 
			
		||||
    let rejoin: string[] | undefined;
 | 
			
		||||
    connection.onEvent('connecting', (isReconnect) => {
 | 
			
		||||
        if(isReconnect) rejoin = Object.keys(state.joinedMap);
 | 
			
		||||
        if(isReconnect && rejoin === undefined) rejoin = Object.keys(state.joinedMap);
 | 
			
		||||
        state.joinedChannels = [];
 | 
			
		||||
        state.joinedMap = {};
 | 
			
		||||
    });
 | 
			
		||||
@ -162,14 +162,16 @@ export default function(this: void, connection: Connection, characters: Characte
 | 
			
		||||
            state.joinedChannels.push(channel);
 | 
			
		||||
            if(item !== undefined) item.isJoined = true;
 | 
			
		||||
        } else {
 | 
			
		||||
            const channel = state.getChannel(data.channel)!;
 | 
			
		||||
            const channel = state.getChannel(data.channel);
 | 
			
		||||
            if(channel === undefined) return state.leave(data.channel);
 | 
			
		||||
            const member = channel.createMember(characters.get(data.character.identity));
 | 
			
		||||
            channel.addMember(member);
 | 
			
		||||
            if(item !== undefined) item.memberCount++;
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    connection.onMessage('ICH', (data) => {
 | 
			
		||||
        const channel = state.getChannel(data.channel)!;
 | 
			
		||||
        const channel = state.getChannel(data.channel);
 | 
			
		||||
        if(channel === undefined) return state.leave(data.channel);
 | 
			
		||||
        channel.mode = data.mode;
 | 
			
		||||
        const members: {[key: string]: Interfaces.Member} = {};
 | 
			
		||||
        const sorted: Interfaces.Member[] = [];
 | 
			
		||||
@ -185,7 +187,11 @@ export default function(this: void, connection: Connection, characters: Characte
 | 
			
		||||
        if(item !== undefined) item.memberCount = data.users.length;
 | 
			
		||||
        for(const handler of state.handlers) handler('join', channel);
 | 
			
		||||
    });
 | 
			
		||||
    connection.onMessage('CDS', (data) => state.getChannel(data.channel)!.description = decodeHTML(data.description));
 | 
			
		||||
    connection.onMessage('CDS', (data) => {
 | 
			
		||||
        const channel = state.getChannel(data.channel);
 | 
			
		||||
        if(channel === undefined) return state.leave(data.channel);
 | 
			
		||||
        channel.description = decodeHTML(data.description);
 | 
			
		||||
    });
 | 
			
		||||
    connection.onMessage('LCH', (data) => {
 | 
			
		||||
        const channel = state.getChannel(data.channel);
 | 
			
		||||
        if(channel === undefined) return;
 | 
			
		||||
@ -201,7 +207,8 @@ export default function(this: void, connection: Connection, characters: Characte
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    connection.onMessage('COA', (data) => {
 | 
			
		||||
        const channel = state.getChannel(data.channel)!;
 | 
			
		||||
        const channel = state.getChannel(data.channel);
 | 
			
		||||
        if(channel === undefined) return state.leave(data.channel);
 | 
			
		||||
        channel.opList.push(data.character);
 | 
			
		||||
        const member = channel.members[data.character];
 | 
			
		||||
        if(member === undefined || member.rank === Interfaces.Rank.Owner) return;
 | 
			
		||||
@ -209,12 +216,14 @@ export default function(this: void, connection: Connection, characters: Characte
 | 
			
		||||
        channel.reSortMember(member);
 | 
			
		||||
    });
 | 
			
		||||
    connection.onMessage('COL', (data) => {
 | 
			
		||||
        const channel = state.getChannel(data.channel)!;
 | 
			
		||||
        const channel = state.getChannel(data.channel);
 | 
			
		||||
        if(channel === undefined) return state.leave(data.channel);
 | 
			
		||||
        channel.owner = data.oplist[0];
 | 
			
		||||
        channel.opList = data.oplist.slice(1);
 | 
			
		||||
    });
 | 
			
		||||
    connection.onMessage('COR', (data) => {
 | 
			
		||||
        const channel = state.getChannel(data.channel)!;
 | 
			
		||||
        const channel = state.getChannel(data.channel);
 | 
			
		||||
        if(channel === undefined) return state.leave(data.channel);
 | 
			
		||||
        channel.opList.splice(channel.opList.indexOf(data.character), 1);
 | 
			
		||||
        const member = channel.members[data.character];
 | 
			
		||||
        if(member === undefined || member.rank === Interfaces.Rank.Owner) return;
 | 
			
		||||
@ -222,7 +231,8 @@ export default function(this: void, connection: Connection, characters: Characte
 | 
			
		||||
        channel.reSortMember(member);
 | 
			
		||||
    });
 | 
			
		||||
    connection.onMessage('CSO', (data) => {
 | 
			
		||||
        const channel = state.getChannel(data.channel)!;
 | 
			
		||||
        const channel = state.getChannel(data.channel);
 | 
			
		||||
        if(channel === undefined) return state.leave(data.channel);
 | 
			
		||||
        const oldOwner = channel.members[channel.owner];
 | 
			
		||||
        if(oldOwner !== undefined) {
 | 
			
		||||
            oldOwner.rank = Interfaces.Rank.Member;
 | 
			
		||||
@ -235,7 +245,11 @@ export default function(this: void, connection: Connection, characters: Characte
 | 
			
		||||
            channel.reSortMember(newOwner);
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    connection.onMessage('RMO', (data) => state.getChannel(data.channel)!.mode = data.mode);
 | 
			
		||||
    connection.onMessage('RMO', (data) => {
 | 
			
		||||
        const channel = state.getChannel(data.channel);
 | 
			
		||||
        if(channel === undefined) return state.leave(data.channel);
 | 
			
		||||
        channel.mode = data.mode;
 | 
			
		||||
    });
 | 
			
		||||
    connection.onMessage('FLN', (data) => {
 | 
			
		||||
        for(const key in state.joinedMap)
 | 
			
		||||
            state.joinedMap[key]!.removeMember(data.character);
 | 
			
		||||
 | 
			
		||||
@ -10,7 +10,7 @@ class Character implements Interfaces.Character {
 | 
			
		||||
    isChatOp = false;
 | 
			
		||||
    isIgnored = false;
 | 
			
		||||
 | 
			
		||||
    constructor(readonly name: string) {
 | 
			
		||||
    constructor(public name: string) {
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -111,6 +111,7 @@ export default function(this: void, connection: Connection): Interfaces.State {
 | 
			
		||||
    connection.onMessage('NLN', (data) => {
 | 
			
		||||
        const character = state.get(data.identity);
 | 
			
		||||
        if(data.identity === connection.character) state.ownCharacter = character;
 | 
			
		||||
        character.name = data.identity;
 | 
			
		||||
        character.gender = data.gender;
 | 
			
		||||
        state.setStatus(character, data.status, '');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -31,7 +31,12 @@ export default class Connection implements Interfaces.Connection {
 | 
			
		||||
        this.cleanClose = false;
 | 
			
		||||
        const isReconnect = this.character === character;
 | 
			
		||||
        this.character = character;
 | 
			
		||||
        this.ticket = await this.ticketProvider();
 | 
			
		||||
        try {
 | 
			
		||||
            this.ticket = await this.ticketProvider();
 | 
			
		||||
        } catch(e) {
 | 
			
		||||
            for(const handler of this.errorHandlers) handler(<Error>e);
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        await this.invokeHandlers('connecting', isReconnect);
 | 
			
		||||
        const socket = this.socket = new this.socketProvider();
 | 
			
		||||
        socket.onOpen(() => {
 | 
			
		||||
@ -75,14 +80,14 @@ export default class Connection implements Interfaces.Connection {
 | 
			
		||||
        if(this.socket !== undefined) this.socket.close();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async queryApi(endpoint: string, data?: {account?: string, ticket?: string}): Promise<object> {
 | 
			
		||||
    async queryApi<T = object>(endpoint: string, data?: {account?: string, ticket?: string}): Promise<T> {
 | 
			
		||||
        if(data === undefined) data = {};
 | 
			
		||||
        data.account = this.account;
 | 
			
		||||
        data.ticket = this.ticket;
 | 
			
		||||
        let res = <{error: string}>(await queryApi(endpoint, data)).data;
 | 
			
		||||
        let res = <T & {error: string}>(await queryApi(endpoint, data)).data;
 | 
			
		||||
        if(res.error === 'Invalid ticket.' || res.error === 'Your login ticket has expired (five minutes) or no ticket requested.') {
 | 
			
		||||
            data.ticket = this.ticket = await this.ticketProvider();
 | 
			
		||||
            res = <{error: string}>(await queryApi(endpoint, data)).data;
 | 
			
		||||
            res = <T & {error: string}>(await queryApi(endpoint, data)).data;
 | 
			
		||||
        }
 | 
			
		||||
        if(res.error !== '') {
 | 
			
		||||
            const error = new Error(res.error);
 | 
			
		||||
@ -109,13 +114,13 @@ export default class Connection implements Interfaces.Connection {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    onMessage<K extends keyof Interfaces.ServerCommands>(type: K, handler: Interfaces.CommandHandler<K>): void {
 | 
			
		||||
        let handlers: (Interfaces.CommandHandler<K>[] | undefined) = this.messageHandlers[type];
 | 
			
		||||
        let handlers = <Interfaces.CommandHandler<K>[] | undefined>this.messageHandlers[type];
 | 
			
		||||
        if(handlers === undefined) handlers = this.messageHandlers[type] = [];
 | 
			
		||||
        handlers.push(handler);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    offMessage<K extends keyof Interfaces.ServerCommands>(type: K, handler: Interfaces.CommandHandler<K>): void {
 | 
			
		||||
        const handlers: (Interfaces.CommandHandler<K>[] | undefined) = this.messageHandlers[type];
 | 
			
		||||
        const handlers = <Interfaces.CommandHandler<K>[] | undefined>this.messageHandlers[type];
 | 
			
		||||
        if(handlers === undefined) return;
 | 
			
		||||
        handlers.splice(handlers.indexOf(handler), 1);
 | 
			
		||||
    }
 | 
			
		||||
@ -149,7 +154,7 @@ export default class Connection implements Interfaces.Connection {
 | 
			
		||||
                }
 | 
			
		||||
        }
 | 
			
		||||
        const time = new Date();
 | 
			
		||||
        const handlers: Interfaces.CommandHandler<T>[] | undefined = this.messageHandlers[type];
 | 
			
		||||
        const handlers = <Interfaces.CommandHandler<T>[] | undefined>this.messageHandlers[type];
 | 
			
		||||
        if(handlers !== undefined)
 | 
			
		||||
            for(const handler of handlers) handler(data, time);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -143,7 +143,7 @@ export namespace Connection {
 | 
			
		||||
        onError(handler: (error: Error) => void): void
 | 
			
		||||
        send(type: 'CHA' | 'FRL' | 'ORS' | 'PCR' | 'PIN' | 'UPT'): void
 | 
			
		||||
        send<K extends keyof ClientCommands>(type: K, data: ClientCommands[K]): void
 | 
			
		||||
        queryApi(endpoint: string, data?: object): Promise<object>
 | 
			
		||||
        queryApi<T = object>(endpoint: string, data?: object): Promise<T>
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
export type Connection = Connection.Connection;
 | 
			
		||||
 | 
			
		||||
@ -75,14 +75,18 @@ span.justifyText {
 | 
			
		||||
    text-align: justify;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
span.indentText {
 | 
			
		||||
div.indentText {
 | 
			
		||||
    padding-left: 3em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.characterAvatarIcon {
 | 
			
		||||
.character-avatar {
 | 
			
		||||
    display: inline;
 | 
			
		||||
    height: 50px;
 | 
			
		||||
    width: 50px;
 | 
			
		||||
    height: 100px;
 | 
			
		||||
    width: 100px;
 | 
			
		||||
    &.icon {
 | 
			
		||||
        height: 50px;
 | 
			
		||||
        width: 50px;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.collapseHeaderText {
 | 
			
		||||
@ -107,8 +111,8 @@ span.indentText {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.styledText, .bbcode {
 | 
			
		||||
    .force-word-wrapping();
 | 
			
		||||
    max-width: 100%;
 | 
			
		||||
    word-wrap: break-word;
 | 
			
		||||
    a {
 | 
			
		||||
        text-decoration: underline;
 | 
			
		||||
        &:hover {
 | 
			
		||||
 | 
			
		||||
@ -13,7 +13,7 @@
 | 
			
		||||
 | 
			
		||||
.characterList.characterListSelected {
 | 
			
		||||
    border-width: 2px;
 | 
			
		||||
    border-color: @characterListSelectedColor;
 | 
			
		||||
    border-color: @character-list-selected-border;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Character image editor.
 | 
			
		||||
@ -28,7 +28,7 @@
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.characterImage.characterImageSelected {
 | 
			
		||||
    border-color: @characterListSelectedColor;
 | 
			
		||||
    border-color: @character-image-selected-border;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.characterImagePreview {
 | 
			
		||||
 | 
			
		||||
@ -1,97 +1,198 @@
 | 
			
		||||
// Kinkes
 | 
			
		||||
.subkinkList.closed {
 | 
			
		||||
    display: none;
 | 
			
		||||
}
 | 
			
		||||
.subkink {
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.characterPageAvatar {
 | 
			
		||||
.character-page-avatar {
 | 
			
		||||
    height: 100px;
 | 
			
		||||
    width: 100px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Inline images
 | 
			
		||||
.imageBlock {
 | 
			
		||||
.inline-image {
 | 
			
		||||
    max-width: 100%;
 | 
			
		||||
    height: auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Quick Compare
 | 
			
		||||
.stockKink.quickCompareActive {
 | 
			
		||||
    border: 1px solid @quickCompareActiveColor;
 | 
			
		||||
}
 | 
			
		||||
.stockKink.quickCompareFave {
 | 
			
		||||
    background-color: @quickCompareFaveColor;
 | 
			
		||||
}
 | 
			
		||||
.stockKink.quickCompareYes {
 | 
			
		||||
    background-color: @quickCompareYesColor;
 | 
			
		||||
}
 | 
			
		||||
.stockKink.quickCompareMaybe {
 | 
			
		||||
    background-color: @quickCompareMaybeColor;
 | 
			
		||||
}
 | 
			
		||||
.stockKink.quickCompareNo {
 | 
			
		||||
    background-color: @quickCompareNoColor;
 | 
			
		||||
.character-page {
 | 
			
		||||
    .character-name {
 | 
			
		||||
        font-size: @font-size-h3;
 | 
			
		||||
        font-weight: bold;
 | 
			
		||||
    }
 | 
			
		||||
    .character-title {
 | 
			
		||||
        font-size: @font-size-small;
 | 
			
		||||
        font-style: italic;
 | 
			
		||||
    }
 | 
			
		||||
    .edit-link {
 | 
			
		||||
        margin-left: 5px;
 | 
			
		||||
        margin-top: @line-height-base;
 | 
			
		||||
    }
 | 
			
		||||
    .character-links-block {
 | 
			
		||||
        a {
 | 
			
		||||
            cursor: pointer;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    .badges-block,.contact-block,.quick-info-block,.character-list-block {
 | 
			
		||||
        margin-top: 15px;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Kink Group Highlighting
 | 
			
		||||
.highlightedKink {
 | 
			
		||||
    font-weight: bolder;
 | 
			
		||||
.badges-block {
 | 
			
		||||
    .character-badge {
 | 
			
		||||
        background-color: @character-badge-bg;
 | 
			
		||||
        border: 1px solid @character-badge-border;
 | 
			
		||||
        border-radius: @border-radius-base;
 | 
			
		||||
        .box-shadow(inset 0 1px 1px rgba(0,0,0,.05));
 | 
			
		||||
 | 
			
		||||
        &.character-badge-subscription-lifetime {
 | 
			
		||||
            background-color: @character-badge-subscriber-bg;
 | 
			
		||||
            border: 2px dashed @character-badge-subscriber-border;
 | 
			
		||||
            font-weight: bold;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.infotags {
 | 
			
		||||
    > .infotag-group {
 | 
			
		||||
        .infotag-title {
 | 
			
		||||
            font-size: @font-size-h4;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.infotag {
 | 
			
		||||
    .infotag-label {
 | 
			
		||||
        font-weight: bold;
 | 
			
		||||
    }
 | 
			
		||||
    .infotag-value {
 | 
			
		||||
        .force-word-wrapping();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.contact-method {
 | 
			
		||||
    .contact-value {
 | 
			
		||||
        .force-word-wrapping();
 | 
			
		||||
        margin-left: 5px;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.quick-info-block {
 | 
			
		||||
    .quick-info-label {
 | 
			
		||||
        font-weight: bold;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.character-kinks {
 | 
			
		||||
    margin-top: 15px;
 | 
			
		||||
    > .col-xs-3 {
 | 
			
		||||
        // Fix up padding on columns so they look distinct without being miles apart.
 | 
			
		||||
        padding: 0 5px 0 0;
 | 
			
		||||
    }
 | 
			
		||||
    .kinks-column {
 | 
			
		||||
        padding: 15px;
 | 
			
		||||
        border: 1px solid @well-border;
 | 
			
		||||
        border-radius: @border-radius-base;
 | 
			
		||||
 | 
			
		||||
        > .kinks-header {
 | 
			
		||||
            font-size: @font-size-h4;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.character-kink {
 | 
			
		||||
    .subkink-list {
 | 
			
		||||
        .well();
 | 
			
		||||
        margin-bottom: 0;
 | 
			
		||||
        padding: 5px 15px;
 | 
			
		||||
        cursor: default;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .subkink-list.closed {
 | 
			
		||||
        display: none;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &.subkink {
 | 
			
		||||
        cursor: pointer;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .comparison-active {
 | 
			
		||||
        border: 1px solid @quick-compare-active-border;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &.comparison-favorite {
 | 
			
		||||
        .comparison-active();
 | 
			
		||||
        background-color: @quick-compare-favorite-bg;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &.comparison-yes {
 | 
			
		||||
        .comparison-active();
 | 
			
		||||
        background-color: @quick-compare-yes-bg;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &.comparison-maybe {
 | 
			
		||||
        .comparison-active();
 | 
			
		||||
        background-color: @quick-compare-maybe-bg;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &.comparison-no {
 | 
			
		||||
        .comparison-active();
 | 
			
		||||
        background-color: @quick-compare-no-bg;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &.highlighted {
 | 
			
		||||
        font-weight: bolder;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#character-page-sidebar {
 | 
			
		||||
    background-color: @well-bg;
 | 
			
		||||
    height: 100%;
 | 
			
		||||
    margin-top: -20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Character Images
 | 
			
		||||
.character-images {
 | 
			
		||||
    .character-image {
 | 
			
		||||
        .col-xs-2();
 | 
			
		||||
        .img-thumbnail();
 | 
			
		||||
        vertical-align: middle;
 | 
			
		||||
        border: none;
 | 
			
		||||
        display: inline-block;
 | 
			
		||||
        background: transparent;
 | 
			
		||||
        img {
 | 
			
		||||
            .center-block();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Guestbook
 | 
			
		||||
.guestbookPager {
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    width: 50%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.characterSubTitle {
 | 
			
		||||
    font-size: @font-size-small;
 | 
			
		||||
    font-style: italic;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.characterPageName {
 | 
			
		||||
    font-size: @font-size-h3;
 | 
			
		||||
    font-weight: bold;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.characterImages {
 | 
			
		||||
    .container-fluid();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.characterPageImage {
 | 
			
		||||
    .col-xs-2();
 | 
			
		||||
    .img-thumbnail();
 | 
			
		||||
    border: none;
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    img {
 | 
			
		||||
        .center-block();
 | 
			
		||||
.guestbook {
 | 
			
		||||
    .guestbook-pager {
 | 
			
		||||
        display: inline-block;
 | 
			
		||||
        width: 50%;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.guestbook-post {
 | 
			
		||||
    .row();
 | 
			
		||||
}
 | 
			
		||||
    .guestbook-avatar {
 | 
			
		||||
        float: left;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
.guestbook-avatar {
 | 
			
		||||
    width: 50px;
 | 
			
		||||
    float: left;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.guestbook-contents {
 | 
			
		||||
    .well();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.guestbook-contents.deleted {
 | 
			
		||||
    .alert-warning();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.guestbook-reply {
 | 
			
		||||
    .guestbook-body {
 | 
			
		||||
        :before {
 | 
			
		||||
            content: "Reply: ";
 | 
			
		||||
    .guestbook-contents {
 | 
			
		||||
        .well();
 | 
			
		||||
        &.deleted {
 | 
			
		||||
            .alert-warning();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .guestbook-reply {
 | 
			
		||||
        &:before {
 | 
			
		||||
            content: "Reply ";
 | 
			
		||||
            font-weight: bold;
 | 
			
		||||
        }
 | 
			
		||||
        .reply-message {
 | 
			
		||||
            .well();
 | 
			
		||||
            .alert-info();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    .well();
 | 
			
		||||
    .alert-info();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#character-friends {
 | 
			
		||||
    .character-friend {
 | 
			
		||||
        display: inline-block;
 | 
			
		||||
        margin: 5px;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -190,4 +190,12 @@
 | 
			
		||||
 | 
			
		||||
.gender-cunt-boy {
 | 
			
		||||
    color: #00CC66;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#character-page-sidebar {
 | 
			
		||||
    margin-top: 0; // Fix up hack for merging the header on the character page, which doesn't work on chat.
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-viewer {
 | 
			
		||||
    width: 98%;
 | 
			
		||||
}
 | 
			
		||||
@ -5,13 +5,15 @@
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    height: auto;
 | 
			
		||||
    position: fixed;
 | 
			
		||||
    z-index: 900000;
 | 
			
		||||
    z-index: 9000;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.flash-message {
 | 
			
		||||
    .alert();
 | 
			
		||||
    position: relative;
 | 
			
		||||
    border-bottom-color: rgba(0, 0, 0, 0.3);
 | 
			
		||||
    margin-bottom: 0;
 | 
			
		||||
    z-index: 150;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.flash-message-enter-active, .flash-message-leave-active {
 | 
			
		||||
@ -38,4 +40,18 @@
 | 
			
		||||
 | 
			
		||||
.sidebar-top-padded {
 | 
			
		||||
    margin-top: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.force-word-wrapping {
 | 
			
		||||
    overflow-wrap: break-word;
 | 
			
		||||
    word-wrap: break-word;
 | 
			
		||||
 | 
			
		||||
    //-ms-word-break: break-all;
 | 
			
		||||
    word-break: break-word; // Non standard form used in some browsers.
 | 
			
		||||
    //word-break: break-all;
 | 
			
		||||
 | 
			
		||||
    -ms-hyphens: auto;
 | 
			
		||||
    -moz-hyphens: auto;
 | 
			
		||||
    -webkit-hyphens: auto;
 | 
			
		||||
    hyphens: auto;
 | 
			
		||||
}
 | 
			
		||||
@ -13,7 +13,13 @@ hr {
 | 
			
		||||
 | 
			
		||||
// Fix weird style where this is overwritten and cannot be styled inside a well.
 | 
			
		||||
.well {
 | 
			
		||||
    // The default of 19 doesn't match any existing elements, which use either 15 or @padding-vertical/horizontal-base
 | 
			
		||||
    padding: 15px;
 | 
			
		||||
    blockquote {
 | 
			
		||||
        border-color: @blockquote-border-color;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.well-lg {
 | 
			
		||||
    padding: 20px;
 | 
			
		||||
}
 | 
			
		||||
@ -1,3 +1,4 @@
 | 
			
		||||
// BBcode colors
 | 
			
		||||
@red-color: #f00;
 | 
			
		||||
@green-color: #0f0;
 | 
			
		||||
@blue-color: #00f;
 | 
			
		||||
@ -10,16 +11,28 @@
 | 
			
		||||
@pink-color: #faa;
 | 
			
		||||
@gray-color: #cccc;
 | 
			
		||||
@orange-color: #f60;
 | 
			
		||||
@collapse-header-bg: @well-bg;
 | 
			
		||||
@collapse-border: darken(@well-border, 25%);
 | 
			
		||||
 | 
			
		||||
@quickCompareActiveColor: @black-color;
 | 
			
		||||
@quickCompareFaveColor: @brand-success;
 | 
			
		||||
@quickCompareYesColor: @brand-info;
 | 
			
		||||
@quickCompareMaybeColor: @brand-warning;
 | 
			
		||||
@quickCompareNoColor: @brand-danger;
 | 
			
		||||
 | 
			
		||||
@characterListSelectedColor: @brand-success;
 | 
			
		||||
// Character page quick kink comparison
 | 
			
		||||
@quick-compare-active-border: @black-color;
 | 
			
		||||
@quick-compare-favorite-bg: @brand-success;
 | 
			
		||||
@quick-compare-yes-bg: @brand-info;
 | 
			
		||||
@quick-compare-maybe-bg: @brand-warning;
 | 
			
		||||
@quick-compare-no-bg: @brand-danger;
 | 
			
		||||
 | 
			
		||||
// character page badges
 | 
			
		||||
@character-badge-bg: darken(@well-bg, 10%);
 | 
			
		||||
@character-badge-border: darken(@well-border, 10%);
 | 
			
		||||
@character-badge-subscriber-bg: @alert-info-bg;
 | 
			
		||||
@character-badge-subscriber-border: @alert-info-border;
 | 
			
		||||
 | 
			
		||||
// Character editor
 | 
			
		||||
@character-list-selected-border: @brand-success;
 | 
			
		||||
@character-image-selected-border: @brand-success;
 | 
			
		||||
 | 
			
		||||
// Notes conversation view
 | 
			
		||||
@note-conversation-you-bg: @alert-info-bg;
 | 
			
		||||
@note-conversation-you-text: @alert-info-text;
 | 
			
		||||
@note-conversation-you-border: @alert-info-border;
 | 
			
		||||
@ -29,7 +42,6 @@
 | 
			
		||||
 | 
			
		||||
@nav-link-hover-color: @link-color;
 | 
			
		||||
 | 
			
		||||
@collapse-header-bg: @well-bg;
 | 
			
		||||
 | 
			
		||||
// General color extensions missing from bootstrap
 | 
			
		||||
@text-background-color: @body-bg;
 | 
			
		||||
@text-background-color-disabled: @gray-lighter;
 | 
			
		||||
@text-background-color-disabled: @gray-lighter;
 | 
			
		||||
@ -50,6 +50,7 @@
 | 
			
		||||
//@import "responsive-utilities.less";
 | 
			
		||||
@import "~font-awesome/less/font-awesome.less";
 | 
			
		||||
@import "../core.less";
 | 
			
		||||
@import "../character_page.less";
 | 
			
		||||
@import "../bbcode_editor.less";
 | 
			
		||||
@import "../bbcode.less";
 | 
			
		||||
@import "../flist_overrides.less";
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										167
									
								
								site/character_page/character_page.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								site/character_page/character_page.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,167 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div class="row character-page" id="pageBody">
 | 
			
		||||
        <div class="alert alert-info" v-show="loading">Loading character information.</div>
 | 
			
		||||
        <div class="alert alert-danger" v-show="error">{{error}}</div>
 | 
			
		||||
        <div class="col-xs-2" v-if="!loading">
 | 
			
		||||
            <sidebar :character="character" @memo="memo" @bookmarked="bookmarked"></sidebar>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="col-xs-10" v-if="!loading">
 | 
			
		||||
            <div id="characterView" class="row">
 | 
			
		||||
                <div>
 | 
			
		||||
                    <div v-if="character.ban_reason" id="headerBanReason" class="alert alert-warning">
 | 
			
		||||
                        This character has been banned and is not visible to the public. Reason:
 | 
			
		||||
                        <br/> {{ character.ban_reason }}
 | 
			
		||||
                        <template v-if="character.timeout"><br/>Timeout expires:
 | 
			
		||||
                            <date :time="character.timeout"></date>
 | 
			
		||||
                        </template>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div v-if="character.block_reason" id="headerBlocked" class="alert alert-warning">
 | 
			
		||||
                        This character has been blocked and is not visible to the public. Reason:
 | 
			
		||||
                        <br/> {{ character.block_reason }}
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div v-if="character.memo" id="headerCharacterMemo" class="alert alert-info">Memo: {{ character.memo.memo }}</div>
 | 
			
		||||
                    <ul class="nav nav-tabs" role="tablist" style="margin-bottom:5px">
 | 
			
		||||
                        <li role="presentation" class="active"><a href="#overview" aria-controls="overview" role="tab" data-toggle="tab">Overview</a>
 | 
			
		||||
                        </li>
 | 
			
		||||
                        <li role="presentation"><a href="#infotags" aria-controls="infotags" role="tab" data-toggle="tab">Info</a></li>
 | 
			
		||||
                        <li role="presentation" v-if="!hideGroups"><a href="#groups" aria-controls="groups" role="tab" data-toggle="tab">Groups</a></li>
 | 
			
		||||
                        <li role="presentation"><a href="#images" aria-controls="images" role="tab"
 | 
			
		||||
                            data-toggle="tab">Images ({{ character.character.image_count }})</a></li>
 | 
			
		||||
                        <li v-if="character.settings.guestbook" role="presentation"><a href="#guestbook" aria-controls="guestbook"
 | 
			
		||||
                            role="tab" data-toggle="tab">Guestbook</a></li>
 | 
			
		||||
                        <li v-if="character.is_self || character.settings.show_friends" role="presentation"><a href="#friends"
 | 
			
		||||
                            aria-controls="friends" role="tab" data-toggle="tab">Friends</a></li>
 | 
			
		||||
                    </ul>
 | 
			
		||||
 | 
			
		||||
                    <div class="tab-content">
 | 
			
		||||
                        <div role="tabpanel" class="tab-pane active" id="overview" aria-labeledby="overview-tab">
 | 
			
		||||
                            <div v-bbcode="character.character.description" class="well"></div>
 | 
			
		||||
                            <character-kinks :character="character"></character-kinks>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div role="tabpanel" class="tab-pane" id="infotags" aria-labeledby="infotags-tab">
 | 
			
		||||
                            <character-infotags :character="character"></character-infotags>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div role="tabpanel" class="tab-pane" id="groups" aria-labeledby="groups-tab" v-if="!hideGroups">
 | 
			
		||||
                            <character-groups :character="character" ref="groups"></character-groups>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div role="tabpanel" class="tab-pane" id="images" aria-labeledby="images-tab">
 | 
			
		||||
                            <character-images :character="character" ref="images"></character-images>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div v-if="character.settings.guestbook" role="tabpanel" class="tab-pane" id="guestbook"
 | 
			
		||||
                            aria-labeledby="guestbook-tab">
 | 
			
		||||
                            <character-guestbook :character="character" ref="guestbook"></character-guestbook>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div v-if="character.is_self || character.settings.show_friends" role="tabpanel" class="tab-pane" id="friends"
 | 
			
		||||
                            aria-labeledby="friends-tab">
 | 
			
		||||
                            <character-friends :character="character" ref="friends"></character-friends>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import Vue from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import {Prop, Watch} from 'vue-property-decorator';
 | 
			
		||||
    import {initCollapse, standardParser} from '../../bbcode/standard';
 | 
			
		||||
    import * as Utils from '../utils';
 | 
			
		||||
    import {methods, Store} from './data_store';
 | 
			
		||||
    import {Character, SharedStore} from './interfaces';
 | 
			
		||||
 | 
			
		||||
    import DateDisplay from '../../components/date_display.vue';
 | 
			
		||||
    import FriendsView from './friends.vue';
 | 
			
		||||
    import GroupsView from './groups.vue';
 | 
			
		||||
    import GuestbookView from './guestbook.vue';
 | 
			
		||||
    import ImagesView from './images.vue';
 | 
			
		||||
    import InfotagsView from './infotags.vue';
 | 
			
		||||
    import CharacterKinksView from './kinks.vue';
 | 
			
		||||
    import Sidebar from './sidebar.vue';
 | 
			
		||||
 | 
			
		||||
    interface ShowableVueTab extends Vue {
 | 
			
		||||
        show?(target: Element): void
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Component({
 | 
			
		||||
        components: {
 | 
			
		||||
            sidebar: Sidebar,
 | 
			
		||||
            date: DateDisplay,
 | 
			
		||||
            'character-friends': FriendsView,
 | 
			
		||||
            'character-guestbook': GuestbookView,
 | 
			
		||||
            'character-groups': GroupsView,
 | 
			
		||||
            'character-infotags': InfotagsView,
 | 
			
		||||
            'character-images': ImagesView,
 | 
			
		||||
            'character-kinks': CharacterKinksView
 | 
			
		||||
        }
 | 
			
		||||
    })
 | 
			
		||||
    export default class CharacterPage extends Vue {
 | 
			
		||||
        //tslint:disable:no-null-keyword
 | 
			
		||||
        @Prop()
 | 
			
		||||
        private readonly name?: string;
 | 
			
		||||
        @Prop()
 | 
			
		||||
        private readonly characterid?: number;
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        private readonly authenticated: boolean;
 | 
			
		||||
        @Prop()
 | 
			
		||||
        readonly hideGroups?: true;
 | 
			
		||||
        private shared: SharedStore = Store;
 | 
			
		||||
        private character: Character | null = null;
 | 
			
		||||
        loading = true;
 | 
			
		||||
        error = '';
 | 
			
		||||
 | 
			
		||||
        beforeMount(): void {
 | 
			
		||||
            this.shared.authenticated = this.authenticated;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        mounted(): void {
 | 
			
		||||
            if(this.character === null) this._getCharacter().then(); //tslint:disable-line:no-floating-promises
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        beforeDestroy(): void {
 | 
			
		||||
            $('a[data-toggle="tab"]').off('shown.bs.tab', (e) => this.switchTabHook(e));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        switchTabHook(evt: JQuery.Event): void {
 | 
			
		||||
            const targetId = (<HTMLElement>evt.target).getAttribute('aria-controls')!;
 | 
			
		||||
            //tslint:disable-next-line:strict-type-predicates no-unbound-method
 | 
			
		||||
            if(typeof this.$refs[targetId] !== 'undefined' && typeof (<ShowableVueTab>this.$refs[targetId]).show === 'function')
 | 
			
		||||
                (<ShowableVueTab>this.$refs[targetId]).show!(<Element>evt.target);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        @Watch('name')
 | 
			
		||||
        onCharacterSet(): void {
 | 
			
		||||
            this._getCharacter().then(); //tslint:disable-line:no-floating-promises
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        memo(memo: {id: number, memo: string}): void {
 | 
			
		||||
            Vue.set(this.character!, 'memo', memo);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        bookmarked(state: boolean): void {
 | 
			
		||||
            Vue.set(this.character!, 'bookmarked', state);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        private async _getCharacter(): Promise<void> {
 | 
			
		||||
            if(this.name === undefined || this.name.length === 0)
 | 
			
		||||
                return;
 | 
			
		||||
            try {
 | 
			
		||||
                this.loading = true;
 | 
			
		||||
                await methods.fieldsGet();
 | 
			
		||||
                this.character = await methods.characterData(this.name, this.characterid);
 | 
			
		||||
                standardParser.allowInlines = true;
 | 
			
		||||
                standardParser.inlines = this.character.character.inlines;
 | 
			
		||||
                this.loading = false;
 | 
			
		||||
                this.$nextTick(() => {
 | 
			
		||||
                    $('a[data-toggle="tab"]').on('shown.bs.tab', (e) => this.switchTabHook(e));
 | 
			
		||||
                    initCollapse();
 | 
			
		||||
                });
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                if(Utils.isJSONError(e))
 | 
			
		||||
                    this.error = <string>e.response.data.error;
 | 
			
		||||
                Utils.ajaxError(e, 'Failed to load character information.');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										53
									
								
								site/character_page/contact_method.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								site/character_page/contact_method.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,53 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div class="contact-method" :title="altText">
 | 
			
		||||
        <span v-if="contactLink" class="contact-link">
 | 
			
		||||
            <a :href="contactLink" target="_blank" rel="nofollow noreferrer noopener">
 | 
			
		||||
                <img :src="iconUrl"><span class="contact-value">{{value}}</span>
 | 
			
		||||
            </a>
 | 
			
		||||
        </span>
 | 
			
		||||
        <span v-else>
 | 
			
		||||
            <img :src="iconUrl"><span class="contact-value">{{value}}</span>
 | 
			
		||||
        </span>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import Vue from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import {Prop} from 'vue-property-decorator';
 | 
			
		||||
    import {formatContactLink, formatContactValue} from './contact_utils';
 | 
			
		||||
    import {methods, Store} from './data_store';
 | 
			
		||||
 | 
			
		||||
    interface DisplayContactMethod {
 | 
			
		||||
        id: number
 | 
			
		||||
        value: string
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Component
 | 
			
		||||
    export default class ContactMethodView extends Vue {
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        private readonly method: DisplayContactMethod;
 | 
			
		||||
 | 
			
		||||
        get iconUrl(): string {
 | 
			
		||||
            const infotag = Store.kinks.infotags[this.method.id];
 | 
			
		||||
            if(typeof infotag === 'undefined')
 | 
			
		||||
                return 'Unknown Infotag';
 | 
			
		||||
            return methods.contactMethodIconUrl(infotag.name);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get value(): string {
 | 
			
		||||
            return formatContactValue(this.method.id, this.method.value);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get altText(): string {
 | 
			
		||||
            const infotag = Store.kinks.infotags[this.method.id];
 | 
			
		||||
            if(typeof infotag === 'undefined')
 | 
			
		||||
                return '';
 | 
			
		||||
            return infotag.name;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get contactLink(): string | undefined {
 | 
			
		||||
            return formatContactLink(this.method.id, this.method.value);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										109
									
								
								site/character_page/contact_utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								site/character_page/contact_utils.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,109 @@
 | 
			
		||||
import {urlRegex as websitePattern} from '../../bbcode/core';
 | 
			
		||||
import {Store} from './data_store';
 | 
			
		||||
 | 
			
		||||
const daUsernamePattern = /^([a-z0-9_\-]+)$/i;
 | 
			
		||||
const daSitePattern = /^https?:\/\/([a-z0-9_\-]+)\.deviantart\.com\//i;
 | 
			
		||||
const emailPattern = /^((?:[a-z0-9])+(?:[a-z0-9\._-])*@(?:[a-z0-9_-])+(?:[a-z0-9\._-]+)+)$/i;
 | 
			
		||||
const faUsernamePattern = /^([a-z0-9_\-~.]+)$/i;
 | 
			
		||||
const faSitePattern = /^https?:\/\/(?:www\.)?furaffinity\.net\/user\/([a-z0-9_\-~,]+)\/?$/i;
 | 
			
		||||
const inkbunnyUsernamePattern = /^([a-z0-9]+)$/i;
 | 
			
		||||
const inkbunnySitePattern = /^https?:\/\/inkbunny\.net\/([a-z0-9]+)\/?$/i;
 | 
			
		||||
const skypeUsernamePattern = /^([a-z][a-z0-9.,\-_]*)/i;
 | 
			
		||||
const twitterUsernamePattern = /^([a-z0-9_]+)$/i;
 | 
			
		||||
const twitterSitePattern = /^https?:\/\/(?:www\.)?twitter\.com\/([a-z0-9_]+)\/?$/i;
 | 
			
		||||
const yimUsernamePattern = /^([a-z0-9_\-]+)$/i;
 | 
			
		||||
 | 
			
		||||
const daNormalize = normalizeSiteUsernamePair(daSitePattern, daUsernamePattern);
 | 
			
		||||
const faNormalize = normalizeSiteUsernamePair(faSitePattern, faUsernamePattern);
 | 
			
		||||
const inkbunnyNormalize = normalizeSiteUsernamePair(inkbunnySitePattern, inkbunnyUsernamePattern);
 | 
			
		||||
const twitterNormalize = normalizeSiteUsernamePair(twitterSitePattern, twitterUsernamePattern);
 | 
			
		||||
 | 
			
		||||
function normalizeSiteUsernamePair(site: RegExp, username: RegExp): (value: string) => string | undefined {
 | 
			
		||||
    return (value: string): string | undefined => {
 | 
			
		||||
        let matches = value.match(site);
 | 
			
		||||
        if(matches !== null && matches.length === 2)
 | 
			
		||||
            return matches[1];
 | 
			
		||||
        matches = value.match(username);
 | 
			
		||||
        if(matches !== null && matches.length === 2)
 | 
			
		||||
            return matches[1];
 | 
			
		||||
        return;
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function formatContactValue(id: number, value: string): string {
 | 
			
		||||
    const infotag = Store.kinks.infotags[id];
 | 
			
		||||
    if(typeof infotag === 'undefined')
 | 
			
		||||
        return value;
 | 
			
		||||
    const methodName = infotag.name.toLowerCase();
 | 
			
		||||
    const formatters: {[key: string]: (() => string | undefined) | undefined} = {
 | 
			
		||||
        deviantart(): string | undefined {
 | 
			
		||||
            return daNormalize(value);
 | 
			
		||||
        },
 | 
			
		||||
        furaffinity(): string | undefined {
 | 
			
		||||
            return faNormalize(value);
 | 
			
		||||
        },
 | 
			
		||||
        inkbunny(): string | undefined {
 | 
			
		||||
            return inkbunnyNormalize(value);
 | 
			
		||||
        },
 | 
			
		||||
        twitter(): string | undefined {
 | 
			
		||||
            return twitterNormalize(value);
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
    if(typeof formatters[methodName] === 'function') {
 | 
			
		||||
        const formatted = formatters[methodName]!();
 | 
			
		||||
        return formatted !== undefined ? formatted : value;
 | 
			
		||||
    }
 | 
			
		||||
    return value;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function formatContactLink(id: number, value: string): string | undefined {
 | 
			
		||||
    const infotag = Store.kinks.infotags[id];
 | 
			
		||||
    if(typeof infotag === 'undefined')
 | 
			
		||||
        return;
 | 
			
		||||
    const methodName = infotag.name.toLowerCase();
 | 
			
		||||
    const formatters: {[key: string]: (() => string | undefined) | undefined} = {
 | 
			
		||||
        deviantart(): string | undefined {
 | 
			
		||||
            const username = daNormalize(value);
 | 
			
		||||
            if(username !== undefined)
 | 
			
		||||
                return `https://${username}.deviantart.com/`;
 | 
			
		||||
        },
 | 
			
		||||
        'e-mail'(): string | undefined {
 | 
			
		||||
            const matches = value.match(emailPattern);
 | 
			
		||||
            if(matches !== null && matches.length === 2)
 | 
			
		||||
                return `mailto:${value}`;
 | 
			
		||||
        },
 | 
			
		||||
        furaffinity(): string | undefined {
 | 
			
		||||
            const username = faNormalize(value);
 | 
			
		||||
            if(username !== undefined)
 | 
			
		||||
                return `https://www.furaffinity.net/user/${username}`;
 | 
			
		||||
        },
 | 
			
		||||
        inkbunny(): string | undefined {
 | 
			
		||||
            const username = inkbunnyNormalize(value);
 | 
			
		||||
            if(username !== undefined)
 | 
			
		||||
                return `https://inkbunny.net/${username}`;
 | 
			
		||||
        },
 | 
			
		||||
        skype(): string | undefined {
 | 
			
		||||
            const matches = value.match(skypeUsernamePattern);
 | 
			
		||||
            if(matches !== null && matches.length === 2)
 | 
			
		||||
                return `skype:${value}?chat`;
 | 
			
		||||
        },
 | 
			
		||||
        twitter(): string | undefined {
 | 
			
		||||
            const username = twitterNormalize(value);
 | 
			
		||||
            if(username !== undefined)
 | 
			
		||||
                return `https://twitter.com/${username}`;
 | 
			
		||||
        },
 | 
			
		||||
        website(): string | undefined {
 | 
			
		||||
            const matches = value.match(websitePattern);
 | 
			
		||||
            if(matches !== null && matches.length === 2)
 | 
			
		||||
                return value;
 | 
			
		||||
        },
 | 
			
		||||
        yim(): string | undefined {
 | 
			
		||||
            const matches = value.match(yimUsernamePattern);
 | 
			
		||||
            if(matches !== null && matches.length === 2)
 | 
			
		||||
                return `ymsg:sendIM?${value}`;
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
    if(typeof formatters[methodName] === 'function')
 | 
			
		||||
        return formatters[methodName]!();
 | 
			
		||||
    return;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										91
									
								
								site/character_page/context_menu.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								site/character_page/context_menu.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,91 @@
 | 
			
		||||
import Vue from 'vue';
 | 
			
		||||
 | 
			
		||||
export default abstract class ContextMenu extends Vue {
 | 
			
		||||
    //tslint:disable:no-null-keyword
 | 
			
		||||
    abstract propName: string;
 | 
			
		||||
    showMenu = false;
 | 
			
		||||
    private position = {left: 0, top: 0};
 | 
			
		||||
    private selectedItem: HTMLElement | null;
 | 
			
		||||
    private touchTimer: number;
 | 
			
		||||
 | 
			
		||||
    abstract itemSelected(element: HTMLElement): void;
 | 
			
		||||
 | 
			
		||||
    shouldShowMenu(_: HTMLElement): boolean {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    hideMenu(): void {
 | 
			
		||||
        this.showMenu = false;
 | 
			
		||||
        this.selectedItem = null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    bindOffclick(): void {
 | 
			
		||||
        document.body.addEventListener('click', () => this.hideMenu());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private fixPosition(e: MouseEvent | Touch): void {
 | 
			
		||||
        const getMenuPosition = (input: number, direction: string): number => {
 | 
			
		||||
            const win = (<Window & {[key: string]: number}>window)[`inner${direction}`];
 | 
			
		||||
            const menu = (<HTMLElement & {[key: string]: number}>this.$refs['menu'])[`offset${direction}`];
 | 
			
		||||
            let position = input;
 | 
			
		||||
 | 
			
		||||
            if(input + menu > win)
 | 
			
		||||
                position = win - menu - 5;
 | 
			
		||||
 | 
			
		||||
            return position;
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        const left = getMenuPosition(e.clientX, 'Width');
 | 
			
		||||
        const top = getMenuPosition(e.clientY, 'Height');
 | 
			
		||||
        this.position = {left, top};
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected innerClick(): void {
 | 
			
		||||
        this.itemSelected(this.selectedItem!);
 | 
			
		||||
        this.hideMenu();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    outerClick(event: MouseEvent | TouchEvent): void {
 | 
			
		||||
        // Provide an opt-out
 | 
			
		||||
        if(event.ctrlKey) return;
 | 
			
		||||
        if(event.type === 'touchend') window.clearTimeout(this.touchTimer);
 | 
			
		||||
        const targetingEvent = event instanceof TouchEvent ? event.touches[0] : event;
 | 
			
		||||
        const findTarget = (): HTMLElement | undefined => {
 | 
			
		||||
            let element = <HTMLElement>targetingEvent.target;
 | 
			
		||||
            while(element !== document.body) {
 | 
			
		||||
                if(typeof element.dataset[this.propName] !== 'undefined' || element.parentElement === null) break;
 | 
			
		||||
                element = element.parentElement;
 | 
			
		||||
            }
 | 
			
		||||
            return typeof element.dataset[this.propName] === 'undefined' ? undefined : element;
 | 
			
		||||
        };
 | 
			
		||||
        const target = findTarget();
 | 
			
		||||
        if(target === undefined) {
 | 
			
		||||
            this.hideMenu();
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        switch(event.type) {
 | 
			
		||||
            case 'click':
 | 
			
		||||
            case 'contextmenu':
 | 
			
		||||
                this.openMenu(targetingEvent, target);
 | 
			
		||||
                break;
 | 
			
		||||
            case 'touchstart':
 | 
			
		||||
                this.touchTimer = window.setTimeout(() => this.openMenu(targetingEvent, target), 500);
 | 
			
		||||
        }
 | 
			
		||||
        event.preventDefault();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private openMenu(event: MouseEvent | Touch, element: HTMLElement): void {
 | 
			
		||||
        if(!this.shouldShowMenu(element))
 | 
			
		||||
            return;
 | 
			
		||||
        this.showMenu = true;
 | 
			
		||||
        this.selectedItem = element;
 | 
			
		||||
        this.$nextTick(() => {
 | 
			
		||||
            this.fixPosition(event);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    get positionText(): string {
 | 
			
		||||
        return `left: ${this.position.left}px; top: ${this.position.top}px;`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										100
									
								
								site/character_page/copy_custom_dialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								site/character_page/copy_custom_dialog.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,100 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div id="copyCustomDialog" tabindex="-1" class="modal" ref="dialog">
 | 
			
		||||
        <div class="modal-dialog">
 | 
			
		||||
            <div class="modal-content">
 | 
			
		||||
                <div class="modal-header">
 | 
			
		||||
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">×</button>
 | 
			
		||||
                    <h4 class="modal-title">Copy Custom Kink</h4>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="modal-body form-horizontal">
 | 
			
		||||
                    <form-errors :errors="errors" field="name">
 | 
			
		||||
                        <label class="col-xs-3 control-label">Name:</label>
 | 
			
		||||
 | 
			
		||||
                        <div class="col-xs-9">
 | 
			
		||||
                            <input type="text" class="form-control" maxlength="60" v-model="name"/>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </form-errors>
 | 
			
		||||
                    <form-errors :errors="errors" field="description">
 | 
			
		||||
                        <label class="col-xs-3 control-label">Description:</label>
 | 
			
		||||
 | 
			
		||||
                        <div class="col-xs-9">
 | 
			
		||||
                            <input type="text" class="form-control" maxlength="60" v-model="description"/>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </form-errors>
 | 
			
		||||
                    <form-errors :errors="errors" field="choice">
 | 
			
		||||
                        <label class="col-xs-3 control-label">Choice:</label>
 | 
			
		||||
                        <div class="col-xs-9">
 | 
			
		||||
                            <select v-model="choice" class="form-control">
 | 
			
		||||
                                <option value="favorite">Favorite</option>
 | 
			
		||||
                                <option value="yes">Yes</option>
 | 
			
		||||
                                <option value="maybe">Maybe</option>
 | 
			
		||||
                                <option value="no">No</option>
 | 
			
		||||
                            </select>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </form-errors>
 | 
			
		||||
                    <form-errors :errors="errors" field="target">
 | 
			
		||||
                        <label class="col-xs-3 control-label">Target Character:</label>
 | 
			
		||||
                        <div class="col-xs-9">
 | 
			
		||||
                            <character-select v-model="target"></character-select>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </form-errors>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="modal-footer">
 | 
			
		||||
                    <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
 | 
			
		||||
                    <button type="button" class="btn btn-success" @click="copyCustom"
 | 
			
		||||
                        :disabled="!valid || submitting">
 | 
			
		||||
                        Copy Custom Kink
 | 
			
		||||
                    </button>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import Vue from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import FormErrors from '../../components/form_errors.vue';
 | 
			
		||||
    import * as Utils from '../utils';
 | 
			
		||||
    import {methods} from './data_store';
 | 
			
		||||
    import {KinkChoice} from './interfaces';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    @Component({
 | 
			
		||||
        components: {
 | 
			
		||||
            'form-errors': FormErrors
 | 
			
		||||
        }
 | 
			
		||||
    })
 | 
			
		||||
    export default class CopyCustomDialog extends Vue {
 | 
			
		||||
        private name = '';
 | 
			
		||||
        private description = '';
 | 
			
		||||
        private choice: KinkChoice = 'favorite';
 | 
			
		||||
        private target = Utils.Settings.defaultCharacter;
 | 
			
		||||
        errors = {};
 | 
			
		||||
        submitting = false;
 | 
			
		||||
 | 
			
		||||
        show(name: string, description: string): void {
 | 
			
		||||
            this.name = name;
 | 
			
		||||
            this.description = description;
 | 
			
		||||
            $(this.$refs['dialog']).modal('show');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        async copyCustom(): Promise<void> {
 | 
			
		||||
            try {
 | 
			
		||||
                this.errors = {};
 | 
			
		||||
                this.submitting = true;
 | 
			
		||||
                await methods.characterCustomKinkAdd(this.target, this.name, this.description, this.choice);
 | 
			
		||||
                this.submitting = false;
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                this.submitting = false;
 | 
			
		||||
                Utils.ajaxError(e, 'Unable to copy custom kink');
 | 
			
		||||
                if(Utils.isJSONError(e))
 | 
			
		||||
                    this.errors = e.response.data;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get valid(): boolean {
 | 
			
		||||
            return this.name.length > 0 && this.description.length > 0;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										47
									
								
								site/character_page/copy_custom_menu.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								site/character_page/copy_custom_menu.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,47 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div>
 | 
			
		||||
        <ul class="dropdown-menu" role="menu" @click="innerClick($event)" @touchstart="innerClick($event)" @touchend="innerClick($event)"
 | 
			
		||||
            style="position: fixed; display: block;" :style="positionText" ref="menu" v-show="showMenu">
 | 
			
		||||
            <li><a href="#">Copy Custom</a></li>
 | 
			
		||||
        </ul>
 | 
			
		||||
        <copy-dialog ref="copy-dialog"></copy-dialog>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import Vue from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import {Prop} from 'vue-property-decorator';
 | 
			
		||||
    import ContextMenu from './context_menu';
 | 
			
		||||
    import CopyCustomDialog from './copy_custom_dialog.vue';
 | 
			
		||||
 | 
			
		||||
    interface ShowableCustomVueDialog extends Vue {
 | 
			
		||||
        show(name: string, description: string): void
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Component({
 | 
			
		||||
        components: {
 | 
			
		||||
            'copy-dialog': CopyCustomDialog
 | 
			
		||||
        }
 | 
			
		||||
    })
 | 
			
		||||
    export default class CopyCustomMenu extends ContextMenu {
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        readonly propName: string;
 | 
			
		||||
 | 
			
		||||
        itemSelected(element: HTMLElement): void {
 | 
			
		||||
            const getName = (children: ReadonlyArray<HTMLElement>): string => {
 | 
			
		||||
                for(const child of children)
 | 
			
		||||
                    if(child.className === 'kink-name')
 | 
			
		||||
                        return child.textContent!;
 | 
			
		||||
                return 'Unknown';
 | 
			
		||||
            };
 | 
			
		||||
            const name = getName(<any>element.children); //tslint:disable-line:no-any
 | 
			
		||||
            const description = element.title;
 | 
			
		||||
            (<ShowableCustomVueDialog>this.$refs['copy-dialog']).show(name, description);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        mounted(): void {
 | 
			
		||||
            this.bindOffclick();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										19
									
								
								site/character_page/data_store.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								site/character_page/data_store.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,19 @@
 | 
			
		||||
import {Component} from 'vue';
 | 
			
		||||
import {SharedStore, StoreMethods} from './interfaces';
 | 
			
		||||
 | 
			
		||||
export let Store: SharedStore = {
 | 
			
		||||
    kinks: <any>undefined, //tslint:disable-line:no-any
 | 
			
		||||
    authenticated: false
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const registeredComponents: {[key: string]: Component | undefined} = {};
 | 
			
		||||
 | 
			
		||||
export function registerComponent(name: string, component: Component): void {
 | 
			
		||||
    registeredComponents[name] = component;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function registerMethod<K extends keyof StoreMethods>(name: K, func: StoreMethods[K]): void {
 | 
			
		||||
    methods[name] = func;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const methods: StoreMethods = <StoreMethods>{}; //tslint:disable-line:no-object-literal-type-assertion
 | 
			
		||||
							
								
								
									
										57
									
								
								site/character_page/delete_dialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								site/character_page/delete_dialog.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,57 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div id="deleteDialog" tabindex="-1" class="modal" ref="dialog">
 | 
			
		||||
        <div class="modal-dialog">
 | 
			
		||||
            <div class="modal-content">
 | 
			
		||||
                <div class="modal-header">
 | 
			
		||||
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">×</button>
 | 
			
		||||
                    <h4 class="modal-title">Delete character {{name}}</h4>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="modal-body">
 | 
			
		||||
                    Are you sure you want to permanently delete {{ name }}?<br/>
 | 
			
		||||
                    Character deletion cannot be undone for any reason.
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="modal-footer">
 | 
			
		||||
                    <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
 | 
			
		||||
                    <button type="button" class="btn btn-danger" @click="deleteCharacter" :disabled="deleting">Delete Character</button>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import Vue from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import {Prop} from 'vue-property-decorator';
 | 
			
		||||
    import * as Utils from '../utils';
 | 
			
		||||
    import {methods} from './data_store';
 | 
			
		||||
    import {Character} from './interfaces';
 | 
			
		||||
 | 
			
		||||
    @Component
 | 
			
		||||
    export default class DeleteDialog extends Vue {
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        private readonly character: Character;
 | 
			
		||||
 | 
			
		||||
        deleting = false;
 | 
			
		||||
 | 
			
		||||
        get name(): string {
 | 
			
		||||
            return this.character.character.name;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        show(): void {
 | 
			
		||||
            $(this.$refs['dialog']).modal('show');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        async deleteCharacter(): Promise<void> {
 | 
			
		||||
            try {
 | 
			
		||||
                this.deleting = true;
 | 
			
		||||
                await methods.characterDelete(this.character.character.id);
 | 
			
		||||
                $(this.$refs['dialog']).modal('hide');
 | 
			
		||||
                window.location.assign('/');
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                Utils.ajaxError(e, 'Unable to delete character');
 | 
			
		||||
            }
 | 
			
		||||
            this.deleting = false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										108
									
								
								site/character_page/duplicate_dialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								site/character_page/duplicate_dialog.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,108 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div id="duplicateDialog" tabindex="-1" class="modal" ref="dialog">
 | 
			
		||||
        <div class="modal-dialog">
 | 
			
		||||
            <div class="modal-content">
 | 
			
		||||
                <div class="modal-header">
 | 
			
		||||
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">×</button>
 | 
			
		||||
                    <h4 class="modal-title">Duplicate character {{name}}</h4>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="modal-body form-horizontal">
 | 
			
		||||
                    <p>This will duplicate the character, kinks, infotags, customs, subkinks and images. Guestbook
 | 
			
		||||
                        entries, friends, groups, and bookmarks are not duplicated.</p>
 | 
			
		||||
                    <div class="form-group">
 | 
			
		||||
                        <label class="col-xs-1 control-label">Name:</label>
 | 
			
		||||
 | 
			
		||||
                        <div class="col-xs-5">
 | 
			
		||||
                            <input type="text" class="form-control" maxlength="60" v-model="newName"
 | 
			
		||||
                                :class="{'has-error': error}"/>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div class="col-xs-2">
 | 
			
		||||
                            <button type="button" class="btn btn-default" @click="checkName"
 | 
			
		||||
                                :disabled="newName.length < 2 || checking">
 | 
			
		||||
                                Check Name
 | 
			
		||||
                            </button>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div class="col-xs-3">
 | 
			
		||||
                            <ul>
 | 
			
		||||
                                <li v-show="valid" class="text-success">Name available and valid.</li>
 | 
			
		||||
                            </ul>
 | 
			
		||||
                            <ul>
 | 
			
		||||
                                <li v-show="error" class="text-danger">{{ error }}</li>
 | 
			
		||||
                            </ul>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="modal-footer">
 | 
			
		||||
                    <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
 | 
			
		||||
                    <button type="button" class="btn btn-success" @click="duplicate"
 | 
			
		||||
                        :disabled="duplicating || checking">
 | 
			
		||||
                        Duplicate Character
 | 
			
		||||
                    </button>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import Vue from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import {Prop} from 'vue-property-decorator';
 | 
			
		||||
    import * as Utils from '../utils';
 | 
			
		||||
    import {methods} from './data_store';
 | 
			
		||||
    import {Character} from './interfaces';
 | 
			
		||||
 | 
			
		||||
    @Component
 | 
			
		||||
    export default class DuplicateDialog extends Vue {
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        private readonly character: Character;
 | 
			
		||||
 | 
			
		||||
        error = '';
 | 
			
		||||
        private newName = '';
 | 
			
		||||
        valid = false;
 | 
			
		||||
 | 
			
		||||
        checking = false;
 | 
			
		||||
        duplicating = false;
 | 
			
		||||
 | 
			
		||||
        get name(): string {
 | 
			
		||||
            return this.character.character.name;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        show(): void {
 | 
			
		||||
            $(this.$refs['dialog']).modal('show');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        async checkName(): Promise<boolean> {
 | 
			
		||||
            try {
 | 
			
		||||
                this.checking = true;
 | 
			
		||||
                const result = await methods.characterNameCheck(this.newName);
 | 
			
		||||
                this.valid = result.valid;
 | 
			
		||||
                this.error = '';
 | 
			
		||||
                return true;
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                this.valid = false;
 | 
			
		||||
                this.error = '';
 | 
			
		||||
                if(Utils.isJSONError(e))
 | 
			
		||||
                    this.error = <string>e.response.data.error;
 | 
			
		||||
                return false;
 | 
			
		||||
            } finally {
 | 
			
		||||
                this.checking = false;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        async duplicate(): Promise<void> {
 | 
			
		||||
            try {
 | 
			
		||||
                this.duplicating = true;
 | 
			
		||||
                const result = await methods.characterDuplicate(this.character.character.id, this.newName);
 | 
			
		||||
                $(this.$refs['dialog']).modal('hide');
 | 
			
		||||
                window.location.assign(result.next);
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                Utils.ajaxError(e, 'Unable to duplicate character');
 | 
			
		||||
                this.valid = false;
 | 
			
		||||
                if(Utils.isJSONError(e))
 | 
			
		||||
                    this.error = <string>e.response.data.error;
 | 
			
		||||
            }
 | 
			
		||||
            this.duplicating = false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										189
									
								
								site/character_page/friend_dialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										189
									
								
								site/character_page/friend_dialog.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,189 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div id="friendDialog" tabindex="-1" class="modal" ref="dialog">
 | 
			
		||||
        <div class="modal-dialog">
 | 
			
		||||
            <div class="modal-content">
 | 
			
		||||
                <div class="modal-header">
 | 
			
		||||
                    <button type="button" class="close" data-dismiss="modal"
 | 
			
		||||
                        aria-label="Close">×
 | 
			
		||||
                    </button>
 | 
			
		||||
                    <h4 class="modal-title">Friends for {{name}}</h4>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="modal-body">
 | 
			
		||||
                    <div v-show="loading" class="alert alert-info">Loading friend information.</div>
 | 
			
		||||
                    <div v-show="error" class="alert alert-danger">{{error}}</div>
 | 
			
		||||
                    <template v-if="!loading">
 | 
			
		||||
                        <div v-if="existing.length" class="well">
 | 
			
		||||
                            <h4>Existing Friendships</h4>
 | 
			
		||||
                            <hr>
 | 
			
		||||
                            <div v-for="friend in existing">
 | 
			
		||||
                                <character-link :character="request.source"><img class="character-avatar icon"
 | 
			
		||||
                                    :src="avatarUrl(request.source.name)"/>
 | 
			
		||||
                                    {{request.source.name}}
 | 
			
		||||
                                </character-link>
 | 
			
		||||
                                Since:
 | 
			
		||||
                                <date-display :time="friend.createdAt"></date-display>
 | 
			
		||||
                                <button type="button" class="btn btn-danger"
 | 
			
		||||
                                    @click="dissolve(friend)">
 | 
			
		||||
                                    Remove
 | 
			
		||||
                                </button>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div v-if="pending.length" class="well">
 | 
			
		||||
                            <h4>Pending Requests To Character</h4>
 | 
			
		||||
                            <hr>
 | 
			
		||||
                            <div v-for="request in pending">
 | 
			
		||||
                                <character-link :character="request.source"><img class="character-avatar icon"
 | 
			
		||||
                                    :src="avatarUrl(request.source.name)"/>
 | 
			
		||||
                                    {{request.source.name}}
 | 
			
		||||
                                </character-link>
 | 
			
		||||
                                Sent:
 | 
			
		||||
                                <date-display :time="request.createdAt"></date-display>
 | 
			
		||||
                                <button type="button" class="btn btn-danger"
 | 
			
		||||
                                    @click="cancel(request)">
 | 
			
		||||
                                    Cancel
 | 
			
		||||
                                </button>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div v-if="incoming.length" class="well">
 | 
			
		||||
                            <h4>Pending Requests From Character</h4>
 | 
			
		||||
                            <hr>
 | 
			
		||||
                            <div v-for="request in incoming">
 | 
			
		||||
                                <character-link :character="request.target"><img class="character-avatar icon"
 | 
			
		||||
                                    :src="avatarUrl(request.target.name)"/>
 | 
			
		||||
                                    {{request.target.name}}
 | 
			
		||||
                                </character-link>
 | 
			
		||||
                                Sent:
 | 
			
		||||
                                <date-display :time="request.createdAt"></date-display>
 | 
			
		||||
                                <button type="button" class="btn btn-success acceptFriend"
 | 
			
		||||
                                    @click="accept(request)">
 | 
			
		||||
                                    Accept
 | 
			
		||||
                                </button>
 | 
			
		||||
                                <button type="button" class="btn btn-danger ignoreFriend"
 | 
			
		||||
                                    @click="ignore(request)">
 | 
			
		||||
                                    Ignore
 | 
			
		||||
                                </button>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div class="well">
 | 
			
		||||
                            <h4>Request Friendship</h4>
 | 
			
		||||
                            <hr>
 | 
			
		||||
                            <div class="form-inline">
 | 
			
		||||
                                <label class="control-label"
 | 
			
		||||
                                    for="friendRequestCharacter">Character: </label>
 | 
			
		||||
                                <character-select id="friendRequestCharacter" v-model="ourCharacter"></character-select>
 | 
			
		||||
                                <button @click="request" class="btn btn-default" :disable="requesting || !ourCharacter">Request</button>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </template>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="modal-footer">
 | 
			
		||||
                    <button type="button" class="btn btn-default" data-dismiss="modal">
 | 
			
		||||
                        Close
 | 
			
		||||
                    </button>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import Vue from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import {Prop} from 'vue-property-decorator';
 | 
			
		||||
    import * as Utils from '../utils';
 | 
			
		||||
    import {methods} from './data_store';
 | 
			
		||||
    import {Character, Friend, FriendRequest} from './interfaces';
 | 
			
		||||
 | 
			
		||||
    @Component
 | 
			
		||||
    export default class FriendDialog extends Vue {
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        private readonly character: Character;
 | 
			
		||||
 | 
			
		||||
        private ourCharacter = Utils.Settings.defaultCharacter;
 | 
			
		||||
 | 
			
		||||
        private incoming: FriendRequest[] = [];
 | 
			
		||||
        private pending: FriendRequest[] = [];
 | 
			
		||||
        private existing: Friend[] = [];
 | 
			
		||||
 | 
			
		||||
        requesting = false;
 | 
			
		||||
        loading = true;
 | 
			
		||||
        error = '';
 | 
			
		||||
 | 
			
		||||
        avatarUrl = Utils.avatarURL;
 | 
			
		||||
 | 
			
		||||
        get name(): string {
 | 
			
		||||
            return this.character.character.name;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        async request(): Promise<void> {
 | 
			
		||||
            try {
 | 
			
		||||
                this.requesting = true;
 | 
			
		||||
                const newRequest = await methods.friendRequest(this.character.character.id, this.ourCharacter);
 | 
			
		||||
                this.pending.push(newRequest);
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                if(Utils.isJSONError(e))
 | 
			
		||||
                    this.error = <string>e.response.data.error;
 | 
			
		||||
                Utils.ajaxError(e, 'Unable to send friend request');
 | 
			
		||||
            }
 | 
			
		||||
            this.requesting = false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        async dissolve(friendship: Friend): Promise<void> {
 | 
			
		||||
            try {
 | 
			
		||||
                await methods.friendDissolve(friendship.id);
 | 
			
		||||
                this.existing = Utils.filterOut(this.existing, 'id', friendship.id);
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                if(Utils.isJSONError(e))
 | 
			
		||||
                    this.error = <string>e.response.data.error;
 | 
			
		||||
                Utils.ajaxError(e, 'Unable to dissolve friendship');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        async accept(request: FriendRequest): Promise<void> {
 | 
			
		||||
            try {
 | 
			
		||||
                const friend = await methods.friendRequestAccept(request.id);
 | 
			
		||||
                this.existing.push(friend);
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                if(Utils.isJSONError(e))
 | 
			
		||||
                    this.error = <string>e.response.data.error;
 | 
			
		||||
                Utils.ajaxError(e, 'Unable to accept friend request');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        async cancel(request: FriendRequest): Promise<void> {
 | 
			
		||||
            try {
 | 
			
		||||
                await methods.friendRequestCancel(request.id);
 | 
			
		||||
                this.pending = Utils.filterOut(this.pending, 'id', request.id);
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                if(Utils.isJSONError(e))
 | 
			
		||||
                    this.error = <string>e.response.data.error;
 | 
			
		||||
                Utils.ajaxError(e, 'Unable to cancel friend request');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        async ignore(request: FriendRequest): Promise<void> {
 | 
			
		||||
            try {
 | 
			
		||||
                await methods.friendRequestIgnore(request.id);
 | 
			
		||||
                this.incoming = Utils.filterOut(this.incoming, 'id', request.id);
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                if(Utils.isJSONError(e))
 | 
			
		||||
                    this.error = <string>e.response.data.error;
 | 
			
		||||
                Utils.ajaxError(e, 'Unable to ignore friend request');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        async show(): Promise<void> {
 | 
			
		||||
            $(this.$refs['dialog']).modal('show');
 | 
			
		||||
            try {
 | 
			
		||||
                this.loading = true;
 | 
			
		||||
                const friendData = await methods.characterFriends(this.character.character.id);
 | 
			
		||||
                this.incoming = friendData.incoming;
 | 
			
		||||
                this.pending = friendData.pending;
 | 
			
		||||
                this.existing = friendData.existing;
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                Utils.ajaxError(e, 'Unable to load character friendship information');
 | 
			
		||||
            }
 | 
			
		||||
            this.loading = false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										49
									
								
								site/character_page/friends.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								site/character_page/friends.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,49 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div id="character-friends">
 | 
			
		||||
        <div v-show="loading" class="alert alert-info">Loading friends.</div>
 | 
			
		||||
        <template v-if="!loading">
 | 
			
		||||
            <div class="character-friend" v-for="friend in friends" :key="friend.id">
 | 
			
		||||
                <a :href="characterUrl(friend.name)"><img class="character-avatar" :src="avatarUrl(friend.name)" :title="friend.name"></a>
 | 
			
		||||
            </div>
 | 
			
		||||
        </template>
 | 
			
		||||
        <div v-if="!loading && !friends.length" class="alert alert-info">No friends to display.</div>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import Vue from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import {Prop} from 'vue-property-decorator';
 | 
			
		||||
    import * as Utils from '../utils';
 | 
			
		||||
    import {methods} from './data_store';
 | 
			
		||||
    import {Character, CharacterFriend} from './interfaces';
 | 
			
		||||
 | 
			
		||||
    @Component
 | 
			
		||||
    export default class FriendsView extends Vue {
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        private readonly character: Character;
 | 
			
		||||
        private shown = false;
 | 
			
		||||
        friends: CharacterFriend[] = [];
 | 
			
		||||
        loading = true;
 | 
			
		||||
        error = '';
 | 
			
		||||
 | 
			
		||||
        avatarUrl = Utils.avatarURL;
 | 
			
		||||
        characterUrl = Utils.characterURL;
 | 
			
		||||
 | 
			
		||||
        async show(): Promise<void> {
 | 
			
		||||
            if(this.shown) return;
 | 
			
		||||
            try {
 | 
			
		||||
                this.error = '';
 | 
			
		||||
                this.shown = true;
 | 
			
		||||
                this.loading = true;
 | 
			
		||||
                this.friends = await methods.friendsGet(this.character.character.id);
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                this.shown = false;
 | 
			
		||||
                if(Utils.isJSONError(e))
 | 
			
		||||
                    this.error = <string>e.response.data.error;
 | 
			
		||||
                Utils.ajaxError(e, 'Unable to load friends.');
 | 
			
		||||
            }
 | 
			
		||||
            this.loading = false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										50
									
								
								site/character_page/groups.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								site/character_page/groups.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,50 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div class="character-groups">
 | 
			
		||||
        <div v-show="loading" class="alert alert-info">Loading groups.</div>
 | 
			
		||||
        <template v-if="!loading">
 | 
			
		||||
            <div class="character-group" v-for="group in groups" :key="group.id">
 | 
			
		||||
                <a :href="groupUrl(group)">{{group.title}}: {{group.threadCount}}</a>
 | 
			
		||||
            </div>
 | 
			
		||||
        </template>
 | 
			
		||||
        <div v-if="!loading && !groups.length" class="alert alert-info">No groups.</div>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import Vue from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import {Prop} from 'vue-property-decorator';
 | 
			
		||||
    import * as Utils from '../utils';
 | 
			
		||||
    import {methods} from './data_store';
 | 
			
		||||
    import {Character, CharacterGroup} from './interfaces';
 | 
			
		||||
 | 
			
		||||
    @Component
 | 
			
		||||
    export default class GroupsView extends Vue {
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        private readonly character: Character;
 | 
			
		||||
        private shown = false;
 | 
			
		||||
        groups: CharacterGroup[] = [];
 | 
			
		||||
        loading = true;
 | 
			
		||||
        error = '';
 | 
			
		||||
 | 
			
		||||
        groupUrl(group: CharacterGroup): string {
 | 
			
		||||
            return `${Utils.staticDomain}threads/group/${group.id}`;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        async show(): Promise<void> {
 | 
			
		||||
            if(this.shown) return;
 | 
			
		||||
            try {
 | 
			
		||||
                this.error = '';
 | 
			
		||||
                this.shown = true;
 | 
			
		||||
                this.loading = true;
 | 
			
		||||
                this.groups = await methods.groupsGet(this.character.character.id);
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                this.shown = false;
 | 
			
		||||
                if(Utils.isJSONError(e))
 | 
			
		||||
                    this.error = <string>e.response.data.error;
 | 
			
		||||
                Utils.ajaxError(e, 'Unable to load groups.');
 | 
			
		||||
            }
 | 
			
		||||
            this.loading = false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										143
									
								
								site/character_page/guestbook.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										143
									
								
								site/character_page/guestbook.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,143 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div class="guestbook">
 | 
			
		||||
        <div v-show="loading" class="alert alert-info">Loading guestbook.</div>
 | 
			
		||||
        <div class="guestbook-controls">
 | 
			
		||||
            <label v-show="canEdit" class="control-label">Unapproved only:
 | 
			
		||||
                <input type="checkbox" v-model="unapprovedOnly"/>
 | 
			
		||||
            </label>
 | 
			
		||||
            <nav>
 | 
			
		||||
                <ul class="pager">
 | 
			
		||||
                    <li class="previous" v-show="page > 1">
 | 
			
		||||
                        <a @click="previousPage">
 | 
			
		||||
                            <span aria-hidden="true">←</span>Previous Page
 | 
			
		||||
                        </a>
 | 
			
		||||
                    </li>
 | 
			
		||||
                    <li class="next" v-show="hasNextPage">
 | 
			
		||||
                        <a @click="nextPage">
 | 
			
		||||
                            Next Page<span aria-hidden="true">→</span>
 | 
			
		||||
                        </a>
 | 
			
		||||
                    </li>
 | 
			
		||||
                </ul>
 | 
			
		||||
            </nav>
 | 
			
		||||
        </div>
 | 
			
		||||
        <template v-if="!loading">
 | 
			
		||||
            <div class="alert alert-info" v-show="posts.length === 0">No guestbook posts.</div>
 | 
			
		||||
            <guestbook-post :post="post" :can-edit="canEdit" v-for="post in posts" :key="post.id" @reload="getPage"></guestbook-post>
 | 
			
		||||
            <div v-if="authenticated" class="form-horizontal">
 | 
			
		||||
                <bbcode-editor v-model="newPost.message" :maxlength="5000" classes="form-control"></bbcode-editor>
 | 
			
		||||
                <label class="control-label"
 | 
			
		||||
                    for="guestbookPostPrivate">Private(only visible to owner): </label>
 | 
			
		||||
                <input type="checkbox"
 | 
			
		||||
                    class="form-control"
 | 
			
		||||
                    id="guestbookPostPrivate"
 | 
			
		||||
                    v-model="newPost.privatePost"/>
 | 
			
		||||
                <label class="control-label"
 | 
			
		||||
                    for="guestbook-post-character">Character: </label>
 | 
			
		||||
                <character-select id="guestbook-post-character" v-model="newPost.character"></character-select>
 | 
			
		||||
                <button @click="makePost" class="btn btn-success" :disabled="newPost.posting">Post</button>
 | 
			
		||||
            </div>
 | 
			
		||||
        </template>
 | 
			
		||||
        <div class="guestbook-controls">
 | 
			
		||||
            <nav>
 | 
			
		||||
                <ul class="pager">
 | 
			
		||||
                    <li class="previous" v-show="page > 1">
 | 
			
		||||
                        <a @click="previousPage">
 | 
			
		||||
                            <span aria-hidden="true">←</span>Previous Page
 | 
			
		||||
                        </a>
 | 
			
		||||
                    </li>
 | 
			
		||||
                    <li class="next" v-show="hasNextPage">
 | 
			
		||||
                        <a @click="nextPage">
 | 
			
		||||
                            Next Page<span aria-hidden="true">→</span>
 | 
			
		||||
                        </a>
 | 
			
		||||
                    </li>
 | 
			
		||||
                </ul>
 | 
			
		||||
            </nav>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import Vue from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import {Prop, Watch} from 'vue-property-decorator';
 | 
			
		||||
    import * as Utils from '../utils';
 | 
			
		||||
    import {methods, Store} from './data_store';
 | 
			
		||||
    import {Character, GuestbookPost} from './interfaces';
 | 
			
		||||
 | 
			
		||||
    import GuestbookPostView from './guestbook_post.vue';
 | 
			
		||||
 | 
			
		||||
    @Component({
 | 
			
		||||
        components: {
 | 
			
		||||
            'guestbook-post': GuestbookPostView
 | 
			
		||||
        }
 | 
			
		||||
    })
 | 
			
		||||
    export default class GuestbookView extends Vue {
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        private readonly character: Character;
 | 
			
		||||
 | 
			
		||||
        loading = true;
 | 
			
		||||
        error = '';
 | 
			
		||||
        authenticated = Store.authenticated;
 | 
			
		||||
 | 
			
		||||
        posts: GuestbookPost[] = [];
 | 
			
		||||
 | 
			
		||||
        private unapprovedOnly = false;
 | 
			
		||||
        private page = 1;
 | 
			
		||||
        hasNextPage = false;
 | 
			
		||||
        canEdit = false;
 | 
			
		||||
        private newPost = {
 | 
			
		||||
            posting: false,
 | 
			
		||||
            privatePost: false,
 | 
			
		||||
            character: Utils.Settings.defaultCharacter,
 | 
			
		||||
            message: ''
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        async nextPage(): Promise<void> {
 | 
			
		||||
            this.page += 1;
 | 
			
		||||
            return this.getPage();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        async previousPage(): Promise<void> {
 | 
			
		||||
            this.page -= 1;
 | 
			
		||||
            return this.getPage();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        @Watch('unapprovedOnly')
 | 
			
		||||
        async getPage(): Promise<void> {
 | 
			
		||||
            try {
 | 
			
		||||
                this.loading = true;
 | 
			
		||||
                const guestbookState = await methods.guestbookPageGet(this.character.character.id, this.page, this.unapprovedOnly);
 | 
			
		||||
                this.posts = guestbookState.posts;
 | 
			
		||||
                this.hasNextPage = guestbookState.nextPage;
 | 
			
		||||
                this.canEdit = guestbookState.canEdit;
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                this.posts = [];
 | 
			
		||||
                this.hasNextPage = false;
 | 
			
		||||
                this.canEdit = false;
 | 
			
		||||
                if(Utils.isJSONError(e))
 | 
			
		||||
                    this.error = <string>e.response.data.error;
 | 
			
		||||
                Utils.ajaxError(e, 'Unable to load guestbook posts.');
 | 
			
		||||
            } finally {
 | 
			
		||||
                this.loading = false;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        async makePost(): Promise<void> {
 | 
			
		||||
            try {
 | 
			
		||||
                this.newPost.posting = true;
 | 
			
		||||
                await methods.guestbookPostPost(this.character.character.id, this.newPost.character, this.newPost.message,
 | 
			
		||||
                    this.newPost.privatePost);
 | 
			
		||||
                this.page = 1;
 | 
			
		||||
                await this.getPage();
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                Utils.ajaxError(e, 'Unable to post new guestbook post.');
 | 
			
		||||
            } finally {
 | 
			
		||||
                this.newPost.posting = false;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        async show(): Promise<void> {
 | 
			
		||||
            return this.getPage();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										121
									
								
								site/character_page/guestbook_post.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								site/character_page/guestbook_post.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,121 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div class="guestbook-post" :id="'guestbook-post-' + post.id">
 | 
			
		||||
        <div class="guestbook-contents" :class="{deleted: post.deleted}">
 | 
			
		||||
            <div class="row">
 | 
			
		||||
                <div class="col-xs-1 guestbook-avatar">
 | 
			
		||||
                    <character-link :character="post.character">
 | 
			
		||||
                        <img :src="avatarUrl" class="character-avatar icon"/>
 | 
			
		||||
                    </character-link>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="col-xs-10">
 | 
			
		||||
                    <span v-show="post.private" class="post-private">*</span>
 | 
			
		||||
                    <span v-show="!post.approved" class="post-unapproved"> (unapproved)</span>
 | 
			
		||||
 | 
			
		||||
                    <span class="guestbook-timestamp">
 | 
			
		||||
                        <character-link :character="post.character"></character-link>, posted <date-display
 | 
			
		||||
                        :time="post.postedAt"></date-display>
 | 
			
		||||
                    </span>
 | 
			
		||||
                    <button class="btn btn-default" v-show="canEdit" @click="approve" :disabled="approving">
 | 
			
		||||
                        {{ (post.approved) ? 'Unapprove' : 'Approve' }}
 | 
			
		||||
                    </button>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="col-xs-1 text-right">
 | 
			
		||||
                    <button class="btn btn-danger" v-show="!post.deleted && (canEdit || post.canEdit)"
 | 
			
		||||
                        @click="deletePost" :disabled="deleting">Delete
 | 
			
		||||
                    </button>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="row">
 | 
			
		||||
                <div class="col-xs-12">
 | 
			
		||||
                    <div class="bbcode guestbook-message" v-bbcode="post.message"></div>
 | 
			
		||||
                    <div v-if="post.reply && !replyBox" class="guestbook-reply">
 | 
			
		||||
                        <date-display v-if="post.repliedAt" :time="post.repliedAt"></date-display>
 | 
			
		||||
                        <div class="reply-message" v-bbcode="post.reply"></div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="row">
 | 
			
		||||
                <div class="col-xs-12">
 | 
			
		||||
                    <a v-show="canEdit && !replyBox" class="reply-link" @click="replyBox = !replyBox">
 | 
			
		||||
                        {{ post.reply ? 'Edit Reply' : 'Reply' }}
 | 
			
		||||
                    </a>
 | 
			
		||||
                    <template v-if="replyBox">
 | 
			
		||||
                        <bbcode-editor v-model="replyMessage" :maxlength="5000" classes="form-control"></bbcode-editor>
 | 
			
		||||
                        <button class="btn btn-success" @click="postReply" :disabled="replying">Reply</button>
 | 
			
		||||
                    </template>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import Vue from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import {Prop} from 'vue-property-decorator';
 | 
			
		||||
    import CharacterLink from '../../components/character_link.vue';
 | 
			
		||||
    import DateDisplay from '../../components/date_display.vue';
 | 
			
		||||
    import * as Utils from '../utils';
 | 
			
		||||
    import {methods} from './data_store';
 | 
			
		||||
    import {GuestbookPost} from './interfaces';
 | 
			
		||||
 | 
			
		||||
    @Component({
 | 
			
		||||
        components: {'date-display': DateDisplay, 'character-link': CharacterLink}
 | 
			
		||||
    })
 | 
			
		||||
    export default class GuestbookPostView extends Vue {
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        private readonly post: GuestbookPost;
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        readonly canEdit: boolean;
 | 
			
		||||
 | 
			
		||||
        replying = false;
 | 
			
		||||
        replyBox = false;
 | 
			
		||||
        private replyMessage = this.post.reply;
 | 
			
		||||
 | 
			
		||||
        approving = false;
 | 
			
		||||
        deleting = false;
 | 
			
		||||
 | 
			
		||||
        get avatarUrl(): string {
 | 
			
		||||
            return Utils.avatarURL(this.post.character.name);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        async deletePost(): Promise<void> {
 | 
			
		||||
            try {
 | 
			
		||||
                this.deleting = true;
 | 
			
		||||
                await methods.guestbookPostDelete(this.post.id);
 | 
			
		||||
                Vue.set(this.post, 'deleted', true);
 | 
			
		||||
                this.$emit('reload');
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                Utils.ajaxError(e, 'Unable to delete guestbook post.');
 | 
			
		||||
            } finally {
 | 
			
		||||
                this.deleting = false;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        async approve(): Promise<void> {
 | 
			
		||||
            try {
 | 
			
		||||
                this.approving = true;
 | 
			
		||||
                await methods.guestbookPostApprove(this.post.id, !this.post.approved);
 | 
			
		||||
                this.post.approved = !this.post.approved;
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                Utils.ajaxError(e, 'Unable to change post approval.');
 | 
			
		||||
            } finally {
 | 
			
		||||
                this.approving = false;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        async postReply(): Promise<void> {
 | 
			
		||||
            try {
 | 
			
		||||
                this.replying = true;
 | 
			
		||||
                const replyData = await methods.guestbookPostReply(this.post.id, this.replyMessage);
 | 
			
		||||
                this.post.reply = replyData.reply;
 | 
			
		||||
                this.post.repliedAt = replyData.repliedAt;
 | 
			
		||||
                this.replyBox = false;
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                Utils.ajaxError(e, 'Unable to post guestbook reply.');
 | 
			
		||||
            } finally {
 | 
			
		||||
                this.replying = false;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										51
									
								
								site/character_page/images.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								site/character_page/images.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,51 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div class="character-images">
 | 
			
		||||
        <div v-show="loading" class="alert alert-info">Loading images.</div>
 | 
			
		||||
        <template v-if="!loading">
 | 
			
		||||
            <div class="character-image" v-for="image in images" :key="image.id">
 | 
			
		||||
                <a :href="imageUrl(image)" target="_blank">
 | 
			
		||||
                    <img :src="thumbUrl(image)" :title="image.description">
 | 
			
		||||
                </a>
 | 
			
		||||
            </div>
 | 
			
		||||
        </template>
 | 
			
		||||
        <div v-if="!loading && !images.length" class="alert alert-info">No images.</div>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import Vue from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import {Prop} from 'vue-property-decorator';
 | 
			
		||||
    import * as Utils from '../utils';
 | 
			
		||||
    import {methods} from './data_store';
 | 
			
		||||
    import {Character, CharacterImage} from './interfaces';
 | 
			
		||||
 | 
			
		||||
    @Component
 | 
			
		||||
    export default class ImagesView extends Vue {
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        private readonly character: Character;
 | 
			
		||||
        private shown = false;
 | 
			
		||||
        images: CharacterImage[] = [];
 | 
			
		||||
        loading = true;
 | 
			
		||||
        error = '';
 | 
			
		||||
 | 
			
		||||
        imageUrl = (image: CharacterImage) => methods.imageUrl(image);
 | 
			
		||||
        thumbUrl = (image: CharacterImage) => methods.imageThumbUrl(image);
 | 
			
		||||
 | 
			
		||||
        async show(): Promise<void> {
 | 
			
		||||
            if(this.shown) return;
 | 
			
		||||
            try {
 | 
			
		||||
                this.error = '';
 | 
			
		||||
                this.shown = true;
 | 
			
		||||
                this.loading = true;
 | 
			
		||||
                this.images = await methods.imagesGet(this.character.character.id);
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                this.shown = false;
 | 
			
		||||
                if(Utils.isJSONError(e))
 | 
			
		||||
                    this.error = <string>e.response.data.error;
 | 
			
		||||
                Utils.ajaxError(e, 'Unable to load images.');
 | 
			
		||||
            }
 | 
			
		||||
            this.loading = false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										54
									
								
								site/character_page/infotag.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								site/character_page/infotag.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,54 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div class="infotag">
 | 
			
		||||
        <span class="infotag-label">{{label}}: </span>
 | 
			
		||||
        <span v-if="!contactLink" class="infotag-value">{{value}}</span>
 | 
			
		||||
        <span v-if="contactLink" class="infotag-value"><a :href="contactLink">{{value}}</a></span>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import Vue from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import {Prop} from 'vue-property-decorator';
 | 
			
		||||
    import {formatContactLink, formatContactValue} from './contact_utils';
 | 
			
		||||
    import {Store} from './data_store';
 | 
			
		||||
    import {DisplayInfotag} from './interfaces';
 | 
			
		||||
 | 
			
		||||
    @Component
 | 
			
		||||
    export default class InfotagView extends Vue {
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        private readonly infotag: DisplayInfotag;
 | 
			
		||||
 | 
			
		||||
        get label(): string {
 | 
			
		||||
            const infotag = Store.kinks.infotags[this.infotag.id];
 | 
			
		||||
            if(typeof infotag === 'undefined')
 | 
			
		||||
                return 'Unknown Infotag';
 | 
			
		||||
            return infotag.name;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get contactLink(): string | undefined {
 | 
			
		||||
            if(this.infotag.isContact)
 | 
			
		||||
                return formatContactLink(this.infotag.id, this.infotag.string!);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get value(): string {
 | 
			
		||||
            const infotag = Store.kinks.infotags[this.infotag.id];
 | 
			
		||||
            if(typeof infotag === 'undefined')
 | 
			
		||||
                return '';
 | 
			
		||||
            if(this.infotag.isContact)
 | 
			
		||||
                return formatContactValue(this.infotag.id, this.infotag.string!);
 | 
			
		||||
            switch(infotag.type) {
 | 
			
		||||
                case 'text':
 | 
			
		||||
                    return this.infotag.string!;
 | 
			
		||||
                case 'number':
 | 
			
		||||
                    if(infotag.allow_legacy && this.infotag.number === null)
 | 
			
		||||
                        return this.infotag.string !== undefined ? this.infotag.string : '';
 | 
			
		||||
                    return this.infotag.number!.toPrecision();
 | 
			
		||||
            }
 | 
			
		||||
            const listitem = Store.kinks.listitems[this.infotag.list!];
 | 
			
		||||
            if(typeof listitem === 'undefined')
 | 
			
		||||
                return '';
 | 
			
		||||
            return listitem.value;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										79
									
								
								site/character_page/infotags.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								site/character_page/infotags.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,79 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div class="infotags">
 | 
			
		||||
        <div class="infotag-group" v-for="group in groupedInfotags" :key="group.id">
 | 
			
		||||
            <div class="col-xs-2">
 | 
			
		||||
                <div class="infotag-title">{{group.name}}</div>
 | 
			
		||||
                <hr>
 | 
			
		||||
                <infotag :infotag="infotag" v-for="infotag in group.infotags" :key="infotag.id"></infotag>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import Vue from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import {Prop} from 'vue-property-decorator';
 | 
			
		||||
    import * as Utils from '../utils';
 | 
			
		||||
    import {Store} from './data_store';
 | 
			
		||||
    import {Character, CONTACT_GROUP_ID, DisplayInfotag} from './interfaces';
 | 
			
		||||
 | 
			
		||||
    import InfotagView from './infotag.vue';
 | 
			
		||||
 | 
			
		||||
    interface DisplayInfotagGroup {
 | 
			
		||||
        name: string
 | 
			
		||||
        sortOrder: number
 | 
			
		||||
        infotags: DisplayInfotag[]
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Component({
 | 
			
		||||
        components: {
 | 
			
		||||
            infotag: InfotagView
 | 
			
		||||
        }
 | 
			
		||||
    })
 | 
			
		||||
    export default class InfotagsView extends Vue {
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        private readonly character: Character;
 | 
			
		||||
 | 
			
		||||
        get groupedInfotags(): DisplayInfotagGroup[] {
 | 
			
		||||
            const groups = Store.kinks.infotag_groups;
 | 
			
		||||
            const infotags = Store.kinks.infotags;
 | 
			
		||||
            const characterTags = this.character.character.infotags;
 | 
			
		||||
            const outputGroups: DisplayInfotagGroup[] = [];
 | 
			
		||||
            const groupedTags = Utils.groupObjectBy(infotags, 'infotag_group');
 | 
			
		||||
            for(const groupId in groups) {
 | 
			
		||||
                const group = groups[groupId]!;
 | 
			
		||||
                const groupedInfotags = groupedTags[groupId];
 | 
			
		||||
                if(groupedInfotags === undefined) continue;
 | 
			
		||||
                const collectedTags: DisplayInfotag[] = [];
 | 
			
		||||
                for(const infotag of groupedInfotags) {
 | 
			
		||||
                    const characterInfotag = characterTags[infotag.id];
 | 
			
		||||
                    if(typeof characterInfotag === 'undefined')
 | 
			
		||||
                        continue;
 | 
			
		||||
                    const newInfotag: DisplayInfotag = {
 | 
			
		||||
                        id: infotag.id,
 | 
			
		||||
                        isContact: infotag.infotag_group === CONTACT_GROUP_ID,
 | 
			
		||||
                        string: characterInfotag.string,
 | 
			
		||||
                        number: characterInfotag.number,
 | 
			
		||||
                        list: characterInfotag.list
 | 
			
		||||
                    };
 | 
			
		||||
                    collectedTags.push(newInfotag);
 | 
			
		||||
                }
 | 
			
		||||
                collectedTags.sort((a, b): number => {
 | 
			
		||||
                    const infotagA = infotags[a.id]!;
 | 
			
		||||
                    const infotagB = infotags[b.id]!;
 | 
			
		||||
                    return infotagA.name < infotagB.name ? -1 : 1;
 | 
			
		||||
                });
 | 
			
		||||
                outputGroups.push({
 | 
			
		||||
                    name: group.name,
 | 
			
		||||
                    sortOrder: group.sort_order,
 | 
			
		||||
                    infotags: collectedTags
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            outputGroups.sort((a, b) => a.sortOrder < b.sortOrder ? -1 : 1);
 | 
			
		||||
 | 
			
		||||
            return outputGroups.filter((a) => a.infotags.length > 0);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										327
									
								
								site/character_page/interfaces.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										327
									
								
								site/character_page/interfaces.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,327 @@
 | 
			
		||||
export interface CharacterMenuItem {
 | 
			
		||||
    label: string
 | 
			
		||||
    permission: string
 | 
			
		||||
    link(character: Character): string
 | 
			
		||||
    handleClick?(evt?: MouseEvent): void
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface SelectItem {
 | 
			
		||||
    text: string
 | 
			
		||||
    value: string | number
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface SharedStore {
 | 
			
		||||
    kinks: SharedKinks
 | 
			
		||||
    authenticated: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface StoreMethods {
 | 
			
		||||
    bookmarkUpdate(id: number, state: boolean): Promise<boolean>
 | 
			
		||||
 | 
			
		||||
    characterBlock?(id: number, block: boolean, reason?: string): Promise<void>
 | 
			
		||||
    characterCustomKinkAdd(id: number, name: string, description: string, choice: KinkChoice): Promise<void>
 | 
			
		||||
    characterData(name: string | undefined, id: number | undefined): Promise<Character>
 | 
			
		||||
    characterDelete(id: number): Promise<void>
 | 
			
		||||
    characterDuplicate(id: number, name: string): Promise<DuplicateResult>
 | 
			
		||||
    characterFriends(id: number): Promise<FriendsByCharacter>
 | 
			
		||||
    characterNameCheck(name: string): Promise<CharacterNameCheckResult>
 | 
			
		||||
    characterRename?(id: number, name: string, renamedFor?: string): Promise<RenameResult>
 | 
			
		||||
    characterReport(reportData: CharacterReportData): Promise<void>
 | 
			
		||||
 | 
			
		||||
    contactMethodIconUrl(name: string): string
 | 
			
		||||
 | 
			
		||||
    fieldsGet(): Promise<void>
 | 
			
		||||
 | 
			
		||||
    friendDissolve(id: number): Promise<void>
 | 
			
		||||
    friendRequest(target: number, source: number): Promise<FriendRequest>
 | 
			
		||||
    friendRequestAccept(id: number): Promise<Friend>
 | 
			
		||||
    friendRequestIgnore(id: number): Promise<void>
 | 
			
		||||
    friendRequestCancel(id: number): Promise<void>
 | 
			
		||||
 | 
			
		||||
    friendsGet(id: number): Promise<CharacterFriend[]>
 | 
			
		||||
 | 
			
		||||
    groupsGet(id: number): Promise<CharacterGroup[]>
 | 
			
		||||
 | 
			
		||||
    guestbookPageGet(id: number, page: number, unapproved: boolean): Promise<GuestbookState>
 | 
			
		||||
    guestbookPostApprove(id: number, approval: boolean): Promise<void>
 | 
			
		||||
    guestbookPostDelete(id: number): Promise<void>
 | 
			
		||||
    guestbookPostPost(target: number, source: number, message: string, privatePost: boolean): Promise<void>
 | 
			
		||||
    guestbookPostReply(id: number, reply: string | null): Promise<GuestbookReply>
 | 
			
		||||
 | 
			
		||||
    hasPermission?(permission: string): boolean
 | 
			
		||||
 | 
			
		||||
    imagesGet(id: number): Promise<CharacterImage[]>
 | 
			
		||||
    imageUrl(image: CharacterImage): string
 | 
			
		||||
    imageThumbUrl(image: CharacterImage): string
 | 
			
		||||
 | 
			
		||||
    kinksGet(id: number): Promise<CharacterKink[]>
 | 
			
		||||
 | 
			
		||||
    memoUpdate(id: number, memo: string): Promise<MemoReply>
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface SharedKinks {
 | 
			
		||||
    kinks: {[key: string]: Kink | undefined}
 | 
			
		||||
    kink_groups: {[key: string]: KinkGroup | undefined}
 | 
			
		||||
    infotags: {[key: string]: Infotag | undefined}
 | 
			
		||||
    infotag_groups: {[key: string]: InfotagGroup | undefined}
 | 
			
		||||
    listitems: {[key: string]: ListItem | undefined}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type SiteDate = number | string | null;
 | 
			
		||||
export type KinkChoice = 'favorite' | 'yes' | 'maybe' | 'no';
 | 
			
		||||
export type KinkChoiceFull = KinkChoice | number;
 | 
			
		||||
export const CONTACT_GROUP_ID = 1;
 | 
			
		||||
 | 
			
		||||
export interface DisplayKink {
 | 
			
		||||
    id: number
 | 
			
		||||
    name: string
 | 
			
		||||
    description: string
 | 
			
		||||
    choice?: KinkChoice
 | 
			
		||||
    group: number
 | 
			
		||||
    isCustom: boolean
 | 
			
		||||
    hasSubkinks: boolean
 | 
			
		||||
    subkinks: DisplayKink[]
 | 
			
		||||
    ignore: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface DisplayInfotag {
 | 
			
		||||
    id: number
 | 
			
		||||
    isContact: boolean
 | 
			
		||||
    string?: string
 | 
			
		||||
    number?: number | null
 | 
			
		||||
    list?: number
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface Kink {
 | 
			
		||||
    id: number
 | 
			
		||||
    name: string
 | 
			
		||||
    description: string
 | 
			
		||||
    kink_group: number
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface KinkGroup {
 | 
			
		||||
    id: number
 | 
			
		||||
    name: string
 | 
			
		||||
    description: string
 | 
			
		||||
    sort_order: number
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface Infotag {
 | 
			
		||||
    id: number
 | 
			
		||||
    name: string
 | 
			
		||||
    type: 'number' | 'text' | 'list'
 | 
			
		||||
    search_field: string
 | 
			
		||||
    validator: string
 | 
			
		||||
    allow_legacy: boolean
 | 
			
		||||
    infotag_group: number
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface InfotagGroup {
 | 
			
		||||
    id: number
 | 
			
		||||
    name: string
 | 
			
		||||
    description: string
 | 
			
		||||
    sort_order: number
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ListItem {
 | 
			
		||||
    id: number
 | 
			
		||||
    name: string
 | 
			
		||||
    value: string
 | 
			
		||||
    sort_order: number
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface CharacterFriend {
 | 
			
		||||
    id: number
 | 
			
		||||
    name: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface CharacterKink {
 | 
			
		||||
    id: number
 | 
			
		||||
    choice: KinkChoice
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface CharacterInfotag {
 | 
			
		||||
    list?: number
 | 
			
		||||
    string?: string
 | 
			
		||||
    number?: number
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface CharacterCustom {
 | 
			
		||||
    id: number
 | 
			
		||||
    choice: KinkChoice
 | 
			
		||||
    name: string
 | 
			
		||||
    description: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface CharacterInline {
 | 
			
		||||
    id: number
 | 
			
		||||
    hash: string
 | 
			
		||||
    extension: string
 | 
			
		||||
    nsfw: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type CharacterImage = CharacterImageOld | CharacterImageNew;
 | 
			
		||||
 | 
			
		||||
export interface CharacterImageNew {
 | 
			
		||||
    id: number
 | 
			
		||||
    extension: string
 | 
			
		||||
    description: string
 | 
			
		||||
    hash: string
 | 
			
		||||
    sort_order: number | null
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface CharacterImageOld {
 | 
			
		||||
    id: number
 | 
			
		||||
    extension: string
 | 
			
		||||
    height: number
 | 
			
		||||
    width: number
 | 
			
		||||
    description: string
 | 
			
		||||
    sort_order: number | null
 | 
			
		||||
    url: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type CharacterName = string | CharacterNameDetails;
 | 
			
		||||
 | 
			
		||||
export interface CharacterNameDetails {
 | 
			
		||||
    id: number
 | 
			
		||||
    name: string
 | 
			
		||||
    deleted: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type ThreadOrderMode = 'post' | 'explicit';
 | 
			
		||||
 | 
			
		||||
export interface GroupPermissions {
 | 
			
		||||
    view: boolean
 | 
			
		||||
    edit: boolean
 | 
			
		||||
    threads: boolean
 | 
			
		||||
    permissions: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface CharacterGroup {
 | 
			
		||||
    id: number
 | 
			
		||||
    title: string
 | 
			
		||||
    public: boolean
 | 
			
		||||
    description: string
 | 
			
		||||
    threadCount: number
 | 
			
		||||
    orderMode: ThreadOrderMode
 | 
			
		||||
    createdAt: SiteDate
 | 
			
		||||
    myPermissions: GroupPermissions
 | 
			
		||||
    character: CharacterName
 | 
			
		||||
    owner: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface CharacterInfo {
 | 
			
		||||
    readonly id: number
 | 
			
		||||
    readonly name: string
 | 
			
		||||
    readonly description: string
 | 
			
		||||
    readonly title?: string
 | 
			
		||||
    readonly created_at: SiteDate
 | 
			
		||||
    readonly updated_at: SiteDate
 | 
			
		||||
    readonly views: number
 | 
			
		||||
    readonly last_online_at?: SiteDate
 | 
			
		||||
    readonly timezone?: number
 | 
			
		||||
    readonly image_count?: number
 | 
			
		||||
    readonly inlines: {[key: string]: CharacterInline | undefined}
 | 
			
		||||
    images?: CharacterImage[]
 | 
			
		||||
    readonly kinks: {[key: string]: KinkChoiceFull | undefined}
 | 
			
		||||
    readonly customs: CharacterCustom[]
 | 
			
		||||
    readonly infotags: {[key: string]: CharacterInfotag | undefined}
 | 
			
		||||
    readonly online_chat?: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface CharacterSettings {
 | 
			
		||||
    readonly customs_first: boolean
 | 
			
		||||
    readonly show_friends: boolean
 | 
			
		||||
    readonly badges: boolean
 | 
			
		||||
    readonly guestbook: boolean
 | 
			
		||||
    readonly prevent_bookmarks: boolean
 | 
			
		||||
    readonly public: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface Character {
 | 
			
		||||
    readonly is_self: boolean
 | 
			
		||||
    character: CharacterInfo
 | 
			
		||||
    readonly settings: CharacterSettings
 | 
			
		||||
    readonly badges?: string[]
 | 
			
		||||
    memo?: {
 | 
			
		||||
        id: number
 | 
			
		||||
        memo: string
 | 
			
		||||
    }
 | 
			
		||||
    readonly character_list?: {
 | 
			
		||||
        id: number
 | 
			
		||||
        name: string
 | 
			
		||||
    }[]
 | 
			
		||||
    bookmarked?: boolean
 | 
			
		||||
    readonly self_staff: boolean
 | 
			
		||||
    readonly ban?: string
 | 
			
		||||
    readonly ban_reason?: string
 | 
			
		||||
    readonly timeout?: number
 | 
			
		||||
    readonly block_reason?: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface GuestbookPost {
 | 
			
		||||
    readonly id: number
 | 
			
		||||
    readonly character: CharacterNameDetails
 | 
			
		||||
    approved: boolean
 | 
			
		||||
    readonly private: boolean
 | 
			
		||||
    postedAt: SiteDate
 | 
			
		||||
    message: string
 | 
			
		||||
    reply: string | null
 | 
			
		||||
    repliedAt: SiteDate
 | 
			
		||||
    canEdit: boolean
 | 
			
		||||
    deleted?: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface GuestbookReply {
 | 
			
		||||
    readonly reply: string
 | 
			
		||||
    readonly postId: number
 | 
			
		||||
    readonly repliedAt: SiteDate
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface GuestbookState {
 | 
			
		||||
    posts: GuestbookPost[]
 | 
			
		||||
    readonly nextPage: boolean
 | 
			
		||||
    readonly canEdit: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface MemoReply {
 | 
			
		||||
    readonly id: number
 | 
			
		||||
    readonly memo: string
 | 
			
		||||
    readonly updated_at: SiteDate
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface DuplicateResult {
 | 
			
		||||
    // Url to redirect user to when duplication is complete.
 | 
			
		||||
    readonly next: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type RenameResult = DuplicateResult;
 | 
			
		||||
 | 
			
		||||
export interface CharacterNameCheckResult {
 | 
			
		||||
    valid: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface CharacterReportData {
 | 
			
		||||
    subject: string
 | 
			
		||||
    message: string
 | 
			
		||||
    character: number | null
 | 
			
		||||
    type: string
 | 
			
		||||
    url: string
 | 
			
		||||
    reported_character: number
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface Friend {
 | 
			
		||||
    id: number
 | 
			
		||||
    source: CharacterNameDetails
 | 
			
		||||
    target: CharacterNameDetails
 | 
			
		||||
    createdAt: SiteDate
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type FriendRequest = Friend;
 | 
			
		||||
 | 
			
		||||
export interface FriendsByCharacter {
 | 
			
		||||
    existing: Friend[]
 | 
			
		||||
    pending: FriendRequest[]
 | 
			
		||||
    incoming: FriendRequest[]
 | 
			
		||||
    name: string
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										61
									
								
								site/character_page/kink.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								site/character_page/kink.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,61 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div class="character-kink" :class="kinkClasses" :id="kinkId" :title="kink.description" @click="toggleSubkinks" :data-custom="customId">
 | 
			
		||||
        <i v-show="kink.hasSubkinks" class="fa" :class="{'fa-minus': !listClosed, 'fa-plus': listClosed}"></i>
 | 
			
		||||
        <i v-show="!kink.hasSubkinks && kink.isCustom" class="fa fa-dot-circle-o custom-kink-icon"></i>
 | 
			
		||||
        <span class="kink-name">{{ kink.name }}</span>
 | 
			
		||||
        <template v-if="kink.hasSubkinks">
 | 
			
		||||
            <div class="subkink-list" :class="{closed: this.listClosed}">
 | 
			
		||||
                <kink v-for="subkink in kink.subkinks" :kink="subkink" :key="kink.id" :comparisons="comparisons"
 | 
			
		||||
                    :highlights="highlights"></kink>
 | 
			
		||||
            </div>
 | 
			
		||||
        </template>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import Vue from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import {Prop} from 'vue-property-decorator';
 | 
			
		||||
    import {DisplayKink} from './interfaces';
 | 
			
		||||
 | 
			
		||||
    @Component({
 | 
			
		||||
        name: 'kink'
 | 
			
		||||
    })
 | 
			
		||||
    export default class KinkView extends Vue {
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        readonly kink: DisplayKink;
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        readonly highlights: {[key: number]: boolean};
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        readonly comparisons: {[key: number]: string | undefined};
 | 
			
		||||
        listClosed = true;
 | 
			
		||||
 | 
			
		||||
        toggleSubkinks(): void {
 | 
			
		||||
            if(!this.kink.hasSubkinks)
 | 
			
		||||
                return;
 | 
			
		||||
            this.listClosed = !this.listClosed;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get kinkId(): number {
 | 
			
		||||
            return this.kink.isCustom ? -this.kink.id : this.kink.id;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get kinkClasses(): {[key: string]: boolean} {
 | 
			
		||||
            const classes: {[key: string]: boolean} = {
 | 
			
		||||
                'stock-kink': !this.kink.isCustom,
 | 
			
		||||
                'custom-kink': this.kink.isCustom,
 | 
			
		||||
                highlighted: !this.kink.isCustom && this.highlights[this.kink.id],
 | 
			
		||||
                subkink: this.kink.hasSubkinks
 | 
			
		||||
            };
 | 
			
		||||
            classes[`kink-id-${this.kinkId}`] = true;
 | 
			
		||||
            classes[`kink-group-${this.kink.group}`] = true;
 | 
			
		||||
            if(!this.kink.isCustom && typeof this.comparisons[this.kink.id] !== 'undefined')
 | 
			
		||||
                classes[`comparison-${this.comparisons[this.kink.id]}`] = true;
 | 
			
		||||
            return classes;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get customId(): number | undefined {
 | 
			
		||||
            return this.kink.isCustom ? this.kink.id : undefined;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										209
									
								
								site/character_page/kinks.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										209
									
								
								site/character_page/kinks.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,209 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div class="character-kinks-block" @contextmenu="contextMenu" @touchstart="contextMenu" @touchend="contextMenu">
 | 
			
		||||
        <div class="compare-highlight-block clearfix">
 | 
			
		||||
            <div v-if="shared.authenticated" class="quick-compare-block pull-left form-inline">
 | 
			
		||||
                <character-select v-model="characterToCompare"></character-select>
 | 
			
		||||
                <button class="btn btn-primary" @click="compareKinks" :disabled="loading || !characterToCompare">
 | 
			
		||||
                    {{ compareButtonText }}
 | 
			
		||||
                </button>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="pull-right form-inline">
 | 
			
		||||
                <select v-model="highlightGroup" class="form-control">
 | 
			
		||||
                    <option :value="null">None</option>
 | 
			
		||||
                    <option v-for="group in kinkGroups" :value="group.id" :key="group.id">{{group.name}}</option>
 | 
			
		||||
                </select>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="character-kinks clearfix">
 | 
			
		||||
            <div class="col-xs-3 kinks-favorite">
 | 
			
		||||
                <div class="kinks-column">
 | 
			
		||||
                    <div class="kinks-header">
 | 
			
		||||
                        Favorite
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <hr>
 | 
			
		||||
                    <kink v-for="kink in groupedKinks['favorite']" :kink="kink" :key="kink.id" :highlights="highlighting"
 | 
			
		||||
                        :comparisons="comparison"></kink>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="col-xs-3 kinks-yes">
 | 
			
		||||
                <div class="kinks-column">
 | 
			
		||||
                    <div class="kinks-header">
 | 
			
		||||
                        Yes
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <hr>
 | 
			
		||||
                    <kink v-for="kink in groupedKinks['yes']" :kink="kink" :key="kink.id" :highlights="highlighting"
 | 
			
		||||
                        :comparisons="comparison"></kink>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="col-xs-3 kinks-maybe">
 | 
			
		||||
                <div class="kinks-column">
 | 
			
		||||
                    <div class="kinks-header">
 | 
			
		||||
                        Maybe
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <hr>
 | 
			
		||||
                    <kink v-for="kink in groupedKinks['maybe']" :kink="kink" :key="kink.id" :highlights="highlighting"
 | 
			
		||||
                        :comparisons="comparison"></kink>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="col-xs-3 kinks-no">
 | 
			
		||||
                <div class="kinks-column">
 | 
			
		||||
                    <div class="kinks-header">
 | 
			
		||||
                        No
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <hr>
 | 
			
		||||
                    <kink v-for="kink in groupedKinks['no']" :kink="kink" :key="kink.id" :highlights="highlighting"
 | 
			
		||||
                        :comparisons="comparison"></kink>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <context-menu v-if="shared.authenticated" prop-name="custom" ref="context-menu"></context-menu>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import Vue from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import {Prop, Watch} from 'vue-property-decorator';
 | 
			
		||||
    import * as Utils from '../utils';
 | 
			
		||||
    import CopyCustomMenu from './copy_custom_menu.vue';
 | 
			
		||||
    import {methods, Store} from './data_store';
 | 
			
		||||
    import {Character, DisplayKink, Kink, KinkChoice, KinkGroup} from './interfaces';
 | 
			
		||||
    import KinkView from './kink.vue';
 | 
			
		||||
 | 
			
		||||
    @Component({
 | 
			
		||||
        components: {
 | 
			
		||||
            'context-menu': CopyCustomMenu,
 | 
			
		||||
            kink: KinkView
 | 
			
		||||
        }
 | 
			
		||||
    })
 | 
			
		||||
    export default class CharacterKinksView extends Vue {
 | 
			
		||||
        //tslint:disable:no-null-keyword
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        private readonly character: Character;
 | 
			
		||||
        private shared = Store;
 | 
			
		||||
        characterToCompare = Utils.Settings.defaultCharacter;
 | 
			
		||||
        highlightGroup: number | null = null;
 | 
			
		||||
 | 
			
		||||
        private loading = false;
 | 
			
		||||
        private comparing = false;
 | 
			
		||||
        highlighting: {[key: string]: boolean} = {};
 | 
			
		||||
        comparison: {[key: string]: KinkChoice} = {};
 | 
			
		||||
 | 
			
		||||
        async compareKinks(): Promise<void> {
 | 
			
		||||
            if(this.comparing) {
 | 
			
		||||
                this.comparison = {};
 | 
			
		||||
                this.comparing = false;
 | 
			
		||||
                this.loading = false;
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            try {
 | 
			
		||||
                this.loading = true;
 | 
			
		||||
                this.comparing = true;
 | 
			
		||||
                const kinks = await methods.kinksGet(this.character.character.id);
 | 
			
		||||
                const toAssign: {[key: number]: KinkChoice} = {};
 | 
			
		||||
                for(const kink of kinks)
 | 
			
		||||
                    toAssign[kink.id] = kink.choice;
 | 
			
		||||
                this.comparison = toAssign;
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                this.comparing = false;
 | 
			
		||||
                this.comparison = {};
 | 
			
		||||
                Utils.ajaxError(e, 'Unable to get kinks for comparison.');
 | 
			
		||||
            }
 | 
			
		||||
            this.loading = false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        @Watch('highlightGroup')
 | 
			
		||||
        highlightKinks(group: number | null): void {
 | 
			
		||||
            this.highlighting = {};
 | 
			
		||||
            if(group === null) return;
 | 
			
		||||
            const toAssign: {[key: string]: boolean} = {};
 | 
			
		||||
            for(const kinkId in this.shared.kinks.kinks) {
 | 
			
		||||
                const kink = this.shared.kinks.kinks[kinkId]!;
 | 
			
		||||
                if(kink.kink_group === group)
 | 
			
		||||
                    toAssign[kinkId] = true;
 | 
			
		||||
            }
 | 
			
		||||
            this.highlighting = toAssign;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get kinkGroups(): {[key: string]: KinkGroup | undefined} {
 | 
			
		||||
            return this.shared.kinks.kink_groups;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get compareButtonText(): string {
 | 
			
		||||
            if(this.loading)
 | 
			
		||||
                return 'Loading...';
 | 
			
		||||
            return this.comparing ? 'Clear' : 'Compare';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get groupedKinks(): {[key in KinkChoice]: DisplayKink[]} | undefined {
 | 
			
		||||
            const kinks = this.shared.kinks.kinks;
 | 
			
		||||
            const characterKinks = this.character.character.kinks;
 | 
			
		||||
            const characterCustoms = this.character.character.customs;
 | 
			
		||||
            const displayCustoms: {[key: string]: DisplayKink | undefined} = {};
 | 
			
		||||
            const outputKinks: {[key: string]: DisplayKink[]} = {favorite: [], yes: [], maybe: [], no: []};
 | 
			
		||||
            const makeKink = (kink: Kink): DisplayKink => ({
 | 
			
		||||
                id: kink.id,
 | 
			
		||||
                name: kink.name,
 | 
			
		||||
                description: kink.description,
 | 
			
		||||
                group: kink.kink_group,
 | 
			
		||||
                isCustom: false,
 | 
			
		||||
                hasSubkinks: false,
 | 
			
		||||
                ignore: false,
 | 
			
		||||
                subkinks: []
 | 
			
		||||
            });
 | 
			
		||||
            const kinkSorter = (a: DisplayKink, b: DisplayKink) => {
 | 
			
		||||
                if(this.character.settings.customs_first && a.isCustom !== b.isCustom)
 | 
			
		||||
                    return a.isCustom < b.isCustom ? 1 : -1;
 | 
			
		||||
 | 
			
		||||
                if(a.name === b.name)
 | 
			
		||||
                    return 0;
 | 
			
		||||
                return a.name < b.name ? -1 : 1;
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            for(const custom of characterCustoms)
 | 
			
		||||
                displayCustoms[custom.id] = {
 | 
			
		||||
                    id: custom.id,
 | 
			
		||||
                    name: custom.name,
 | 
			
		||||
                    description: custom.description,
 | 
			
		||||
                    choice: custom.choice,
 | 
			
		||||
                    group: -1,
 | 
			
		||||
                    isCustom: true,
 | 
			
		||||
                    hasSubkinks: false,
 | 
			
		||||
                    ignore: false,
 | 
			
		||||
                    subkinks: []
 | 
			
		||||
                };
 | 
			
		||||
 | 
			
		||||
            for(const kinkId in characterKinks) {
 | 
			
		||||
                const kinkChoice = characterKinks[kinkId]!;
 | 
			
		||||
                const kink = kinks[kinkId];
 | 
			
		||||
                if(kink === undefined) return;
 | 
			
		||||
                const newKink = makeKink(kink);
 | 
			
		||||
                if(typeof kinkChoice === 'number' && typeof displayCustoms[kinkChoice] !== 'undefined') {
 | 
			
		||||
                    const custom = displayCustoms[kinkChoice]!;
 | 
			
		||||
                    newKink.ignore = true;
 | 
			
		||||
                    custom.hasSubkinks = true;
 | 
			
		||||
                    custom.subkinks.push(newKink);
 | 
			
		||||
                }
 | 
			
		||||
                if(!newKink.ignore)
 | 
			
		||||
                    outputKinks[kinkChoice].push(newKink);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            for(const customId in displayCustoms) {
 | 
			
		||||
                const custom = displayCustoms[customId]!;
 | 
			
		||||
                if(custom.hasSubkinks)
 | 
			
		||||
                    custom.subkinks.sort(kinkSorter);
 | 
			
		||||
                outputKinks[<string>custom.choice].push(custom);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            for(const choice in outputKinks)
 | 
			
		||||
                outputKinks[choice].sort(kinkSorter);
 | 
			
		||||
 | 
			
		||||
            return <{[key in KinkChoice]: DisplayKink[]}>outputKinks;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        contextMenu(event: TouchEvent): void {
 | 
			
		||||
            (<CopyCustomMenu>this.$refs['context-menu']).outerClick(event);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										71
									
								
								site/character_page/memo_dialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								site/character_page/memo_dialog.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,71 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div id="memoDialog" tabindex="-1" class="modal" ref="dialog">
 | 
			
		||||
        <div class="modal-dialog">
 | 
			
		||||
            <div class="modal-content">
 | 
			
		||||
                <div class="modal-header">
 | 
			
		||||
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">×</button>
 | 
			
		||||
                    <h4 class="modal-title">Memo for {{name}}</h4>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="modal-body">
 | 
			
		||||
                    <div class="form-group" v-if="editing">
 | 
			
		||||
                        <textarea v-model="message" maxlength="1000" class="form-control"></textarea>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div v-if="!editing">
 | 
			
		||||
                        <p>{{message}}</p>
 | 
			
		||||
 | 
			
		||||
                        <p><a href="#" @click="editing=true">Edit</a></p>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="modal-footer">
 | 
			
		||||
                    <button type="button" class="btn btn-default" data-dismiss="modal">
 | 
			
		||||
                        Close
 | 
			
		||||
                    </button>
 | 
			
		||||
                    <button v-if="editing" class="btn btn-primary" @click="save" :disabled="saving">Save and Close</button>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import Vue from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import {Prop} from 'vue-property-decorator';
 | 
			
		||||
    import {methods} from './data_store';
 | 
			
		||||
    import {Character} from './interfaces';
 | 
			
		||||
 | 
			
		||||
    @Component
 | 
			
		||||
    export default class MemoDialog extends Vue {
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        private readonly character: Character;
 | 
			
		||||
 | 
			
		||||
        private message = '';
 | 
			
		||||
        editing = false;
 | 
			
		||||
        saving = false;
 | 
			
		||||
 | 
			
		||||
        get name(): string {
 | 
			
		||||
            return this.character.character.name;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        show(): void {
 | 
			
		||||
            if(this.character.memo !== undefined)
 | 
			
		||||
                this.message = this.character.memo.memo;
 | 
			
		||||
            $(this.$refs['dialog']).modal('show');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        async save(): Promise<void> {
 | 
			
		||||
            try {
 | 
			
		||||
                this.saving = true;
 | 
			
		||||
                const memoReply = await methods.memoUpdate(this.character.character.id, this.message);
 | 
			
		||||
                if(this.message === '')
 | 
			
		||||
                    this.$emit('memo', undefined);
 | 
			
		||||
                else
 | 
			
		||||
                    this.$emit('memo', memoReply);
 | 
			
		||||
                this.saving = false;
 | 
			
		||||
                $(this.$refs['dialog']).modal('hide');
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                this.saving = false;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										141
									
								
								site/character_page/report_dialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								site/character_page/report_dialog.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,141 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div id="reportDialog" tabindex="-1" class="modal" ref="dialog">
 | 
			
		||||
        <div class="modal-dialog">
 | 
			
		||||
            <div class="modal-content">
 | 
			
		||||
                <div class="modal-header">
 | 
			
		||||
                    <button type="button" class="close" data-dismiss="modal"
 | 
			
		||||
                        aria-label="Close">×
 | 
			
		||||
                    </button>
 | 
			
		||||
                    <h4 class="modal-title">Report Character {{ name }}</h4>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="modal-body form-horizontal">
 | 
			
		||||
                    <div class="form-group">
 | 
			
		||||
                        <label class="col-xs-4 control-label">Type:</label>
 | 
			
		||||
 | 
			
		||||
                        <div class="col-xs-8">
 | 
			
		||||
                            <select v-select="validTypes" v-model="type" class="form-control">
 | 
			
		||||
                            </select>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div v-if="type !== 'takedown'">
 | 
			
		||||
                        <div class="form-group" v-if="type === 'profile'">
 | 
			
		||||
                            <label class="col-xs-4 control-label">Violation Type:</label>
 | 
			
		||||
 | 
			
		||||
                            <div class="col-xs-8">
 | 
			
		||||
                                <select v-select="violationTypes" v-model="violation" class="form-control">
 | 
			
		||||
                                </select>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div class="form-group">
 | 
			
		||||
                            <label class="col-xs-4 control-label">Your Character:</label>
 | 
			
		||||
 | 
			
		||||
                            <div class="col-xs-8">
 | 
			
		||||
                                <character-select v-model="ourCharacter"></character-select>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div class="form-group">
 | 
			
		||||
                            <label class="col-xs-4 control-label">Reason/Message:</label>
 | 
			
		||||
 | 
			
		||||
                            <div class="col-xs-8">
 | 
			
		||||
                                <bbcode-editor v-model="message" :maxlength="45000"
 | 
			
		||||
                                    :classes="'form-control'"></bbcode-editor>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div v-show="type === 'takedown'" class="alert alert-info">
 | 
			
		||||
                        Please file art takedowns from the <a :href="ticketUrl">tickets page.</a>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="modal-footer">
 | 
			
		||||
                    <button type="button" class="btn btn-default" data-dismiss="modal">
 | 
			
		||||
                        Close
 | 
			
		||||
                    </button>
 | 
			
		||||
                    <button :disabled="!dataValid || submitting" class="btn btn-primary" @click="submitReport">
 | 
			
		||||
                        Report Character
 | 
			
		||||
                    </button>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import Vue from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import {Prop} from 'vue-property-decorator';
 | 
			
		||||
    import * as Utils from '../utils';
 | 
			
		||||
    import {methods} from './data_store';
 | 
			
		||||
    import {Character, SelectItem} from './interfaces';
 | 
			
		||||
 | 
			
		||||
    @Component
 | 
			
		||||
    export default class ReportDialog extends Vue {
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        private readonly character: Character;
 | 
			
		||||
 | 
			
		||||
        private ourCharacter = Utils.Settings.defaultCharacter;
 | 
			
		||||
        private type = '';
 | 
			
		||||
        private violation = '';
 | 
			
		||||
        private message = '';
 | 
			
		||||
 | 
			
		||||
        submitting = false;
 | 
			
		||||
 | 
			
		||||
        ticketUrl = `${Utils.siteDomain}tickets/new`;
 | 
			
		||||
 | 
			
		||||
        validTypes: ReadonlyArray<SelectItem> = [
 | 
			
		||||
            {text: 'None', value: ''},
 | 
			
		||||
            {text: 'Profile Violation', value: 'profile'},
 | 
			
		||||
            {text: 'Name Request', value: 'name_request'},
 | 
			
		||||
            {text: 'Art Takedown', value: 'takedown'},
 | 
			
		||||
            {text: 'Other', value: 'other'}
 | 
			
		||||
        ];
 | 
			
		||||
        violationTypes: ReadonlyArray<string> = [
 | 
			
		||||
            'Real life images on underage character',
 | 
			
		||||
            'Real life animal images on sexual character',
 | 
			
		||||
            'Amateur/farmed real life images',
 | 
			
		||||
            'Defamation',
 | 
			
		||||
            'OOC Kinks',
 | 
			
		||||
            'Real life contact information',
 | 
			
		||||
            'Solicitation for real life contact',
 | 
			
		||||
            'Other'
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
        get name(): string {
 | 
			
		||||
            return this.character.character.name;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get dataValid(): boolean {
 | 
			
		||||
            if(this.type === '' || this.type === 'takedown')
 | 
			
		||||
                return false;
 | 
			
		||||
            if(this.message === '')
 | 
			
		||||
                return false;
 | 
			
		||||
            if(this.type === 'profile' && this.violation === '')
 | 
			
		||||
                return false;
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        show(): void {
 | 
			
		||||
            $(this.$refs['dialog']).modal('show');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        async submitReport(): Promise<void> {
 | 
			
		||||
            try {
 | 
			
		||||
                this.submitting = true;
 | 
			
		||||
                const message = (this.type === 'profile' ? `Reporting character for violation: ${this.violation}\n\n` : '') + this.message;
 | 
			
		||||
                await methods.characterReport({
 | 
			
		||||
                    subject: (this.type === 'name_request' ? 'Requesting name: ' : 'Reporting character: ') + this.name,
 | 
			
		||||
                    message,
 | 
			
		||||
                    character: this.ourCharacter,
 | 
			
		||||
                    type: this.type,
 | 
			
		||||
                    url: Utils.characterURL(this.name),
 | 
			
		||||
                    reported_character: this.character.character.id
 | 
			
		||||
                });
 | 
			
		||||
                this.submitting = false;
 | 
			
		||||
                $(this.$refs['dialog']).modal('hide');
 | 
			
		||||
                Utils.flashSuccess('Character reported.');
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                this.submitting = false;
 | 
			
		||||
                Utils.ajaxError(e, 'Unable to report character');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										252
									
								
								site/character_page/sidebar.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										252
									
								
								site/character_page/sidebar.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,252 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div id="character-page-sidebar">
 | 
			
		||||
        <span class="character-name">{{ character.character.name }}</span>
 | 
			
		||||
        <div v-if="character.character.title" class="character-title">{{ character.character.title }}</div>
 | 
			
		||||
        <character-action-menu :character="character"></character-action-menu>
 | 
			
		||||
        <div>
 | 
			
		||||
            <img :src="avatarUrl(character.character.name)" class="character-avatar">
 | 
			
		||||
        </div>
 | 
			
		||||
        <div v-if="authenticated" class="character-links-block">
 | 
			
		||||
            <template v-if="character.is_self">
 | 
			
		||||
                <a :href="editUrl" class="edit-link"><i class="fa fa-pencil"></i>Edit</a>
 | 
			
		||||
                <a @click="showDelete" class="delete-link"><i class="fa fa-trash"></i>Delete</a>
 | 
			
		||||
                <a @click="showDuplicate" class="duplicate-link"><i class="fa fa-copy"></i>Duplicate</a>
 | 
			
		||||
            </template>
 | 
			
		||||
            <template v-else>
 | 
			
		||||
            <span v-if="character.self_staff || character.settings.prevent_bookmarks !== true">
 | 
			
		||||
                <a @click="toggleBookmark" :class="{bookmarked: character.bookmarked, unbookmarked: !character.bookmarked}">
 | 
			
		||||
                    {{ character.bookmarked ? '-' : '+' }}Bookmark
 | 
			
		||||
                </a>
 | 
			
		||||
                <span v-if="character.settings.prevent_bookmarks" class="prevents-bookmarks">!</span>
 | 
			
		||||
            </span>
 | 
			
		||||
                <a @click="showFriends" class="friend-link"><i class="fa fa-user"></i>Friend</a>
 | 
			
		||||
                <a @click="showReport" class="report-link"><i class="fa fa-exclamation-triangle"></i>Report</a>
 | 
			
		||||
            </template>
 | 
			
		||||
            <a @click="showMemo" class="memo-link"><i class="fa fa-sticky-note-o"></i>Memo</a>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div v-if="character.badges && character.badges.length > 0" class="badges-block">
 | 
			
		||||
            <div v-for="badge in character.badges" class="character-badge" :class="badgeClass(badge)">
 | 
			
		||||
                <i class="fa fa-fw" :class="badgeIconClass(badge)"></i> {{ badgeTitle(badge) }}
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <a v-if="authenticated && !character.is_self" :href="noteUrl" class="character-page-note-link">Send Note</a>
 | 
			
		||||
        <div v-if="character.character.online_chat" @click="showInChat" class="character-page-online-chat">Online In Chat</div>
 | 
			
		||||
 | 
			
		||||
        <div class="contact-block">
 | 
			
		||||
            <contact-method v-for="method in contactMethods" :method="method" :key="method.id"></contact-method>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div class="quick-info-block">
 | 
			
		||||
            <infotag-item v-for="infotag in quickInfoItems" :infotag="infotag" :key="infotag.id"></infotag-item>
 | 
			
		||||
            <div class="quick-info">
 | 
			
		||||
                <span class="quick-info-label">Created: </span>
 | 
			
		||||
                <span class="quick-info-value"><date :time="character.character.created_at"></date></span>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="quick-info">
 | 
			
		||||
                <span class="quick-info-label">Last updated: </span>
 | 
			
		||||
                <span class="quick-info-value"><date :time="character.character.updated_at"></date></span>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="quick-info" v-if="character.character.last_online_at">
 | 
			
		||||
                <span class="quick-info-label">Last online:</span>
 | 
			
		||||
                <span class="quick-info-value"><date :time="character.character.last_online_at"></date></span>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="quick-info">
 | 
			
		||||
                <span class="quick-info-label">Views: </span>
 | 
			
		||||
                <span class="quick-info-value">{{character.character.views}}</span>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="quick-info" v-if="character.character.timezone != null">
 | 
			
		||||
                <span class="quick-info-label">Timezone:</span>
 | 
			
		||||
                <span class="quick-info-value">
 | 
			
		||||
                    UTC{{character.character.timezone > 0 ? '+' : ''}}{{character.character.timezone != 0 ? character.character.timezone : ''}}
 | 
			
		||||
                </span>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div class="character-list-block">
 | 
			
		||||
            <div v-for="listCharacter in character.character_list">
 | 
			
		||||
                <img :src="avatarUrl(listCharacter.name)" class="character-avatar icon">
 | 
			
		||||
                <character-link :character="listCharacter.name"></character-link>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <template>
 | 
			
		||||
            <memo-dialog :character="character" ref="memo-dialog" @memo="memo"></memo-dialog>
 | 
			
		||||
            <delete-dialog :character="character" ref="delete-dialog"></delete-dialog>
 | 
			
		||||
            <rename-dialog :character="character" ref="rename-dialog"></rename-dialog>
 | 
			
		||||
            <duplicate-dialog :character="character" ref="duplicate-dialog"></duplicate-dialog>
 | 
			
		||||
            <report-dialog v-if="authenticated && !character.is_self" :character="character" ref="report-dialog"></report-dialog>
 | 
			
		||||
            <friend-dialog :character="character" ref="friend-dialog"></friend-dialog>
 | 
			
		||||
            <block-dialog :character="character" ref="block-dialog"></block-dialog>
 | 
			
		||||
        </template>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
    import Vue, {Component as VueComponent, ComponentOptions, CreateElement, VNode} from 'vue';
 | 
			
		||||
    import Component from 'vue-class-component';
 | 
			
		||||
    import {Prop} from 'vue-property-decorator';
 | 
			
		||||
    import * as Utils from '../utils';
 | 
			
		||||
    import {methods, registeredComponents, Store} from './data_store';
 | 
			
		||||
    import {Character, CONTACT_GROUP_ID, Infotag, SharedStore} from './interfaces';
 | 
			
		||||
 | 
			
		||||
    import DateDisplay from '../../components/date_display.vue';
 | 
			
		||||
    import InfotagView from './infotag.vue';
 | 
			
		||||
 | 
			
		||||
    import ContactMethodView from './contact_method.vue';
 | 
			
		||||
    import DeleteDialog from './delete_dialog.vue';
 | 
			
		||||
    import DuplicateDialog from './duplicate_dialog.vue';
 | 
			
		||||
    import FriendDialog from './friend_dialog.vue';
 | 
			
		||||
    import MemoDialog from './memo_dialog.vue';
 | 
			
		||||
    import ReportDialog from './report_dialog.vue';
 | 
			
		||||
 | 
			
		||||
    interface ShowableVueDialog extends Vue {
 | 
			
		||||
        show(): void
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function resolveComponent(name: string): () => Promise<VueComponent | ComponentOptions<Vue>> {
 | 
			
		||||
        return async(): Promise<VueComponent | ComponentOptions<Vue>> => {
 | 
			
		||||
            if(typeof registeredComponents[name] === 'undefined')
 | 
			
		||||
                return {
 | 
			
		||||
                    render(createElement: CreateElement): VNode {
 | 
			
		||||
                        return createElement('span');
 | 
			
		||||
                    },
 | 
			
		||||
                    name
 | 
			
		||||
                };
 | 
			
		||||
            return registeredComponents[name]!;
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Vue.component('block-dialog', resolveComponent('block-dialog'));
 | 
			
		||||
    Vue.component('rename-dialog', resolveComponent('rename-dialog'));
 | 
			
		||||
    Vue.component('character-action-menu', resolveComponent('character-action-menu'));
 | 
			
		||||
 | 
			
		||||
    @Component({
 | 
			
		||||
        components: {
 | 
			
		||||
            'contact-method': ContactMethodView,
 | 
			
		||||
            date: DateDisplay,
 | 
			
		||||
            'delete-dialog': DeleteDialog,
 | 
			
		||||
            'duplicate-dialog': DuplicateDialog,
 | 
			
		||||
            'friend-dialog': FriendDialog,
 | 
			
		||||
            'infotag-item': InfotagView,
 | 
			
		||||
            'memo-dialog': MemoDialog,
 | 
			
		||||
            'report-dialog': ReportDialog
 | 
			
		||||
        }
 | 
			
		||||
    })
 | 
			
		||||
    export default class Sidebar extends Vue {
 | 
			
		||||
        @Prop({required: true})
 | 
			
		||||
        readonly character: Character;
 | 
			
		||||
        readonly shared: SharedStore = Store;
 | 
			
		||||
        readonly quickInfoIds: ReadonlyArray<number> = [1, 3, 2, 49, 9, 29, 15, 41, 25]; // Do not sort these.
 | 
			
		||||
        readonly avatarUrl = Utils.avatarURL;
 | 
			
		||||
 | 
			
		||||
        badgeClass(badgeName: string): string {
 | 
			
		||||
            return `character-badge-${badgeName.replace('.', '-')}`;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        badgeIconClass(badgeName: string): string {
 | 
			
		||||
            const classMap: {[key: string]: string} = {
 | 
			
		||||
                admin: 'fa-star',
 | 
			
		||||
                global: 'fa-star-o',
 | 
			
		||||
                chatop: 'fa-commenting',
 | 
			
		||||
                chanop: 'fa-commenting-o',
 | 
			
		||||
                helpdesk: 'fa-user',
 | 
			
		||||
                developer: 'fa-terminal',
 | 
			
		||||
                'subscription.lifetime': 'fa-certificate'
 | 
			
		||||
            };
 | 
			
		||||
            return badgeName in classMap ? classMap[badgeName] : '';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        badgeTitle(badgeName: string): string {
 | 
			
		||||
            const badgeMap: {[key: string]: string} = {
 | 
			
		||||
                admin: 'Administrator',
 | 
			
		||||
                global: 'Global Moderator',
 | 
			
		||||
                chatop: 'Chat Moderator',
 | 
			
		||||
                chanop: 'Channel Moderator',
 | 
			
		||||
                helpdesk: 'Helpdesk',
 | 
			
		||||
                developer: 'Developer',
 | 
			
		||||
                'subscription.lifetime': 'Lifetime Subscriber',
 | 
			
		||||
                'subscription.other': 'Subscriber'
 | 
			
		||||
            };
 | 
			
		||||
            return badgeName in badgeMap ? badgeMap[badgeName] : badgeName;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        showDelete(): void {
 | 
			
		||||
            (<ShowableVueDialog>this.$refs['delete-dialog']).show();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        showDuplicate(): void {
 | 
			
		||||
            (<ShowableVueDialog>this.$refs['duplicate-dialog']).show();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        showMemo(): void {
 | 
			
		||||
            (<ShowableVueDialog>this.$refs['memo-dialog']).show();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        showReport(): void {
 | 
			
		||||
            (<ShowableVueDialog>this.$refs['report-dialog']).show();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        showFriends(): void {
 | 
			
		||||
            (<ShowableVueDialog>this.$refs['friend-dialog']).show();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        async toggleBookmark(): Promise<void> {
 | 
			
		||||
            const previousState = this.character.bookmarked;
 | 
			
		||||
            try {
 | 
			
		||||
                const state = !this.character.bookmarked;
 | 
			
		||||
                this.$emit('bookmarked', state);
 | 
			
		||||
                const actualState = await methods.bookmarkUpdate(this.character.character.id, state);
 | 
			
		||||
                this.$emit('bookmarked', actualState);
 | 
			
		||||
            } catch(e) {
 | 
			
		||||
                this.$emit('bookmarked', previousState);
 | 
			
		||||
                Utils.ajaxError(e, 'Unable to change bookmark state.');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get editUrl(): string {
 | 
			
		||||
            return `${Utils.siteDomain}character/${this.character.character.id}/`;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get noteUrl(): string {
 | 
			
		||||
            return `${Utils.siteDomain}notes/folder/1/0?target=${this.character.character.name}`;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get contactMethods(): object[] {
 | 
			
		||||
            const contactInfotags = Utils.groupObjectBy(Store.kinks.infotags, 'infotag_group');
 | 
			
		||||
            contactInfotags[CONTACT_GROUP_ID]!.sort((a: Infotag, b: Infotag) => a.name < b.name ? -1 : 1);
 | 
			
		||||
            const contactMethods = [];
 | 
			
		||||
            for(const infotag of contactInfotags[CONTACT_GROUP_ID]!) {
 | 
			
		||||
                const charTag = this.character.character.infotags[infotag.id];
 | 
			
		||||
                if(charTag === undefined) continue;
 | 
			
		||||
                contactMethods.push({
 | 
			
		||||
                    id: infotag.id,
 | 
			
		||||
                    value: charTag.string
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
            return contactMethods;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get quickInfoItems(): object[] {
 | 
			
		||||
            const quickItems = [];
 | 
			
		||||
            for(const id of this.quickInfoIds) {
 | 
			
		||||
                const infotag = this.character.character.infotags[id];
 | 
			
		||||
                if(infotag === undefined) continue;
 | 
			
		||||
                quickItems.push({
 | 
			
		||||
                    id,
 | 
			
		||||
                    string: infotag.string,
 | 
			
		||||
                    list: infotag.list,
 | 
			
		||||
                    number: infotag.number
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
            return quickItems;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        get authenticated(): boolean {
 | 
			
		||||
            return Store.authenticated;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        memo(memo: object): void {
 | 
			
		||||
            this.$emit('memo', memo);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										60
									
								
								site/directives/vue-select.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								site/directives/vue-select.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,60 @@
 | 
			
		||||
import Vue, {VNodeDirective} from 'vue';
 | 
			
		||||
//tslint:disable:strict-boolean-expressions
 | 
			
		||||
type Option = { value: string | null, disabled: boolean, text: string, label: string, options: Option[]} | string | number;
 | 
			
		||||
 | 
			
		||||
function rebuild(e: HTMLElement, binding: VNodeDirective): void {
 | 
			
		||||
    const el = <HTMLSelectElement>e;
 | 
			
		||||
    if(binding.oldValue === binding.value) return;
 | 
			
		||||
    if(!binding.value) console.error('Must provide a value');
 | 
			
		||||
    const value = <Option[]>binding.value;
 | 
			
		||||
 | 
			
		||||
    function _isObject(val: any): val is object { //tslint:disable-line:no-any
 | 
			
		||||
        return val !== null && typeof val === 'object';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function clearOptions(): void {
 | 
			
		||||
        let i = el.options.length;
 | 
			
		||||
        while(i--) {
 | 
			
		||||
            const opt = el.options[i];
 | 
			
		||||
            const parent = opt.parentNode!;
 | 
			
		||||
            if(parent === el) parent.removeChild(opt);
 | 
			
		||||
            else {
 | 
			
		||||
                el.removeChild(parent);
 | 
			
		||||
                i = el.options.length;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function buildOptions(parent: HTMLElement, options: Option[]): void {
 | 
			
		||||
        let newEl: (HTMLOptionElement & {'_value'?: string | null});
 | 
			
		||||
        for(let i = 0, l = options.length; i < l; i++) {
 | 
			
		||||
            const op = options[i];
 | 
			
		||||
            if(!_isObject(op) || !op.options) {
 | 
			
		||||
                newEl = document.createElement('option');
 | 
			
		||||
                if(typeof op === 'string' || typeof op === 'number')
 | 
			
		||||
                    newEl.text = newEl.value = op as string;
 | 
			
		||||
                else {
 | 
			
		||||
                    if(op.value !== null && !_isObject(op.value))
 | 
			
		||||
                        newEl.value = op.value;
 | 
			
		||||
                    newEl['_value'] = op.value;
 | 
			
		||||
                    newEl.text = op.text || '';
 | 
			
		||||
                    if(op.disabled)
 | 
			
		||||
                        newEl.disabled = true;
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                newEl = document.createElement('optgroup');
 | 
			
		||||
                newEl.label = op.label;
 | 
			
		||||
                buildOptions(newEl, op.options);
 | 
			
		||||
            }
 | 
			
		||||
            parent.appendChild(newEl);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    clearOptions();
 | 
			
		||||
    buildOptions(el, value);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default Vue.directive('select', {
 | 
			
		||||
    inserted: rebuild,
 | 
			
		||||
    update: rebuild
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										93
									
								
								site/flash_display.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								site/flash_display.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,93 @@
 | 
			
		||||
import Vue from 'vue';
 | 
			
		||||
 | 
			
		||||
export type flashMessageType = 'info' | 'success' | 'warning' | 'danger';
 | 
			
		||||
let boundHandler;
 | 
			
		||||
 | 
			
		||||
interface FlashComponent extends Vue {
 | 
			
		||||
    lastId: number
 | 
			
		||||
    floating: boolean
 | 
			
		||||
    messages: {
 | 
			
		||||
        id: number
 | 
			
		||||
        message: string
 | 
			
		||||
        classes: string
 | 
			
		||||
    }[]
 | 
			
		||||
    removeMessage(id: number)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function addFlashMessage(type: flashMessageType, message: string): void {
 | 
			
		||||
    instance.addMessage(type, message);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function bindEventHandler(vm): void {
 | 
			
		||||
    boundHandler = eventHandler.bind(vm);
 | 
			
		||||
    document.addEventListener('scroll', boundHandler);
 | 
			
		||||
    document.addEventListener('resize', boundHandler);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function removeHandlers(): void {
 | 
			
		||||
    document.removeEventListener('scroll', boundHandler);
 | 
			
		||||
    document.removeEventListener('resize', boundHandler);
 | 
			
		||||
    boundHandler = undefined;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function eventHandler(this: FlashComponent): void {
 | 
			
		||||
    const isElementVisible = (el: Element): boolean => {
 | 
			
		||||
        const rect = el.getBoundingClientRect();
 | 
			
		||||
        const vHeight = window.innerWidth || document.documentElement.clientHeight;
 | 
			
		||||
        const vWidth = window.innerWidth || document.documentElement.clientWidth;
 | 
			
		||||
        const efp = (x, y) => document.elementFromPoint(x, y);
 | 
			
		||||
        if(rect.top > vHeight || rect.bottom < 0 || rect.left > vWidth || rect.right < 0)
 | 
			
		||||
            return false;
 | 
			
		||||
        return true;
 | 
			
		||||
        //return (el.contains(efp(rect.left, rect.top)) || el.contains(efp(rect.right, rect.top)));
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    this.floating = !isElementVisible(this.$refs['detector'] as Element);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function addMessage(this: FlashComponent, type: flashMessageType, message: string): void {
 | 
			
		||||
    if(!boundHandler) {
 | 
			
		||||
        bindEventHandler(this);
 | 
			
		||||
        boundHandler();
 | 
			
		||||
    }
 | 
			
		||||
    const newId = this.lastId++;
 | 
			
		||||
    this.messages.push({id: newId, message, classes: `flash-message alert-${type}`});
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
        this.removeMessage(newId);
 | 
			
		||||
    }, 15000);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function removeMessage(id: number): void {
 | 
			
		||||
    this.messages = this.messages.filter(function(item) {
 | 
			
		||||
        return item['id'] !== id;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if(this.messages.length === 0)
 | 
			
		||||
        removeHandlers();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface FlashMessageManager {
 | 
			
		||||
    addMessage(type: flashMessageType, message: string): void
 | 
			
		||||
    removeMessage(id: number): void
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const instance: Vue & FlashMessageManager = new Vue({
 | 
			
		||||
    template: '#flashMessagesTemplate',
 | 
			
		||||
    el: '#flashMessages',
 | 
			
		||||
    data() {
 | 
			
		||||
        return {
 | 
			
		||||
            lastId: 1,
 | 
			
		||||
            messages: [],
 | 
			
		||||
            floating: false
 | 
			
		||||
        };
 | 
			
		||||
    },
 | 
			
		||||
    computed: {
 | 
			
		||||
        containerClasses(this: FlashComponent): string {
 | 
			
		||||
            return this.floating ? 'flash-messages-fixed' : 'flash-messages';
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    methods: {
 | 
			
		||||
        addMessage,
 | 
			
		||||
        removeMessage
 | 
			
		||||
    }
 | 
			
		||||
}) as Vue & FlashMessageManager;
 | 
			
		||||
							
								
								
									
										111
									
								
								site/utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								site/utils.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,111 @@
 | 
			
		||||
import Axios, {AxiosError, AxiosResponse} from 'axios';
 | 
			
		||||
//import {addFlashMessage, flashMessageType} from './flash_display';
 | 
			
		||||
import {InlineDisplayMode} from '../bbcode/interfaces';
 | 
			
		||||
 | 
			
		||||
export function avatarURL(name: string): string {
 | 
			
		||||
    const uregex = /^[a-zA-Z0-9_\-\s]+$/;
 | 
			
		||||
    if(!uregex.test(name)) return '#';
 | 
			
		||||
    return `${staticDomain}images/avatar/${name.toLowerCase()}.png`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function characterURL(name: string): string {
 | 
			
		||||
    const uregex = /^[a-zA-Z0-9_\-\s]+$/;
 | 
			
		||||
    if(!uregex.test(name)) return '#';
 | 
			
		||||
    return `${siteDomain}c/${name}`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface Dictionary<T> {
 | 
			
		||||
    [key: string]: T | undefined;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function groupObjectBy<K extends string, T extends {[k in K]: string | number}>(obj: Dictionary<T>, key: K): Dictionary<T[]> {
 | 
			
		||||
    const newObject: Dictionary<T[]> = {};
 | 
			
		||||
    for(const objkey in obj) {
 | 
			
		||||
        if(!(objkey in obj)) continue;
 | 
			
		||||
        const realItem = obj[objkey]!;
 | 
			
		||||
        const newKey = realItem[key];
 | 
			
		||||
        if(newObject[<string>newKey] === undefined) newObject[newKey] = [];
 | 
			
		||||
        newObject[newKey]!.push(realItem);
 | 
			
		||||
    }
 | 
			
		||||
    return newObject;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function groupArrayBy<K extends string, T extends {[k in K]: string | number}>(arr: T[], key: K): Dictionary<T[]> {
 | 
			
		||||
    const newObject: Dictionary<T[]> = {};
 | 
			
		||||
    arr.map((item) => {
 | 
			
		||||
        const realItem = item;
 | 
			
		||||
        const newKey = realItem[key];
 | 
			
		||||
        if(newObject[<string>newKey] === undefined) newObject[newKey] = [];
 | 
			
		||||
        newObject[newKey]!.push(realItem);
 | 
			
		||||
    });
 | 
			
		||||
    return newObject;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function filterOut<K extends string, V, T extends {[key in K]: V}>(arr: ReadonlyArray<T>, field: K, value: V): T[] {
 | 
			
		||||
    return arr.filter((item) => item[field] !== value);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
//tslint:disable-next-line:no-any
 | 
			
		||||
export function isJSONError(error: any): error is Error & {response: AxiosResponse<{[key: string]: object | string | number}>} {
 | 
			
		||||
    return (<AxiosError>error).response !== undefined && typeof (<AxiosError>error).response!.data === 'object';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function ajaxError(error: any, prefix: string, showFlashMessage: boolean = true): void { //tslint:disable-line:no-any
 | 
			
		||||
    let message: string | undefined;
 | 
			
		||||
    if(error instanceof Error) {
 | 
			
		||||
        if(Axios.isCancel(error)) return;
 | 
			
		||||
 | 
			
		||||
        if(isJSONError(error)) {
 | 
			
		||||
            const data = <{error?: string | string[]}>error.response.data;
 | 
			
		||||
            if(typeof (data.error) === 'string')
 | 
			
		||||
                message = data.error;
 | 
			
		||||
            else if(typeof (data.error) === 'object' && data.error.length > 0)
 | 
			
		||||
                message = data.error[0];
 | 
			
		||||
        }
 | 
			
		||||
        if(message === undefined)
 | 
			
		||||
            message = (<Error & {response?: AxiosResponse}>error).response !== undefined ?
 | 
			
		||||
                (<Error & {response: AxiosResponse}>error).response.statusText : error.name;
 | 
			
		||||
    } else message = <string>error;
 | 
			
		||||
    if(showFlashMessage) flashError(`[ERROR] ${prefix}: ${message}`);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function flashError(message: string): void {
 | 
			
		||||
    flashMessage('danger', message);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function flashSuccess(message: string): void {
 | 
			
		||||
    flashMessage('success', message);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function flashMessage(type: string, message: string): void {
 | 
			
		||||
    console.log(`${type}: ${message}`); //TODO addFlashMessage(type, message);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export let siteDomain = '';
 | 
			
		||||
export let staticDomain = '';
 | 
			
		||||
 | 
			
		||||
interface Settings {
 | 
			
		||||
    animatedIcons: boolean
 | 
			
		||||
    inlineDisplayMode: InlineDisplayMode
 | 
			
		||||
    defaultCharacter: number
 | 
			
		||||
    fuzzyDates: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export let Settings: Settings = {
 | 
			
		||||
    animatedIcons: false,
 | 
			
		||||
    inlineDisplayMode: InlineDisplayMode.DISPLAY_ALL,
 | 
			
		||||
    defaultCharacter: -1,
 | 
			
		||||
    fuzzyDates: true
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function setDomains(site: string, stat: string): void {
 | 
			
		||||
    siteDomain = site;
 | 
			
		||||
    staticDomain = stat;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function copySettings(settings: Settings): void {
 | 
			
		||||
    Settings.animatedIcons = settings.animatedIcons;
 | 
			
		||||
    Settings.inlineDisplayMode = settings.inlineDisplayMode;
 | 
			
		||||
    Settings.defaultCharacter = settings.defaultCharacter;
 | 
			
		||||
    Settings.fuzzyDates = settings.fuzzyDates;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										15
									
								
								tslint.json
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								tslint.json
									
									
									
									
									
								
							@ -54,6 +54,7 @@
 | 
			
		||||
      true,
 | 
			
		||||
      "array"
 | 
			
		||||
    ],
 | 
			
		||||
    "await-promise": [true, "AxiosPromise"],
 | 
			
		||||
    "comment-format": false,
 | 
			
		||||
    "completed-docs": false,
 | 
			
		||||
    "curly": [
 | 
			
		||||
@ -62,6 +63,7 @@
 | 
			
		||||
    ],
 | 
			
		||||
    "cyclomatic-complexity": false,
 | 
			
		||||
    "eofline": false,
 | 
			
		||||
    "forin": false,
 | 
			
		||||
    "interface-name": false,
 | 
			
		||||
    "interface-over-type-literal": false,
 | 
			
		||||
    "linebreak-style": false,
 | 
			
		||||
@ -74,12 +76,7 @@
 | 
			
		||||
      true,
 | 
			
		||||
      "no-public"
 | 
			
		||||
    ],
 | 
			
		||||
    "member-ordering": [
 | 
			
		||||
      true,
 | 
			
		||||
      {
 | 
			
		||||
        "order": "fields-first"
 | 
			
		||||
      }
 | 
			
		||||
    ],
 | 
			
		||||
    "member-ordering": false,
 | 
			
		||||
    "newline-before-return": false,
 | 
			
		||||
    "no-angle-bracket-type-assertion": false,
 | 
			
		||||
    "no-bitwise": false,
 | 
			
		||||
@ -89,6 +86,7 @@
 | 
			
		||||
    "no-console": false,
 | 
			
		||||
    "no-default-export": false,
 | 
			
		||||
    "no-floating-promises": [true, "AxiosPromise"],
 | 
			
		||||
    "no-implicit-dependencies": false,
 | 
			
		||||
    "no-import-side-effect": [
 | 
			
		||||
      true,
 | 
			
		||||
      {
 | 
			
		||||
@ -100,7 +98,6 @@
 | 
			
		||||
    "no-non-null-assertion": false,
 | 
			
		||||
    "no-parameter-properties": false,
 | 
			
		||||
    "no-parameter-reassignment": false,
 | 
			
		||||
    //covered by --noImplicitAny
 | 
			
		||||
    "no-string-literal": false,
 | 
			
		||||
    "no-submodule-imports": [true, "vue", "bootstrap"],
 | 
			
		||||
    "no-unused-variable": false,
 | 
			
		||||
@ -137,6 +134,7 @@
 | 
			
		||||
      true,
 | 
			
		||||
      "never"
 | 
			
		||||
    ],
 | 
			
		||||
    "strict-boolean-expressions": [true, "allow-boolean-or-undefined"],
 | 
			
		||||
    "switch-default": false,
 | 
			
		||||
    "trailing-comma": [
 | 
			
		||||
      true,
 | 
			
		||||
@ -168,8 +166,7 @@
 | 
			
		||||
      "check-type-operator",
 | 
			
		||||
      "check-rest-spread"
 | 
			
		||||
    ],
 | 
			
		||||
    "vue-props": true,
 | 
			
		||||
    "no-return-await": true
 | 
			
		||||
    "vue-props": true
 | 
			
		||||
  },
 | 
			
		||||
  "rulesDirectory": ["./tslint"]
 | 
			
		||||
}
 | 
			
		||||
@ -1,36 +0,0 @@
 | 
			
		||||
"use strict";
 | 
			
		||||
exports.__esModule = true;
 | 
			
		||||
var tslib_1 = require("tslib");
 | 
			
		||||
var Lint = require("tslint");
 | 
			
		||||
var ts = require("typescript");
 | 
			
		||||
var Rule = /** @class */ (function (_super) {
 | 
			
		||||
    tslib_1.__extends(Rule, _super);
 | 
			
		||||
    function Rule() {
 | 
			
		||||
        return _super !== null && _super.apply(this, arguments) || this;
 | 
			
		||||
    }
 | 
			
		||||
    Rule.prototype.apply = function (sourceFile) {
 | 
			
		||||
        return this.applyWithFunction(sourceFile, walk, undefined);
 | 
			
		||||
    };
 | 
			
		||||
    return Rule;
 | 
			
		||||
}(Lint.Rules.AbstractRule));
 | 
			
		||||
exports.Rule = Rule;
 | 
			
		||||
function walk(ctx) {
 | 
			
		||||
    if (ctx.sourceFile.isDeclarationFile)
 | 
			
		||||
        return;
 | 
			
		||||
    return ts.forEachChild(ctx.sourceFile, cb);
 | 
			
		||||
    function cb(node) {
 | 
			
		||||
        if (node.kind !== ts.SyntaxKind.ReturnStatement || node.expression === undefined)
 | 
			
		||||
            return ts.forEachChild(node, cb);
 | 
			
		||||
        var curNode = node.expression;
 | 
			
		||||
        while (true) {
 | 
			
		||||
            switch (curNode.kind) {
 | 
			
		||||
                case ts.SyntaxKind.ParenthesizedExpression:
 | 
			
		||||
                    curNode = curNode.expression;
 | 
			
		||||
                    continue;
 | 
			
		||||
                case ts.SyntaxKind.AwaitExpression:
 | 
			
		||||
                    ctx.addFailureAtNode(node, 'return await is redundant');
 | 
			
		||||
            }
 | 
			
		||||
            break;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user