Replace Ember app with Mithril app

This commit is contained in:
Toby Zerner
2015-04-25 22:28:39 +09:30
parent 6f67b8c247
commit b68a4711dc
377 changed files with 5641 additions and 7330 deletions

42
js/lib/component.js Normal file
View File

@ -0,0 +1,42 @@
/**
*/
export default class Component {
/**
*/
constructor(props) {
this.props = props || {};
this.element = m.prop();
}
/**
*/
$(selector) {
return selector ? $(this.element()).find(selector) : $(this.element());
}
/**
*/
static component(props) {
props = props || {};
var view = function(component) {
component.props = props;
return component.view();
};
view.$original = this.prototype.view;
var output = {
props: props,
component: this,
controller: this.bind(undefined, props),
view: view
};
if (props.key) {
output.attrs = {key: props.key};
}
return output;
}
}

View File

@ -0,0 +1,21 @@
import Component from 'flarum/component';
import icon from 'flarum/helpers/icon';
export default class ActionButton extends Component {
view() {
var attrs = {};
for (var i in this.props) { attrs[i] = this.props[i]; }
var iconName = attrs.icon;
delete attrs.icon;
var label = attrs.label;
delete attrs.label;
attrs.href = attrs.href || 'javascript:;';
return m('a', attrs, [
iconName ? icon(iconName+' icon-glyph') : '',
m('span.label', label)
]);
}
}

View File

@ -0,0 +1,33 @@
import Component from 'flarum/component';
import ActionButton from 'flarum/components/action-button';
import listItems from 'flarum/helpers/list-items';
export default class Alert extends Component {
view() {
var attrs = {};
for (var i in this.props) { attrs[i] = this.props[i]; }
attrs.className = (attrs.className || '') + ' alert-'+attrs.type;
delete attrs.type;
var message = attrs.message;
delete attrs.message;
var controlItems = attrs.controls.slice() || [];
delete attrs.controls;
if (attrs.dismissible || attrs.dismissible === undefined) {
controlItems.push(ActionButton.component({
icon: 'times',
className: 'btn btn-icon btn-link',
onclick: attrs.ondismiss.bind(this)
}));
}
delete attrs.dismissible;
return m('div.alert', attrs, [
m('span.alert-text', message),
m('ul.alert-controls', listItems(controlItems))
]);
}
}

View File

@ -0,0 +1,34 @@
import Component from 'flarum/component';
export default class Alerts extends Component {
constructor(props) {
super(props);
this.components = [];
}
view() {
return m('div.alerts', this.components.map((component) => {
component.props.ondismiss = this.dismiss.bind(this, component);
return m('div.alert-wrapper', component);
}));
}
show(component) {
this.components.push(component);
m.redraw();
}
dismiss(component) {
var index = this.components.indexOf(component);
if (index !== -1) {
this.components.splice(index, 1);
}
m.redraw();
}
clear() {
this.components = [];
m.redraw();
}
}

View File

@ -0,0 +1,32 @@
import Component from 'flarum/component';
import icon from 'flarum/helpers/icon';
/**
The back/pin button group in the top-left corner of Flarum's interface.
*/
export default class BackButton extends Component {
view() {
var history = app.history;
var pane = app.pane;
return m('div.back-button', {
className: this.props.className || '',
onmouseenter: pane && pane.show.bind(pane),
onmouseleave: pane && pane.onmouseleave.bind(pane),
config: this.onload.bind(this)
}, history.canGoBack() ? m('div.btn-group', [
m('button.btn.btn-default.btn-icon.back', {onclick: history.back.bind(history)}, icon('chevron-left icon-glyph')),
pane && pane.active ? m('button.btn.btn-default.btn-icon.pin'+(pane.active ? '.active' : ''), {onclick: pane.togglePinned.bind(pane)}, icon('thumb-tack icon-glyph')) : '',
]) : (this.props.drawer ? [
m('button.btn.btn-default.btn-icon.drawer-toggle', {onclick: this.toggleDrawer.bind(this)}, icon('reorder icon-glyph'))
] : ''));
}
onload(element, isInitialized, context) {
context.retain = true;
}
toggleDrawer() {
$('body').toggleClass('drawer-open');
}
}

View File

@ -0,0 +1,19 @@
import Component from 'flarum/component';
import icon from 'flarum/helpers/icon';
export default class Badge extends Component {
view(ctrl) {
var iconName = this.props.icon;
var label = this.props.title = this.props.label;
delete this.props.icon, this.props.label;
this.props.config = function(element) {
$(element).tooltip();
};
this.props.className = 'badge '+(this.props.className || '');
return m('span', this.props, [
icon(iconName+' icon-glyph'),
m('span.label', label)
]);
}
}

View File

