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.',
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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";
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
|
@ -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;`;
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||
});
|
|
@ -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;
|
|
@ -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…
Reference in New Issue