@ -0,0 +1,20 @@
import Component from 'flarum/component';
import icon from 'flarum/helpers/icon';
import listItems from 'flarum/helpers/list-items';
export default class DropdownButton extends Component {
view() {
return m('div', {className: 'dropdown btn-group '+(this.props.items ? 'item-count-'+this.props.items.length : '')+' '+(this.props.className || '')}, [
m('a[href=javascript:;]', {
className: 'dropdown-toggle '+(this.props.buttonClass || 'btn btn-default'),
'data-toggle': 'dropdown',
onclick: this.props.buttonClick
}, this.props.buttonContent || [
icon((this.props.icon || 'ellipsis-v')+' icon-glyph'),
m('span.label', this.props.label || 'Controls'),
icon('caret-down icon-caret')
]),
m(this.props.menuContent ? 'div' : 'ul', {className: 'dropdown-menu '+(this.props.menuClass || '')}, this.props.menuContent || listItems(this.props.items))
]);
}
}

View File

@ -0,0 +1,18 @@
import Component from 'flarum/component'
import icon from 'flarum/helpers/icon'
import listItems from 'flarum/helpers/list-items';
export default class DropdownSelect extends Component {
view() {
var activeItem = this.props.items.filter((item) => item.component.active && item.component.active(item.props))[0];
var label = activeItem && activeItem.props.label;
return m('div', {className: 'dropdown dropdown-select btn-group item-count-'+this.props.items.length+' '+this.props.className}, [
m('a[href=javascript:;]', {className: 'dropdown-toggle '+(this.props.buttonClass || 'btn btn-default'), 'data-toggle': 'dropdown'}, [
m('span.label', label), ' ',
icon('sort icon-caret')
]),
m('ul', {className: 'dropdown-menu '+this.props.menuClass}, listItems(this.props.items, true))
])
}
}

View File

@ -0,0 +1,30 @@
import Component from 'flarum/component';
import icon from 'flarum/helpers/icon';
import listItems from 'flarum/helpers/list-items';
import ActionButton from 'flarum/components/action-button';
/**
Given a list of items, this component displays a split button: the left side
is the first item in the list, while the right side is a dropdown-toggle
which shows a dropdown menu containing all of the items.
*/
export default class DropdownSplit extends Component {
view() {
var firstItem = this.props.items[0];
var items = listItems(this.props.items);
var buttonProps = { className: this.props.buttonClass || 'btn btn-default' };
for (var i in firstItem.props) {
buttonProps[i] = firstItem.props[i];
}
return m('div', {className: 'dropdown dropdown-split btn-group item-count-'+(items.length)+' '+this.props.className}, [
ActionButton.component(buttonProps),
m('a[href=javascript:;]', {className: 'dropdown-toggle '+this.props.buttonClass, 'data-toggle': 'dropdown'}, [
icon('caret-down icon-caret'),
icon((this.props.icon || 'ellipsis-v')+' icon-glyph'),
]),
m('ul', {className: 'dropdown-menu '+(this.props.menuClass || 'pull-right')}, items)
])
}
}

View File

@ -0,0 +1,11 @@
import Component from 'flarum/component';
import listItems from 'flarum/helpers/list-items';
export default class FieldSet extends Component {
view() {
return m('fieldset', {className: this.props.className}, [
m('legend', this.props.label),
m('ul', listItems(this.props.fields))
]);
}
}

View File

@ -0,0 +1,15 @@
import Component from 'flarum/component';
export default class LoadingIndicator extends Component {
view() {
var size = this.props.size || 'small';
delete this.props.size;
this.props.config = function(element) {
$.fn.spin.presets[size].zIndex = 'auto';
$(element).spin(size);
};
return m('div.loading-indicator', this.props, m.trust(' '));
}
}

View File

@ -0,0 +1,35 @@
import Component from 'flarum/component';
export default class Modal extends Component {
view() {
return m('div.modal.fade', {config: this.onload.bind(this)}, this.component && this.component.view())
}
onload(element, isInitialized) {
if (isInitialized) { return; }
this.element(element);
this.$()
.on('hidden.bs.modal', this.destroy.bind(this))
.on('shown.bs.modal', this.ready.bind(this));
}
show(component) {
this.component = component;
m.redraw(true);
this.$().modal('show');
}
close() {
this.$().modal('hide');
}
destroy() {
this.component = null;
}
ready() {
this.component && this.component.ready && this.component.ready(this.$());
}
}

View File

@ -0,0 +1,17 @@
import Component from 'flarum/component'
import icon from 'flarum/helpers/icon'
export default class NavItem extends Component {
view() {
var active = NavItem.active(this.props);
return m('li'+(active ? '.active' : ''), m('a', {href: this.props.href, config: m.route}, [
icon(this.props.icon),
this.props.label, ' ',
m('span.count', this.props.badge)
]))
}
static active(props) {
return typeof props.active !== 'undefined' ? props.active : m.route() === props.href;
}
}

View File

@ -0,0 +1,13 @@
import Component from 'flarum/component'
import icon from 'flarum/helpers/icon';
export default class SelectInput extends Component {
view(ctrl) {
return m('span.select-input', [
m('select.form-control', {onchange: m.withAttr('value', this.props.onchange.bind(ctrl)), value: this.props.value}, [
this.props.options.map(function(option) { return m('option', {value: option.key}, option.value) })
]),
icon('sort')
])
}
}

View File

@ -0,0 +1,14 @@
import Component from 'flarum/component';
/**
*/
class Separator extends Component {
view() {
return m('span');
}
}
Separator.wrapperClass = 'divider';
export default Separator;

View File

@ -0,0 +1,30 @@
import Component from 'flarum/component';
import LoadingIndicator from 'flarum/components/loading-indicator';
export default class SwitchInput extends Component {
constructor(props) {
super(props);
this.loading = m.prop(false);
}
view() {
return m('div.checkbox.checkbox-switch', [
m('label', [
m('div.switch-control', [
m('input[type=checkbox]', {
checked: this.props.state,
onchange: m.withAttr('checked', this.onchange.bind(this))
}),
m('div.switch', {className: this.loading() && 'loading'})
]),
this.props.label, ' ',
this.loading() ? LoadingIndicator.component({size: 'tiny'}) : ''
])
])
}
onchange(checked) {
this.props.onchange && this.props.onchange(checked, this);
}
}

View File

@ -0,0 +1,65 @@
import Component from 'flarum/component';
import ItemList from 'flarum/utils/item-list';
import listItems from 'flarum/helpers/list-items';
import ActionButton from 'flarum/components/action-button';
/**
A text editor. Contains a textarea and an item list of `controls`, including
a submit button.
*/
export default class TextEditor extends Component {
constructor(props) {
props.submitLabel = props.submitLabel || 'Submit';
super(props);
this.value = m.prop(this.props.value || '');
}
view() {
return m('div.text-editor', {config: this.element}, [
m('textarea.form-control.flexible-height', {
config: this.configTextarea.bind(this),
onkeyup: m.withAttr('value', this.onkeyup.bind(this)),
placeholder: this.props.placeholder || '',
disabled: !!this.props.disabled,
value: this.props.value || ''
}),
m('ul.text-editor-controls.fade', listItems(this.controlItems().toArray()))
]);
}
configTextarea(element, isInitialized) {
if (isInitialized) { return; }
$(element).bind('keydown', 'meta+return', this.onsubmit.bind(this));
}
controlItems() {
var items = new ItemList();
items.add('submit',
ActionButton.component({
label: this.props.submitLabel,
icon: 'check',
className: 'btn btn-primary',
wrapperClass: 'primary-control',
onclick: this.onsubmit.bind(this)
})
);
return items;
}
onkeyup(value) {
this.value(value);
this.props.onchange(this.value());
this.$('.text-editor-controls').toggleClass('in', !!value);
m.redraw.strategy('none');
}
onsubmit() {
this.props.onsubmit(this.value());
}
}

View File

@ -0,0 +1,35 @@
import Component from 'flarum/component';
import LoadingIndicator from 'flarum/components/loading-indicator';
import classList from 'flarum/utils/class-list';
import icon from 'flarum/helpers/icon';
export default class YesNoInput extends Component {
constructor(props) {
super(props);
this.loading = m.prop(false);
}
view() {
return m('label.yesno-control', [
m('input[type=checkbox]', {
checked: this.props.state,
disabled: this.props.disabled,
onchange: m.withAttr('checked', this.onchange.bind(this))
}),
m('div.yesno', {className: classList({
loading: this.loading(),
disabled: this.props.disabled,
state: this.props.state ? 'yes' : 'no'
})}, [
this.loading()
? LoadingIndicator.component({size: 'tiny'})
: icon(this.props.state ? 'check' : 'times')
])
]);
}
onchange(checked) {
this.props.onchange && this.props.onchange(checked, this);
}
}

View File

@ -0,0 +1,8 @@
export function extend(object, func, extension) {
var oldFunc = object[func];
object[func] = function() {
var value = oldFunc.apply(this, arguments);
var args = [].slice.apply(arguments);
return extension.apply(this, [value].concat(args));
}
};

26
js/lib/helpers/avatar.js Normal file
View File

@ -0,0 +1,26 @@
export default function avatar(user, args) {
args = args || {}
args.className = 'avatar '+(args.className || '')
var content = ''
var title = typeof args.title === 'undefined' || args.title
if (!title) { delete args.title }
if (user) {
var username = user.username() || '?'
if (title) { args.title = args.title || username }
var avatarUrl = user.avatarUrl()
if (avatarUrl) {
args.src = avatarUrl
return m('img', args)
}
content = username.charAt(0).toUpperCase()
args.style = {background: user.color()}
}
if (!args.title) { delete args.title }
return m('span', args, content)
}

View File

@ -0,0 +1,7 @@
export default function fullTime(time) {
var time = moment(time);
var datetime = time.format();
var full = time.format('LLLL');
return m('time', {pubdate: '', datetime}, full);
}

View File

@ -0,0 +1,11 @@
import humanTime from 'flarum/utils/human-time';
export default function humanTimeHelper(time) {
var time = moment(time);
var datetime = time.format();
var full = time.format('LLLL');
var ago = humanTime(time);
return m('time', {pubdate: '', datetime, title: full, 'data-humantime': ''}, ago);
}

3
js/lib/helpers/icon.js Normal file
View File

@ -0,0 +1,3 @@
export default function icon(icon) {
return m('i.fa.fa-fw.fa-'+icon)
}

View File

@ -0,0 +1,21 @@
import Separator from 'flarum/components/separator';
function isSeparator(item) {
return item && item.component === Separator;
}
export default function listItems(array, noWrap) {
// Remove duplicate/unnecessary separators
var prevItem;
var newArray = [];
array.forEach(function(item, i) {
if ((!prevItem || isSeparator(prevItem) || i === array.length - 1) && isSeparator(item)) {
} else {
prevItem = item;
newArray.push(item);
}
});
return newArray.map(item => [(noWrap && !isSeparator(item)) ? item : m('li', {className: (item.props && item.props.wrapperClass) || (item.component && item.component.wrapperClass) || ''}, item), ' ']);
};

View File

@ -0,0 +1,5 @@
export default function username(user) {
var username = (user && user.username()) || '[deleted]';
return m('span.username', username);
}

View File

@ -0,0 +1,5 @@
export default function(app) {
if (app.preload.data) {
app.store.pushPayload({data: app.preload.data});
}
};

View File

@ -0,0 +1,10 @@
import Session from 'flarum/session';
export default function(app) {
app.session = new Session();
if (app.preload.session) {
app.session.token(app.preload.session.token);
app.session.user(app.store.getById('users', app.preload.session.userId));
}
}

View File

@ -0,0 +1,18 @@
import Store from 'flarum/store';
import User from 'flarum/models/user';
import Discussion from 'flarum/models/discussion';
import Post from 'flarum/models/post';
import Group from 'flarum/models/group';
import Activity from 'flarum/models/activity';
import Notification from 'flarum/models/notification';
export default function(app) {
app.store = new Store();
app.store.model('users', User);
app.store.model('discussions', Discussion);
app.store.model('posts', Post);
app.store.model('groups', Group);
app.store.model('activity', Activity);
app.store.model('notifications', Notification);
};

View File

@ -0,0 +1,138 @@
import humanTime from 'flarum/utils/human-time';
export default function(app) {
// Livestamp.js / v1.1.2 / (c) 2012 Matt Bradley / MIT License
// @todo rewrite this to be simpler and cleaner
(function($, moment) {
var updateInterval = 1e3,
paused = false,
$livestamps = $([]),
init = function() {
livestampGlobal.resume();
},
prep = function($el, timestamp) {
var oldData = $el.data('livestampdata');
if (typeof timestamp == 'number')
timestamp *= 1e3;
$el.removeAttr('data-humantime')
.removeData('humantime');
timestamp = moment(timestamp);
if (moment().diff(timestamp) > 60 * 60) {
return;
}
if (moment.isMoment(timestamp) && !isNaN(+timestamp)) {
var newData = $.extend({ }, { 'original': $el.contents() }, oldData);
newData.moment = moment(timestamp);
$el.data('livestampdata', newData).empty();
$livestamps.push($el[0]);
}
},
run = function() {
if (paused) return;
livestampGlobal.update();
setTimeout(run, updateInterval);
},
livestampGlobal = {
update: function() {
$('[data-humantime]').each(function() {
var $this = $(this);
prep($this, $this.attr('datetime'));
});
var toRemove = [];
$livestamps.each(function() {
var $this = $(this),
data = $this.data('livestampdata');
if (data === undefined)
toRemove.push(this);
else if (moment.isMoment(data.moment)) {
var from = $this.html(),
to = humanTime(data.moment);
// to = data.moment.fromNow();
if (from != to) {
var e = $.Event('change.livestamp');
$this.trigger(e, [from, to]);
if (!e.isDefaultPrevented())
$this.html(to);
}
}
});
$livestamps = $livestamps.not(toRemove);
},
pause: function() {
paused = true;
},
resume: function() {
paused = false;
run();
},
interval: function(interval) {
if (interval === undefined)
return updateInterval;
updateInterval = interval;
}
},
livestampLocal = {
add: function($el, timestamp) {
if (typeof timestamp == 'number')
timestamp *= 1e3;
timestamp = moment(timestamp);
if (moment.isMoment(timestamp) && !isNaN(+timestamp)) {
$el.each(function() {
prep($(this), timestamp);
});
livestampGlobal.update();
}
return $el;
},
destroy: function($el) {
$livestamps = $livestamps.not($el);
$el.each(function() {
var $this = $(this),
data = $this.data('livestampdata');
if (data === undefined)
return $el;
$this
.html(data.original ? data.original : '')
.removeData('livestampdata');
});
return $el;
},
isLivestamp: function($el) {
return $el.data('livestampdata') !== undefined;
}
};
$.livestamp = livestampGlobal;
$(init);
$.fn.livestamp = function(method, options) {
if (!livestampLocal[method]) {
options = method;
method = 'add';
}
return livestampLocal[method](this, options);
};
})(jQuery, moment);
}

88
js/lib/model.js Normal file
View File

@ -0,0 +1,88 @@
export default class Model {
constructor(data, store) {
this.data = m.prop(data || {});
this.freshness = new Date();
this.exists = false;
this.store = store;
}
pushData(newData) {
var data = this.data();
for (var i in newData) {
if (i === 'links') {
data[i] = data[i] || {};
for (var j in newData[i]) {
if (newData[i][j] instanceof Model) {
newData[i][j] = {linkage: {type: newData[i][j].data().type, id: newData[i][j].data().id}};
}
data[i][j] = newData[i][j];
}
} else {
data[i] = newData[i];
}
}
this.freshness = new Date();
}
save(data) {
if (data.links) {
for (var i in data.links) {
var model = data.links[i];
data.links[i] = {linkage: {type: model.data().type, id: model.data().id}};
}
}
this.pushData(data);
return m.request({
method: this.exists ? 'PUT' : 'POST',
url: app.config.apiURL+'/'+this.data().type+(this.exists ? '/'+this.data().id : ''),
data: {data},
background: true,
config: app.session.authorize.bind(app.session)
}).then(payload => {
this.store.data[payload.data.type][payload.data.id] = this;
return this.store.pushPayload(payload);
});
}
delete() {
if (!this.exists) { return; }
return m.request({
method: 'DELETE',
url: app.config.apiURL+'/'+this.data().type+'/'+this.data().id,
background: true,
config: app.session.authorize.bind(app.session)
}).then(() => this.exists = false);
}
static prop(name, transform) {
return function() {
var data = this.data()[name];
return transform ? transform(data) : data
}
}
static one(name) {
return function() {
var link = this.data().links[name];
return link && app.store.getById(link.linkage.type, link.linkage.id)
}
}
static many(name) {
return function() {
var link = this.data().links[name];
return link && link.linkage.map(function(link) {
return app.store.getById(link.type, link.id)
})
}
}
static date(data) {
return data ? new Date(data) : null;
}
}

14
js/lib/models/activity.js Normal file
View File

@ -0,0 +1,14 @@
import Model from 'flarum/model';
class Activity extends Model {}
Activity.prototype.id = Model.prop('id');
Activity.prototype.contentType = Model.prop('contentType');
Activity.prototype.content = Model.prop('content');
Activity.prototype.time = Model.prop('time', Model.date);
Activity.prototype.user = Model.one('user');
Activity.prototype.sender = Model.one('sender');
Activity.prototype.post = Model.one('post');
export default Activity;

View File

@ -0,0 +1,45 @@
import Model from 'flarum/model';
import computed from 'flarum/utils/computed';
import ItemList from 'flarum/utils/item-list';
class Discussion extends Model {}
Discussion.prototype.id = Model.prop('id');
Discussion.prototype.title = Model.prop('title');
Discussion.prototype.slug = computed('title', title => title.toLowerCase().replace(/[^a-z0-9]/gi, '-').replace(/-+/g, '-').replace(/-$|^-/g, ''));
Discussion.prototype.startTime = Model.prop('startTime', Model.date);
Discussion.prototype.startUser = Model.one('startUser');
Discussion.prototype.startPost = Model.one('startPost');
Discussion.prototype.lastTime = Model.prop('lastTime', Model.date);
Discussion.prototype.lastUser = Model.one('lastUser');
Discussion.prototype.lastPost = Model.one('lastPost');
Discussion.prototype.lastPostNumber = Model.prop('lastPostNumber');
Discussion.prototype.canReply = Model.prop('canReply');
Discussion.prototype.canEdit = Model.prop('canEdit');
Discussion.prototype.canDelete = Model.prop('canDelete');
Discussion.prototype.commentsCount = Model.prop('commentsCount');
Discussion.prototype.repliesCount = computed('commentsCount', commentsCount => commentsCount - 1);
Discussion.prototype.posts = Model.many('posts');
Discussion.prototype.relevantPosts = Model.many('relevantPosts');
Discussion.prototype.addedPosts = Model.many('addedPosts');
Discussion.prototype.readTime = Model.prop('readTime', Model.date);
Discussion.prototype.readNumber = Model.prop('readNumber');
Discussion.prototype.unreadCount = function() {
var user = app.session.user();
if (user && user.readTime() < this.lastTime()) {
return Math.max(0, this.lastPostNumber() - (this.readNumber() || 0))
}
return 0
};
Discussion.prototype.isUnread = computed('unreadCount', unreadCount => !!unreadCount);
Discussion.prototype.badges = () => new ItemList();
export default Discussion;

8
js/lib/models/group.js Normal file
View File

@ -0,0 +1,8 @@
import Model from 'flarum/model';
class Group extends Model {}
Group.prototype.id = Model.prop('id');
Group.prototype.name = Model.prop('name');
export default Group;

View File

@ -0,0 +1,19 @@
import Model from 'flarum/model';
import computed from 'flarum/utils/computed';
class Notification extends Model {}
Notification.prototype.id = Model.prop('id');
Notification.prototype.contentType = Model.prop('contentType');
Notification.prototype.subjectId = Model.prop('subjectId');
Notification.prototype.content = Model.prop('content');
Notification.prototype.time = Model.prop('time', Model.date);
Notification.prototype.isRead = Model.prop('isRead');
Notification.prototype.unreadCount = Model.prop('unreadCount');
Notification.prototype.additionalUnreadCount = computed('unreadCount', unreadCount => Math.max(0, unreadCount - 1));
Notification.prototype.user = Model.one('user');
Notification.prototype.sender = Model.one('sender');
Notification.prototype.subject = Model.one('subject');
export default Notification;

27
js/lib/models/post.js Normal file
View File

@ -0,0 +1,27 @@
import Model from 'flarum/model';
import computed from 'flarum/utils/computed';
class Post extends Model {}
Post.prototype.id = Model.prop('id');
Post.prototype.number = Model.prop('number');
Post.prototype.discussion = Model.one('discussion');
Post.prototype.time = Model.prop('time');
Post.prototype.user = Model.one('user');
Post.prototype.contentType = Model.prop('contentType');
Post.prototype.content = Model.prop('content');
Post.prototype.contentHtml = Model.prop('contentHtml');
Post.prototype.editTime = Model.prop('editTime', Model.date);
Post.prototype.editUser = Model.one('editUser');
Post.prototype.isEdited = computed('editTime', editTime => !!editTime);
Post.prototype.hideTime = Model.prop('hideTime', Model.date);
Post.prototype.hideUser = Model.one('hideUser');
Post.prototype.isHidden = computed('hideTime', hideTime => !!hideTime);
Post.prototype.canEdit = Model.prop('canEdit');
Post.prototype.canDelete = Model.prop('canDelete');
export default Post;

53
js/lib/models/user.js Normal file
View File

@ -0,0 +1,53 @@
import Model from 'flarum/model'
import stringToColor from 'flarum/utils/string-to-color';
import ItemList from 'flarum/utils/item-list';
import computed from 'flarum/utils/computed';
class User extends Model {}
User.prototype.id = Model.prop('id');
User.prototype.username = Model.prop('username');
User.prototype.email = Model.prop('email');
User.prototype.isConfirmed = Model.prop('isConfirmed');
User.prototype.password = Model.prop('password');
User.prototype.avatarUrl = Model.prop('avatarUrl');
User.prototype.bio = Model.prop('bio');
User.prototype.bioHtml = Model.prop('bioHtml');
User.prototype.preferences = Model.prop('preferences');
User.prototype.groups = Model.many('groups');
User.prototype.joinTime = Model.prop('joinTime', Model.date);
User.prototype.lastSeenTime = Model.prop('lastSeenTime', Model.date);
User.prototype.online = function() { return this.lastSeenTime() > moment().subtract(5, 'minutes').toDate(); };
User.prototype.readTime = Model.prop('readTime', Model.date);
User.prototype.unreadNotificationsCount = Model.prop('unreadNotificationsCount');
User.prototype.discussionsCount = Model.prop('discussionsCount');
User.prototype.commentsCount = Model.prop('commentsCount');
;
User.prototype.canEdit = Model.prop('canEdit');
User.prototype.canDelete = Model.prop('canDelete');
User.prototype.color = computed('username', 'avatarUrl', 'avatarColor', function(username, avatarUrl, avatarColor) {
if (avatarColor) {
return 'rgb('+avatarColor[0]+', '+avatarColor[1]+', '+avatarColor[2]+')';
} else if (avatarUrl) {
var image = new Image();
var user = this;
image.onload = function() {
var colorThief = new ColorThief();
user.avatarColor = colorThief.getColor(this);
user.freshness = new Date();
m.redraw();
};
image.src = avatarUrl;
return '';
} else {
return '#'+stringToColor(username);
}
});
User.prototype.badges = () => new ItemList();
export default User;

41
js/lib/session.js Normal file
View File

@ -0,0 +1,41 @@
import mixin from 'flarum/utils/mixin';
import evented from 'flarum/utils/evented';
export default class Session extends mixin(class {}, evented) {
constructor() {
super();
this.user = m.prop();
this.token = m.prop();
}
login(identification, password) {
var deferred = m.deferred();
var self = this;
m.request({
method: 'POST',
url: app.config.baseURL+'/login',
data: {identification, password},
background: true
}).then(function(response) {
self.token(response.token);
m.startComputation();
app.store.find('users', response.userId).then(function(user) {
self.user(user);
deferred.resolve(user);
self.trigger('loggedIn', user);
m.endComputation();
});
}, function(response) {
deferred.reject(response);
});
return deferred.promise;
}
logout() {
window.location = app.config.baseURL+'/logout';
}
authorize(xhr) {
xhr.setRequestHeader('Authorization', 'Token '+this.token());
}
}

67
js/lib/store.js Normal file
View File

@ -0,0 +1,67 @@
export default class Store {
constructor() {
this.data = {}
this.models = {}
}
pushPayload(payload) {
payload.included && payload.included.map(this.pushObject.bind(this))
var result = payload.data instanceof Array ? payload.data.map(this.pushObject.bind(this)) : this.pushObject(payload.data);
result.meta = payload.meta;
result.payload = payload;
return result;
}
pushObject(data) {
if (!this.models[data.type]) { return; }
var type = this.data[data.type] = this.data[data.type] || {};
if (type[data.id]) {
type[data.id].pushData(data);
} else {
type[data.id] = this.createRecord(data.type, data);
}
type[data.id].exists = true;
return type[data.id];
}
find(type, id, query) {
var endpoint = type
var params = {}
if (id instanceof Array) {
endpoint += '?ids[]='+id.join('&ids[]=');
params = query
} else if (typeof id === 'object') {
params = id
} else if (id) {
endpoint += '/'+id
params = query
}
return m.request({
method: 'GET',
url: app.config.apiURL+'/'+endpoint,
data: params,
background: true,
config: app.session.authorize.bind(app.session)
}).then(this.pushPayload.bind(this));
}
getById(type, id) {
return this.data[type] && this.data[type][id];
}
all(type) {
return this.data[type] || {};
}
model(type, Model) {
this.models[type] = Model;
}
createRecord(type, data) {
data = data || {};
data.type = data.type || type;
return new (this.models[type])(data, this);
}
}

View File

@ -0,0 +1,3 @@
export default function(number) {
return ''+number; // todo
}

21
js/lib/utils/app.js Normal file
View File

@ -0,0 +1,21 @@
import ItemList from 'flarum/utils/item-list';
class App {
constructor() {
this.initializers = new ItemList();
this.cache = {};
}
boot() {
this.initializers.toArray().forEach((initializer) => initializer(this));
}
route(name, args, queryParams) {
var queryString = m.route.buildQueryString(queryParams);
return this.routes[name][0].replace(/:([^\/]+)/g, function(m, t) {
return typeof args[t] === 'function' ? args[t]() : args[t];
}) + (queryString ? '?'+queryString : '');
}
}
export default App;

View File

@ -0,0 +1,12 @@
export default function classList(classes) {
var classNames = [];
for (var i in classes) {
var value = classes[i];
if (value === true) {
classNames.push(i);
} else if (value) {
classNames.push(value);
}
}
return classNames.join(' ');
}

22
js/lib/utils/computed.js Normal file
View File

@ -0,0 +1,22 @@
export default function computed() {
var args = [].slice.apply(arguments);
var keys = args.slice(0, -1);
var compute = args.slice(-1)[0];
var values = {};
var computed;
return function() {
var recompute = false;
keys.forEach(function(key) {
var value = typeof this[key] === 'function' ? this[key]() : this[key];
if (values[key] !== value) {
recompute = true;
values[key] = value;
}
}.bind(this));
if (recompute) {
computed = compute.apply(this, keys.map((key) => values[key]));
}
return computed;
}
};

36
js/lib/utils/evented.js Normal file
View File

@ -0,0 +1,36 @@
export default {
handlers: null,
/**
*/
getHandlers(event) {
this.handlers = this.handlers || {};
return this.handlers[event] = this.handlers[event] || [];
},
/**
*/
trigger(event, ...args) {
this.getHandlers(event).forEach((handler) => handler.apply(this, args));
},
/**
*/
on(event, handler) {
this.getHandlers(event).push(handler);
},
/**
*/
off(event, handler) {
var handlers = this.getHandlers(event);
var index = handlers.indexOf(handler);
if (index !== -1) {
handlers.splice(index, 1);
}
}
}

View File

@ -0,0 +1,40 @@
moment.locale('en', {
relativeTime : {
future: "in %s",
past: "%s ago",
s: "seconds",
m: "1m",
mm: "%dm",
h: "1h",
hh: "%dh",
d: "1d",
dd: "%dd",
M: "a month",
MM: "%d months",
y: "a year",
yy: "%d years"
}
});
export default function humanTime(time) {
var m = moment(time);
var minute = 6e4;
var hour = 36e5;
var day = 864e5;
var ago = null;
var diff = m.diff(moment());
if (diff < -30 * day) {
if (m.year() === moment().year()) {
ago = m.format('D MMM');
} else {
ago = m.format('MMM \'YY');
}
} else {
ago = m.fromNow();
}
return ago;
};

55
js/lib/utils/item-list.js Normal file
View File

@ -0,0 +1,55 @@
export class Item {
constructor(content, position) {
this.content = content;
this.position = position;
}
}
export default class ItemList {
add(key, content, position) {
this[key] = new Item(content, position);
}
toArray() {
var items = [];
for (var i in this) {
if (this.hasOwnProperty(i) && this[i] instanceof Item) {
items.push(this[i]);
}
}
var array = [];
var addItems = function(method, position) {
items = items.filter(function(item) {
if ((position && item.position && item.position[position]) || (!position && !item.position)) {
array[method](item);
} else {
return true;
}
});
};
addItems('unshift', 'first');
addItems('push', false);
addItems('push', 'last');
items = items.filter(function(item) {
var key = item.position.before || item.position.after;
var type = item.position.before ? 'before' : 'after';
if (key) {
var index = array.indexOf(this[key]);
if (index === -1) {
console.log("Can't find item with key '"+key+"' to insert "+type+", inserting at end instead");
return true;
} else {
array.splice(array.indexOf(this[key]) + (type === 'after' ? 1 : 0), 0, item);
}
}
}.bind(this));
array = array.concat(items);
return array.map((item) => item.content);
}
}

View File

@ -0,0 +1,7 @@
export default function mapRoutes(routes) {
var map = {};
for (var r in routes) {
map[routes[r][0]] = routes[r][1];
}
return map;
}

11
js/lib/utils/mixin.js Normal file
View File

@ -0,0 +1,11 @@
export default function mixin(Parent, ...mixins) {
class Mixed extends Parent {}
for (var i in mixins) {
var keys = Object.keys(mixins[i]);
for (var j in keys) {
var prop = keys[j];
Mixed.prototype[prop] = mixins[i][prop];
}
}
return Mixed;
}

View File

@ -0,0 +1,43 @@
var scroll = window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.msRequestAnimationFrame ||
window.oRequestAnimationFrame ||
function(callback) { window.setTimeout(callback, 1000/60) };
export default class ScrollListener {
constructor(callback) {
this.callback = callback;
this.lastTop = -1;
}
loop() {
if (!this.active) {
return;
}
this.update();
scroll(this.loop.bind(this));
}
update(force) {
var top = window.pageYOffset;
if (this.lastTop !== top || force) {
this.callback(top);
this.lastTop = top;
}
}
stop() {
this.active = false;
}
start() {
if (!this.active) {
this.active = true;
this.loop();
}
}
}

View File

@ -0,0 +1,34 @@
function hsvToRgb(h, s, v) {
var r, g, b, i, f, p, q, t;
if (h && s === undefined && v === undefined) {
s = h.s; v = h.v; h = h.h;
}
i = Math.floor(h * 6);
f = h * 6 - i;
p = v * (1 - s);
q = v * (1 - f * s);
t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
}
return {
r: Math.floor(r * 255),
g: Math.floor(g * 255),
b: Math.floor(b * 255)
};
}
export default function stringToColor(string) {
var num = 0;
for (var i = 0; i < string.length; i++) {
num += string.charCodeAt(i);
}
var hue = num % 360;
var rgb = hsvToRgb(hue / 360, 0.4, 0.9);
return ''+rgb.r.toString(16)+rgb.g.toString(16)+rgb.b.toString(16);
};

View File

@ -0,0 +1,33 @@
/**
// constructor
this.subtree = new SubtreeRetainer(
() => this.props.post.freshness,
() => this.showing
);
this.subtree.add(() => this.props.user.freshness);
// view
this.subtree.retain() || 'expensive expression'
*/
export default class SubtreeRetainer {
constructor() {
this.old = [];
this.callbacks = [].slice.call(arguments);
}
retain() {
var needsRebuild = false;
this.callbacks.forEach((callback, i) => {
var result = callback();
if (result !== this.old[i]) {
this.old[i] = result;
needsRebuild = true;
}
});
return needsRebuild ? false : {subtree: 'retain'};
}
add() {
this.callbacks = this.callbacks.concat([].slice.call(arguments));
}
}