diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f87efdf --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/vendor +composer.phar +.DS_Store +Thumbs.db +node_modules +bower_components +.idea/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4105477 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 ReFlar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b254c47 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Gamification by ReFlar + +[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/ReFlar/gamification/blob/master/LICENSE) [![Latest Stable Version](https://img.shields.io/packagist/v/reflar/gamification.svg)](https://github.com/ReFlar/gamification) + +A [Flarum](http://flarum.org) extension that adds upvotes, downvotes, and ranks to your Flarum Community! + +Upvote and downvote posts anonymously, and reward active users with ranks, and sort posts by hotness. + +### Usage + +- Just click upvote or downvote +- Posts can be sorted by "Hotness" + +### Installation + +Install it with composer: + +```bash +composer require reflar/gamification +``` + +Then login and enable the extension. + +You can optionally convert your likes into upvotes, as well as calculate the hotness of all previous discussions. + +### Developer Guide + +You have 2 events to listen for "PostWasUpvoted" as well as "PostWasDownvoted" which both contain the post, post's user, and the upvoter/downvoter. + +### To Do + +- Add ranking page +- Add notifications +- Requests? + +### Issues + +- [Open an issue on Github](https://github.com/ReFlar/gamification/issues) + +### Links + +- [on github](https://github.com/ReFlar/gamification) +- [on packagist](https://packagist.org/packages/ReFlar/gamification) diff --git a/bootstrap.php b/bootstrap.php new file mode 100644 index 0000000..5dd6058 --- /dev/null +++ b/bootstrap.php @@ -0,0 +1,26 @@ +subscribe(Listeners\AddApiAttributes::class); + $events->subscribe(Listeners\AddRelationships::class); + $events->subscribe(Listeners\EventHandlers::class); + $events->subscribe(Listeners\AddClientAssets::class); + $events->subscribe(Listeners\SaveVotesToDatabase::class); + $events->subscribe(Listeners\FilterDiscussionListByHotness::class); +}; \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..148ca3f --- /dev/null +++ b/composer.json @@ -0,0 +1,49 @@ +{ + "name": "reflar/gamification", + "description": "Manage your users with style", + "keywords": [ + "misc", + "settings", + "flarum", + "reflar", + "points", + "gamification" + ], + "type": "flarum-extension", + "license": "MIT", + "authors": [ + { + "name": "Christian Lopez", + "email": "ralkage@reflar.email", + "homepage": "https://reflar.io" + }, + + { + "name": "Charlie K", + "email": "issyrocks12@reflar.email", + "homepage": "https://reflar.io" + } + ], + "support": { + "issues": "https://github.com/ReFlar/gamification/issues", + "source": "https://github.com/ReFlar/gamification" + }, + "require": { + "flarum/core": "^0.1.0-beta.6" + }, + "autoload": { + "psr-4": { + "Reflar\\gamification\\": "src/" + } + }, + "extra": { + "flarum-extension": { + "title": "ReFlar Gamification", + "icon": { + "name": "thumbs-up", + "backgroundColor": "#263238", + "color": "#fff" + } + } + } +} \ No newline at end of file diff --git a/js/admin/Gulpfile.js b/js/admin/Gulpfile.js new file mode 100644 index 0000000..d940a88 --- /dev/null +++ b/js/admin/Gulpfile.js @@ -0,0 +1,9 @@ +var gulp = require('flarum-gulp'); + +gulp({ + modules: { + 'Reflar/gamification': [ + 'src/**/*.js' + ] + } +}); \ No newline at end of file diff --git a/js/admin/dist/extension.js b/js/admin/dist/extension.js new file mode 100644 index 0000000..c735010 --- /dev/null +++ b/js/admin/dist/extension.js @@ -0,0 +1,259 @@ +"use strict"; + +System.register("Reflar/gamification/addSettingsPage", ["flarum/extend", "flarum/components/AdminNav", "flarum/components/AdminLinkButton", "Reflar/gamification/components/SettingsPage"], function (_export, _context) { + "use strict"; + + var extend, AdminNav, AdminLinkButton, SettingsPage; + + _export("default", function () { + app.routes['reflar-gamification'] = { path: '/reflar/gamification', component: SettingsPage.component() }; + + app.extensionSettings['reflar-gamification'] = function () { + return m.route(app.route('reflar-gamification')); + }; + + extend(AdminNav.prototype, 'items', function (items) { + items.add('reflar-gamification', AdminLinkButton.component({ + href: app.route('reflar-gamification'), + icon: 'thumbs-up', + children: 'Gamification', + description: app.translator.trans('reflar-gamification.admin.nav.desc') + })); + }); + }); + + return { + setters: [function (_flarumExtend) { + extend = _flarumExtend.extend; + }, function (_flarumComponentsAdminNav) { + AdminNav = _flarumComponentsAdminNav.default; + }, function (_flarumComponentsAdminLinkButton) { + AdminLinkButton = _flarumComponentsAdminLinkButton.default; + }, function (_ReflarGamificationComponentsSettingsPage) { + SettingsPage = _ReflarGamificationComponentsSettingsPage.default; + }], + execute: function () {} + }; +});; +"use strict"; + +System.register("Reflar/gamification/components/SettingsPage", ["flarum/Component", "flarum/components/Button", "flarum/utils/saveSettings", "flarum/components/Alert"], function (_export, _context) { + "use strict"; + + var Component, Button, saveSettings, Alert, SettingsPage; + return { + setters: [function (_flarumComponent) { + Component = _flarumComponent.default; + }, function (_flarumComponentsButton) { + Button = _flarumComponentsButton.default; + }, function (_flarumUtilsSaveSettings) { + saveSettings = _flarumUtilsSaveSettings.default; + }, function (_flarumComponentsAlert) { + Alert = _flarumComponentsAlert.default; + }], + execute: function () { + SettingsPage = function (_Component) { + babelHelpers.inherits(SettingsPage, _Component); + + function SettingsPage() { + babelHelpers.classCallCheck(this, SettingsPage); + return babelHelpers.possibleConstructorReturn(this, (SettingsPage.__proto__ || Object.getPrototypeOf(SettingsPage)).apply(this, arguments)); + } + + babelHelpers.createClass(SettingsPage, [{ + key: "init", + value: function init() { + var _this2 = this; + + this.loading = false; + + this.fields = ['convertedLikes', 'defaultRank', 'amountPerPost', 'amountPerDiscussion', 'postStartAmount']; + + // fields that are objects + this.objects = ['ranks']; + + this.values = {}; + + this.settingsPrefix = 'reflar.gamification'; + + var settings = app.data.settings; + + this.fields.forEach(function (key) { + return _this2.values[key] = m.prop(settings[_this2.addPrefix(key)]); + }); + + this.objects.forEach(function (key) { + return _this2.values[key] = settings[_this2.addPrefix(key)] ? m.prop(JSON.parse(settings[_this2.addPrefix(key)])) : m.prop(''); + }); + + this.values.ranks() || (this.values.ranks = m.prop({ + '50': 'Helper: #000' + })); + + this.newRank = { + 'points': m.prop(''), + 'name': m.prop('') + }; + } + }, { + key: "view", + value: function view() { + var _this3 = this; + + return [m('div', { className: 'SettingsPage' }, [m('div', { className: 'container' }, [m('form', { onsubmit: this.onsubmit.bind(this) }, [m('div', { className: 'helpText' }, app.translator.trans('reflar-gamification.admin.page.convert.help')), this.values.convertedLikes() === undefined ? Button.component({ + type: 'button', + className: 'Button Button--warning Ranks-button', + children: app.translator.trans('reflar-gamification.admin.page.convert.button'), + onclick: function onclick() { + app.request({ + url: app.forum.attribute('apiUrl') + '/reflar/gamification/convert', + method: 'POST' + }).then(_this3.values.convertedLikes('converting')); + } + }) : this.values.convertedLikes() === 'converting' ? m('label', {}, app.translator.trans('reflar-gamification.admin.page.convert.converting')) : m('label', {}, app.translator.trans('reflar-gamification.admin.page.convert.converted', { number: this.values.convertedLikes() })), m('fieldset', { className: 'SettingsPage-ranks' }, [m('legend', {}, app.translator.trans('reflar-gamification.admin.page.ranks.title')), m('label', {}, app.translator.trans('reflar-gamification.admin.page.ranks.ranks')), m('div', { className: 'Ranks--Container' }, Object.keys(this.values.ranks()).map(function (rank) { + return m('div', {}, [m('input', { + className: 'FormControl Ranks-number', + type: 'number', + value: rank, + oninput: m.withAttr('value', _this3.updateRankPoints.bind(_this3, rank)) + }), m('input', { + className: 'FormControl Ranks-name', + value: _this3.values.ranks()[rank], + oninput: m.withAttr('value', _this3.updateRankName.bind(_this3, rank)) + }), Button.component({ + type: 'button', + className: 'Button Button--warning Ranks-button', + icon: 'times', + onclick: _this3.deleteRank.bind(_this3, rank) + })]); + }), m('br'), m('div', {}, [m('input', { + className: 'FormControl Ranks-number', + value: this.newRank.points(), + type: 'number', + oninput: m.withAttr('value', this.newRank.points) + }), m('input', { + className: 'FormControl Ranks-name', + value: this.newRank.name(), + oninput: m.withAttr('value', this.newRank.name) + }), Button.component({ + type: 'button', + className: 'Button Button--warning Ranks-button', + icon: 'plus', + onclick: this.addRank.bind(this) + })])), m('div', { className: 'helpText' }, app.translator.trans('reflar-gamification.admin.page.ranks.help')), m('label', {}, app.translator.trans('reflar-gamification.admin.page.ranks.default')), m('input', { + className: 'FormControl Ranks-default', + value: this.values.defaultRank() || '', + placeholder: 'Newbie', + oninput: m.withAttr('value', this.values.defaultRank) + })]), m('div', { className: 'helpText' }, app.translator.trans('reflar-gamification.admin.page.ranks.default_help')), Button.component({ + type: 'submit', + className: 'Button Button--primary Ranks-save', + children: app.translator.trans('reflar-gamification.admin.page.save_settings'), + loading: this.loading, + disabled: !this.changed() + })])])])]; + } + }, { + key: "updateRankPoints", + value: function updateRankPoints(rank, value) { + this.values.ranks()[value] = this.values.ranks()[rank]; + + this.deleteRank(rank); + } + }, { + key: "updateRankName", + value: function updateRankName(rank, value) { + this.values.ranks()[rank] = value; + } + }, { + key: "deleteRank", + value: function deleteRank(rank) { + delete this.values.ranks()[rank]; + } + }, { + key: "addRank", + value: function addRank() { + this.values.ranks()[this.newRank.points()] = this.newRank.name(); + + this.newRank.points(''); + this.newRank.name(''); + } + }, { + key: "changed", + value: function changed() { + var _this4 = this; + + var fieldsCheck = this.fields.some(function (key) { + return _this4.values[key]() !== app.data.settings[_this4.addPrefix(key)]; + }); + var objectsCheck = this.objects.some(function (key) { + return JSON.stringify(_this4.values[key]()) !== app.data.settings[_this4.addPrefix(key)]; + }); + return fieldsCheck || objectsCheck; + } + }, { + key: "onsubmit", + value: function onsubmit(e) { + var _this5 = this; + + e.preventDefault(); + + if (this.loading) return; + + this.loading = true; + + app.alerts.dismiss(this.successAlert); + + var settings = {}; + + this.fields.forEach(function (key) { + return settings[_this5.addPrefix(key)] = _this5.values[key](); + }); + this.objects.forEach(function (key) { + return settings[_this5.addPrefix(key)] = JSON.stringify(_this5.values[key]()); + }); + + saveSettings(settings).then(function () { + app.alerts.show(_this5.successAlert = new Alert({ + type: 'success', + children: app.translator.trans('core.admin.basics.saved_message') + })); + }).catch(function () {}).then(function () { + _this5.loading = false; + window.location.reload(); + }); + } + }, { + key: "addPrefix", + value: function addPrefix(key) { + return this.settingsPrefix + '.' + key; + } + }]); + return SettingsPage; + }(Component); + + _export("default", SettingsPage); + } + }; +});; +'use strict'; + +System.register('Reflar/gamification/main', ['flarum/app', 'Reflar/gamification/addSettingsPage'], function (_export, _context) { + "use strict"; + + var app, addSettingsPage; + return { + setters: [function (_flarumApp) { + app = _flarumApp.default; + }, function (_ReflarGamificationAddSettingsPage) { + addSettingsPage = _ReflarGamificationAddSettingsPage.default; + }], + execute: function () { + + app.initializers.add('reflar-gamification', function () { + + addSettingsPage(); + }); + } + }; +}); \ No newline at end of file diff --git a/js/admin/package.json b/js/admin/package.json new file mode 100644 index 0000000..f4c413b --- /dev/null +++ b/js/admin/package.json @@ -0,0 +1,7 @@ +{ + "private": true, + "devDependencies": { + "gulp": "^3.9.1", + "flarum-gulp": "^0.2.0" + } +} \ No newline at end of file diff --git a/js/admin/src/addSettingsPage.js b/js/admin/src/addSettingsPage.js new file mode 100644 index 0000000..1c1a2cf --- /dev/null +++ b/js/admin/src/addSettingsPage.js @@ -0,0 +1,19 @@ +import {extend} from "flarum/extend"; +import AdminNav from "flarum/components/AdminNav"; +import AdminLinkButton from "flarum/components/AdminLinkButton"; +import SettingsPage from "Reflar/gamification/components/SettingsPage"; + +export default function () { + app.routes['reflar-gamification'] = {path: '/reflar/gamification', component: SettingsPage.component()}; + + app.extensionSettings['reflar-gamification'] = () => m.route(app.route('reflar-gamification')); + + extend(AdminNav.prototype, 'items', items => { + items.add('reflar-gamification', AdminLinkButton.component({ + href: app.route('reflar-gamification'), + icon: 'thumbs-up', + children: 'Gamification', + description: app.translator.trans('reflar-gamification.admin.nav.desc') + })); + }); +} diff --git a/js/admin/src/components/SettingsPage.js b/js/admin/src/components/SettingsPage.js new file mode 100644 index 0000000..71e2b9d --- /dev/null +++ b/js/admin/src/components/SettingsPage.js @@ -0,0 +1,217 @@ +import Component from "flarum/Component"; +import Button from "flarum/components/Button"; +import saveSettings from "flarum/utils/saveSettings"; +import Alert from "flarum/components/Alert"; + +export default class SettingsPage extends Component { + + init() { + this.loading = false; + + this.fields = [ + 'convertedLikes', + 'defaultRank', + 'amountPerPost', + 'amountPerDiscussion', + 'postStartAmount' + ]; + + + // fields that are objects + this.objects = [ + 'ranks' + ]; + + + this.values = {}; + + this.settingsPrefix = 'reflar.gamification'; + + const settings = app.data.settings; + + this.fields.forEach(key => + this.values[key] = m.prop(settings[this.addPrefix(key)]) + ); + + this.objects.forEach(key => + this.values[key] = settings[this.addPrefix(key)] ? m.prop(JSON.parse(settings[this.addPrefix(key)])) : m.prop('') + ); + + + this.values.ranks() || (this.values.ranks = m.prop({ + '50': 'Helper: #000' + })); + + this.newRank = { + 'points': m.prop(''), + 'name': m.prop('') + }; + } + + + /** + * @returns {*} + */ + view() { + return [ + m('div', {className: 'SettingsPage'}, [ + m('div', {className: 'container'}, [ + m('form', {onsubmit: this.onsubmit.bind(this)}, [ + m('div', {className: 'helpText'}, app.translator.trans('reflar-gamification.admin.page.convert.help')), + (this.values.convertedLikes() === undefined ? ( + Button.component({ + type: 'button', + className: 'Button Button--warning Ranks-button', + children: app.translator.trans('reflar-gamification.admin.page.convert.button'), + onclick: () => { + app.request({ + url: app.forum.attribute('apiUrl') + '/reflar/gamification/convert', + method: 'POST' + }).then(this.values.convertedLikes('converting')); + } + }) + ) : (this.values.convertedLikes() === 'converting' ? ( + m('label', {}, app.translator.trans('reflar-gamification.admin.page.convert.converting')) + ) : (m('label', {}, app.translator.trans('reflar-gamification.admin.page.convert.converted', {number: this.values.convertedLikes()}))))), + + m('fieldset', {className: 'SettingsPage-ranks'}, [ + m('legend', {}, app.translator.trans('reflar-gamification.admin.page.ranks.title')), + m('label', {}, app.translator.trans('reflar-gamification.admin.page.ranks.ranks')), + m('div', {className: 'Ranks--Container'}, + Object.keys(this.values.ranks()).map(rank => { + return m('div', {}, [ + m('input', { + className: 'FormControl Ranks-number', + type: 'number', + value: rank, + oninput: m.withAttr('value', this.updateRankPoints.bind(this, rank)) + }), + m('input', { + className: 'FormControl Ranks-name', + value: this.values.ranks()[rank], + oninput: m.withAttr('value', this.updateRankName.bind(this, rank)) + }), + Button.component({ + type: 'button', + className: 'Button Button--warning Ranks-button', + icon: 'times', + onclick: this.deleteRank.bind(this, rank) + }), + ]) + }), + m('br'), + m('div', {}, [ + m('input', { + className: 'FormControl Ranks-number', + value: this.newRank.points(), + type: 'number', + oninput: m.withAttr('value', this.newRank.points) + }), + m('input', { + className: 'FormControl Ranks-name', + value: this.newRank.name(), + oninput: m.withAttr('value', this.newRank.name) + }), + Button.component({ + type: 'button', + className: 'Button Button--warning Ranks-button', + icon: 'plus', + onclick: this.addRank.bind(this) + }), + ]) + ), + m('div', {className: 'helpText'}, app.translator.trans('reflar-gamification.admin.page.ranks.help')), + m('label', {}, app.translator.trans('reflar-gamification.admin.page.ranks.default')), + m('input', { + className: 'FormControl Ranks-default', + value: this.values.defaultRank() || '', + placeholder: 'Newbie', + oninput: m.withAttr('value', this.values.defaultRank) + }), + ]), + m('div', {className: 'helpText'}, app.translator.trans('reflar-gamification.admin.page.ranks.default_help')), + Button.component({ + type: 'submit', + className: 'Button Button--primary Ranks-save', + children: app.translator.trans('reflar-gamification.admin.page.save_settings'), + loading: this.loading, + disabled: !this.changed() + }) + ]) + ]) + ]) + ]; + } + + + updateRankPoints(rank, value) { + this.values.ranks()[value] = this.values.ranks()[rank]; + + this.deleteRank(rank); + } + + updateRankName(rank, value) { + this.values.ranks()[rank] = value; + } + + deleteRank(rank) { + delete this.values.ranks()[rank]; + } + + addRank() { + this.values.ranks()[this.newRank.points()] = this.newRank.name(); + + this.newRank.points(''); + this.newRank.name(''); + } + + + /** + * + * @returns boolean + */ + changed() { + var fieldsCheck = this.fields.some(key => this.values[key]() !== app.data.settings[this.addPrefix(key)]); + var objectsCheck = this.objects.some(key => JSON.stringify(this.values[key]()) !== (app.data.settings[this.addPrefix(key)])); + return fieldsCheck || objectsCheck; + } + + /** + * @param e + */ + onsubmit(e) { + e.preventDefault(); + + if (this.loading) return; + + this.loading = true; + + app.alerts.dismiss(this.successAlert); + + const settings = {}; + + this.fields.forEach(key => settings[this.addPrefix(key)] = this.values[key]()); + this.objects.forEach(key => settings[this.addPrefix(key)] = JSON.stringify(this.values[key]())); + + saveSettings(settings) + .then(() => { + app.alerts.show(this.successAlert = new Alert({ + type: 'success', + children: app.translator.trans('core.admin.basics.saved_message') + })); + }) + .catch(() => { + }) + .then(() => { + this.loading = false; + window.location.reload(); + }); + } + + /** + * @returns string + */ + addPrefix(key) { + return this.settingsPrefix + '.' + key; + } +} diff --git a/js/admin/src/main.js b/js/admin/src/main.js new file mode 100644 index 0000000..651eb6a --- /dev/null +++ b/js/admin/src/main.js @@ -0,0 +1,9 @@ +import app from 'flarum/app'; + +import addSettingsPage from 'Reflar/gamification/addSettingsPage'; + +app.initializers.add('reflar-gamification', () => { + + addSettingsPage(); + +}); diff --git a/js/forum/Gulpfile.js b/js/forum/Gulpfile.js new file mode 100644 index 0000000..d940a88 --- /dev/null +++ b/js/forum/Gulpfile.js @@ -0,0 +1,9 @@ +var gulp = require('flarum-gulp'); + +gulp({ + modules: { + 'Reflar/gamification': [ + 'src/**/*.js' + ] + } +}); \ No newline at end of file diff --git a/js/forum/dist/extension.js b/js/forum/dist/extension.js new file mode 100644 index 0000000..7c842fc --- /dev/null +++ b/js/forum/dist/extension.js @@ -0,0 +1,602 @@ +'use strict'; + +System.register('Reflar/gamification/components/AddAttributes', ['flarum/helpers/avatar', 'flarum/helpers/username', 'flarum/extend', 'flarum/Model', 'flarum/models/Post', 'flarum/components/PostUser', 'flarum/models/User', 'flarum/components/UserCard', 'flarum/helpers/userOnline', 'flarum/helpers/listItems'], function (_export, _context) { + "use strict"; + + var avatar, username, extend, Model, Post, PostUser, User, UserCard, userOnline, listItems; + + _export('default', function () { + User.prototype.points = Model.attribute('points'); + User.prototype.Rank = Model.attribute('Rank'); + + Post.prototype.upvotes = Model.hasMany('upvotes'); + Post.prototype.downvotes = Model.hasMany('downvotes'); + + extend(UserCard.prototype, 'infoItems', function (items, user) { + var rank = this.props.user.data.attributes.Rank.split(': '); + if (rank[0] == '') { + rank[0] = app.forum.attribute('DefaultRank'); + } + items.add('points', app.translator.trans('reflar-gamification.forum.user.points', { points: this.props.user.data.attributes.Points })); + + items.add('rank', app.translator.trans('reflar-gamification.forum.user.rank', { rank: rank[0] })); + }); + + PostUser.prototype.view = function () { + var post = this.props.post; + var user = post.user(); + + var rank = user.Rank().split(': '); + + if (rank[0] == '') { + rank[0] = app.forum.attribute('DefaultRank'); + } + + if (!user) { + return m( + 'div', + { className: 'PostUser' }, + m( + 'h3', + null, + avatar(user, { className: 'PostUser-avatar' }), + ' ', + username(user), + ' ', + rank[0] + ) + ); + } + + var card = ''; + + if (!post.isHidden() && this.cardVisible) { + card = UserCard.component({ + user: user, + className: 'UserCard--popover', + controlsButtonClassName: 'Button Button--icon Button--flat' + }); + } + + return m( + 'div', + { className: 'PostUser' }, + userOnline(user), + m( + 'h3', + null, + m( + 'a', + { href: app.route.user(user), config: m.route }, + avatar(user, { className: 'PostUser-avatar' }), + ' ', + username(user) + ), + m( + 'span', + { className: 'Post-Rank', style: "color: " + rank[1] }, + rank[0] + ) + ), + m( + 'ul', + { className: 'PostUser-badges badges' }, + listItems(user.badges().toArray()) + ), + card + ); + }; + }); + + return { + setters: [function (_flarumHelpersAvatar) { + avatar = _flarumHelpersAvatar.default; + }, function (_flarumHelpersUsername) { + username = _flarumHelpersUsername.default; + }, function (_flarumExtend) { + extend = _flarumExtend.extend; + }, function (_flarumModel) { + Model = _flarumModel.default; + }, function (_flarumModelsPost) { + Post = _flarumModelsPost.default; + }, function (_flarumComponentsPostUser) { + PostUser = _flarumComponentsPostUser.default; + }, function (_flarumModelsUser) { + User = _flarumModelsUser.default; + }, function (_flarumComponentsUserCard) { + UserCard = _flarumComponentsUserCard.default; + }, function (_flarumHelpersUserOnline) { + userOnline = _flarumHelpersUserOnline.default; + }, function (_flarumHelpersListItems) { + listItems = _flarumHelpersListItems.default; + }], + execute: function () {} + }; +});; +'use strict'; + +System.register('Reflar/gamification/components/AddHotnessSort', ['flarum/extend', 'flarum/components/IndexPage', 'flarum/utils/ItemList', 'flarum/components/DiscussionList', 'flarum/components/Select'], function (_export, _context) { + "use strict"; + + var extend, IndexPage, ItemList, DiscussionList, Select; + + _export('default', function () { + IndexPage.prototype.viewItems = function () { + var items = new ItemList(); + var sortMap = app.cache.discussionList.sortMap(); + + var sortOptions = {}; + for (var i in sortMap) { + sortOptions[i] = app.translator.trans('core.forum.index_sort.' + i + '_button'); + } + + var sort = this.params().sort; + + if (this.props.routeName == 'index.filter') { + sort = 'hot'; + } + + items.add('sort', Select.component({ + options: sortOptions, + value: sort || Object.keys(sortMap)[0], + onchange: this.changeSort.bind(this) + })); + + return items; + }; + + IndexPage.prototype.changeSort = function (sort) { + var params = this.params(); + + if (sort === 'hot') { + m.route('/hot'); + } else { + if (sort === Object.keys(app.cache.discussionList.sortMap())[0]) { + delete params.sort; + } else { + params.sort = sort; + } + if (params.filter = 'hot') { + delete params.filter; + } + m.route(app.route('index', params)); + } + }; + + extend(DiscussionList.prototype, 'sortMap', function (map) { + map.hot = 'hot'; + }); + + extend(DiscussionList.prototype, 'requestParams', function (params) { + if (this.props.params.filter === 'hot') { + params.filter.q = ' is:hot'; + } + }); + }); + + return { + setters: [function (_flarumExtend) { + extend = _flarumExtend.extend; + }, function (_flarumComponentsIndexPage) { + IndexPage = _flarumComponentsIndexPage.default; + }, function (_flarumUtilsItemList) { + ItemList = _flarumUtilsItemList.default; + }, function (_flarumComponentsDiscussionList) { + DiscussionList = _flarumComponentsDiscussionList.default; + }, function (_flarumComponentsSelect) { + Select = _flarumComponentsSelect.default; + }], + execute: function () {} + }; +});; +'use strict'; + +System.register('Reflar/gamification/components/AddVoteButtons', ['flarum/extend', 'flarum/app', 'flarum/components/Button', 'flarum/components/CommentPost'], function (_export, _context) { + "use strict"; + + var extend, app, Button, CommentPost; + + _export('default', function () { + extend(CommentPost.prototype, 'actionItems', function (items) { + var post = this.props.post; + + if (post.isHidden()) return; + + var isUpvoted = app.session.user && post.upvotes().some(function (user) { + return user === app.session.user; + }); + var isDownvoted = app.session.user && post.downvotes().some(function (user) { + return user === app.session.user; + }); + + items.add('upvote', Button.component({ + icon: 'thumbs-up', + className: 'Post-vote Post-upvote', + style: isUpvoted !== false ? 'color:' + app.forum.attribute('themePrimaryColor') : 'color:', + onclick: function onclick() { + var upData = post.data.relationships.upvotes.data; + var downData = post.data.relationships.downvotes.data; + + isUpvoted = !isUpvoted; + + isDownvoted = false; + + post.save({ isUpvoted: isUpvoted, isDownvoted: isDownvoted }); + + upData.some(function (upvote, i) { + if (upvote.id === app.session.user.id()) { + upData.splice(i, 1); + return true; + } + }); + + downData.some(function (downvote, i) { + if (downvote.id === app.session.user.id()) { + downData.splice(i, 1); + return true; + } + }); + + if (isUpvoted) { + upData.unshift({ type: 'users', id: app.session.user.id() }); + } + } + })); + + items.add('points', m( + 'div', + { className: 'Post-points' }, + post.data.relationships.upvotes.data.length - post.data.relationships.downvotes.data.length + )); + + items.add('downvote', Button.component({ + icon: 'thumbs-down', + className: 'Post-vote Post-downvote', + style: isDownvoted !== false ? 'color:' + app.forum.attribute('themePrimaryColor') : '', + onclick: function onclick() { + var upData = post.data.relationships.upvotes.data; + var downData = post.data.relationships.downvotes.data; + + isDownvoted = !isDownvoted; + + isUpvoted = false; + + post.save({ isUpvoted: isUpvoted, isDownvoted: isDownvoted }); + + upData.some(function (upvote, i) { + if (upvote.id === app.session.user.id()) { + upData.splice(i, 1); + return true; + } + }); + + downData.some(function (downvote, i) { + if (downvote.id === app.session.user.id()) { + downData.splice(i, 1); + return true; + } + }); + + if (isDownvoted) { + downData.unshift({ type: 'users', id: app.session.user.id() }); + } + } + })); + }); + }); + + return { + setters: [function (_flarumExtend) { + extend = _flarumExtend.extend; + }, function (_flarumApp) { + app = _flarumApp.default; + }, function (_flarumComponentsButton) { + Button = _flarumComponentsButton.default; + }, function (_flarumComponentsCommentPost) { + CommentPost = _flarumComponentsCommentPost.default; + }], + execute: function () {} + }; +});; +'use strict'; + +System.register('Reflar/gamification/components/RankingsPage', ['flarum/helpers/avatar', 'flarum/Component', 'flarum/components/IndexPage', 'flarum/helpers/listItems', 'flarum/helpers/icon', 'flarum/helpers/username', 'flarum/components/UserCard'], function (_export, _context) { + "use strict"; + + var avatar, Component, IndexPage, listItems, icon, username, UserCard, RankingsPage; + return { + setters: [function (_flarumHelpersAvatar) { + avatar = _flarumHelpersAvatar.default; + }, function (_flarumComponent) { + Component = _flarumComponent.default; + }, function (_flarumComponentsIndexPage) { + IndexPage = _flarumComponentsIndexPage.default; + }, function (_flarumHelpersListItems) { + listItems = _flarumHelpersListItems.default; + }, function (_flarumHelpersIcon) { + icon = _flarumHelpersIcon.default; + }, function (_flarumHelpersUsername) { + username = _flarumHelpersUsername.default; + }, function (_flarumComponentsUserCard) { + UserCard = _flarumComponentsUserCard.default; + }], + execute: function () { + RankingsPage = function (_Component) { + babelHelpers.inherits(RankingsPage, _Component); + + function RankingsPage() { + babelHelpers.classCallCheck(this, RankingsPage); + return babelHelpers.possibleConstructorReturn(this, (RankingsPage.__proto__ || Object.getPrototypeOf(RankingsPage)).apply(this, arguments)); + } + + babelHelpers.createClass(RankingsPage, [{ + key: 'init', + value: function init() { + var _this2 = this; + + app.current = this; + this.cardVisible = false; + + app.request({ + method: 'GET', + url: app.forum.attribute('apiUrl') + '/rankings' + }).then(function (response) { + _this2.data = response.data; + _this2.users = []; + for (i = 0; i < _this2.data.length; i++) { + _this2.users[i] = []; + _this2.users[i]['user'] = _this2.findRecipient(_this2.data[i].id); + _this2.users[i]['class'] = i + 1; + } + console.log(_this2.users); + console.log(_this2.users[1]); + _this2.loading = false; + m.redraw(); + }); + } + }, { + key: 'view', + value: function view() { + return m( + 'div', + { className: 'RankingPage' }, + IndexPage.prototype.hero(), + m( + 'div', + { className: 'container' }, + m( + 'nav', + { className: 'IndexPage-nav sideNav', config: IndexPage.prototype.affixSidebar }, + m( + 'ul', + null, + listItems(IndexPage.prototype.sidebarItems().toArray()) + ) + ), + m( + 'div', + { className: 'sideNavOffset' }, + m( + 'table', + { 'class': 'rankings' }, + m( + 'tr', + null, + m( + 'th', + null, + app.translator.trans('reflar-gamification.forum.ranking.rank') + ), + m( + 'th', + null, + app.translator.trans('reflar-gamification.forum.ranking.name') + ), + m( + 'th', + null, + app.translator.trans('reflar-gamification.forum.ranking.amount') + ) + ), + this.users.map(function (user) { + + user['user'].then(function (user) { + + var card = ''; + + return [m( + 'tr', + null, + m( + 'td', + { 'class': "rankings-" + user['class'] }, + icon("trophy") + ), + m( + 'td', + null, + m( + 'div', + { className: 'PostUser' }, + m( + 'h3', + { className: 'rankings-info' }, + m( + 'a', + { href: app.route.user(user), config: m.route }, + avatar(user, { className: 'info-avatar rankings-' + user + '-avatar' }) + ) + ), + card + ) + ), + m( + 'td', + null, + user.data.attributes['antoinefr-money.money'] + ) + )]; + }); + }) + ) + ) + ) + ); + } + }, { + key: 'findRecipient', + value: function findRecipient(id) { + return app.store.find('users', id); + } + }, { + key: 'config', + value: function config(isInitialized) { + var _this3 = this; + + if (isInitialized) return; + + var timeout = void 0; + + this.$().on('mouseover', 'h3 a, .UserCard', function () { + clearTimeout(timeout); + timeout = setTimeout(_this3.showCard.bind(_this3), 500); + }).on('mouseout', 'h3 a, .UserCard', function () { + clearTimeout(timeout); + timeout = setTimeout(_this3.hideCard.bind(_this3), 250); + }); + } + }, { + key: 'showCard', + value: function showCard() { + var _this4 = this; + + this.cardVisible = true; + + m.redraw(); + + setTimeout(function () { + return _this4.$('.UserCard').addClass('in'); + }); + } + }, { + key: 'hideCard', + value: function hideCard() { + var _this5 = this; + + this.$('.UserCard').removeClass('in').one('transitionend webkitTransitionEnd oTransitionEnd', function () { + _this5.cardVisible = false; + m.redraw(); + }); + } + }]); + return RankingsPage; + }(Component); + + _export('default', RankingsPage); + } + }; +});; +'use strict'; + +System.register('Reflar/gamification/components/UserPromotedNotification', ['flarum/components/Notification', 'flarum/helpers/username', 'flarum/helpers/punctuateSeries'], function (_export, _context) { + "use strict"; + + var Notification, username, punctuateSeries, UserPromotedNotification; + return { + setters: [function (_flarumComponentsNotification) { + Notification = _flarumComponentsNotification.default; + }, function (_flarumHelpersUsername) { + username = _flarumHelpersUsername.default; + }, function (_flarumHelpersPunctuateSeries) { + punctuateSeries = _flarumHelpersPunctuateSeries.default; + }], + execute: function () { + UserPromotedNotification = function (_Notification) { + babelHelpers.inherits(UserPromotedNotification, _Notification); + + function UserPromotedNotification() { + babelHelpers.classCallCheck(this, UserPromotedNotification); + return babelHelpers.possibleConstructorReturn(this, (UserPromotedNotification.__proto__ || Object.getPrototypeOf(UserPromotedNotification)).apply(this, arguments)); + } + + babelHelpers.createClass(UserPromotedNotification, [{ + key: 'icon', + value: function icon() { + return 'thumbs-o-up'; + } + }, { + key: 'href', + value: function href() { + return app.route.post(this.props.notification.subject()); + } + }, { + key: 'content', + value: function content() { + var notification = this.props.notification; + var user = notification.sender(); + var auc = notification.additionalUnreadCount(); + + return app.translator.transChoice('flarum-likes.forum.notifications.post_liked_text', auc + 1, { + user: user, + username: auc ? punctuateSeries([username(user), app.translator.transChoice('flarum-likes.forum.notifications.others_text', auc, { count: auc })]) : undefined + }); + } + }, { + key: 'excerpt', + value: function excerpt() { + return this.props.notification.subject().contentPlain(); + } + }]); + return UserPromotedNotification; + }(Notification); + + _export('default', UserPromotedNotification); + } + }; +});; +'use strict'; + +System.register('Reflar/gamification/main', ['flarum/extend', 'flarum/app', 'Reflar/gamification/components/AddAttributes', 'Reflar/gamification/components/AddHotnessSort', 'Reflar/gamification/components/AddVoteButtons'], function (_export, _context) { + "use strict"; + + var extend, app, AddAttributes, AddHotnessFilter, AddVoteButtons; + return { + setters: [function (_flarumExtend) { + extend = _flarumExtend.extend; + }, function (_flarumApp) { + app = _flarumApp.default; + }, function (_ReflarGamificationComponentsAddAttributes) { + AddAttributes = _ReflarGamificationComponentsAddAttributes.default; + }, function (_ReflarGamificationComponentsAddHotnessSort) { + AddHotnessFilter = _ReflarGamificationComponentsAddHotnessSort.default; + }, function (_ReflarGamificationComponentsAddVoteButtons) { + AddVoteButtons = _ReflarGamificationComponentsAddVoteButtons.default; + }], + execute: function () { + // import UserPromotedNotification from 'Reflar/gamification/components/UserPromotedNotification'; + // import RankingsPage from 'Reflar/gamification/components/RankingsPage'; + + + app.initializers.add('Reflar-gamification', function () { + + // app.notificationComponents.userPromoted = UserPromotedNotification; + + // app.routes.page = {path: '/rankings', component: RankingsPage.component()}; + + AddVoteButtons(); + AddHotnessFilter(); + AddAttributes(); + + /** + extend(NotificationGrid.prototype, 'notificationTypes', function (items) { + items.add('userPromoted', { + name: 'userPromoted', + icon: 'arrow-up', + label: ['hi'] + }); + });*/ + }); + // import NotificationGrid from 'flarum/components/NotificationGrid'; + } + }; +}); \ No newline at end of file diff --git a/js/forum/package.json b/js/forum/package.json new file mode 100644 index 0000000..f4c413b --- /dev/null +++ b/js/forum/package.json @@ -0,0 +1,7 @@ +{ + "private": true, + "devDependencies": { + "gulp": "^3.9.1", + "flarum-gulp": "^0.2.0" + } +} \ No newline at end of file diff --git a/js/forum/src/components/AddAttributes.js b/js/forum/src/components/AddAttributes.js new file mode 100644 index 0000000..39f3151 --- /dev/null +++ b/js/forum/src/components/AddAttributes.js @@ -0,0 +1,79 @@ +import avatar from 'flarum/helpers/avatar'; +import username from 'flarum/helpers/username'; +import { extend } from 'flarum/extend'; +import Model from 'flarum/Model'; +import Post from 'flarum/models/Post'; +import PostUser from 'flarum/components/PostUser'; +import User from 'flarum/models/User'; +import UserCard from 'flarum/components/UserCard'; +import userOnline from 'flarum/helpers/userOnline'; +import listItems from 'flarum/helpers/listItems'; + +export default function () { + User.prototype.points = Model.attribute('points'); + User.prototype.Rank = Model.attribute('Rank'); + + Post.prototype.upvotes = Model.hasMany('upvotes'); + Post.prototype.downvotes = Model.hasMany('downvotes'); + + extend(UserCard.prototype, 'infoItems', function (items, user) { + let rank = this.props.user.data.attributes.Rank.split(': '); + if (rank[0] == '') { + rank[0] = app.forum.attribute('DefaultRank'); + } + items.add('points', + app.translator.trans('reflar-gamification.forum.user.points', {points: this.props.user.data.attributes.Points}) + ); + + items.add('rank', + app.translator.trans('reflar-gamification.forum.user.rank', {rank: rank[0]}) + ); + }); + + PostUser.prototype.view = function () { + const post = this.props.post; + const user = post.user(); + + const rank = user.Rank().split(': '); + + if (rank[0] == '') { + rank[0] = app.forum.attribute('DefaultRank'); + } + + if (!user) { + return ( +
+

{avatar(user, {className: 'PostUser-avatar'})} {username(user)} {rank[0]}

+
+ ); + } + + let card = ''; + + if (!post.isHidden() && this.cardVisible) { + card = UserCard.component({ + user, + className: 'UserCard--popover', + controlsButtonClassName: 'Button Button--icon Button--flat' + }); + } + + return ( +
+ {userOnline(user)} +

+ + {avatar(user, {className: 'PostUser-avatar'})}{' '}{username(user)} + + + {rank[0]} + +

+ + {card} +
+ ); + } +} \ No newline at end of file diff --git a/js/forum/src/components/AddHotnessSort.js b/js/forum/src/components/AddHotnessSort.js new file mode 100644 index 0000000..cdb5cc5 --- /dev/null +++ b/js/forum/src/components/AddHotnessSort.js @@ -0,0 +1,61 @@ +import {extend} from 'flarum/extend'; +import IndexPage from 'flarum/components/IndexPage'; +import ItemList from 'flarum/utils/ItemList'; +import DiscussionList from 'flarum/components/DiscussionList'; +import Select from 'flarum/components/Select'; + +export default function () { + IndexPage.prototype.viewItems = function () { + const items = new ItemList(); + const sortMap = app.cache.discussionList.sortMap(); + + const sortOptions = {}; + for (const i in sortMap) { + sortOptions[i] = app.translator.trans('core.forum.index_sort.' + i + '_button'); + } + + let sort = this.params().sort; + + if (this.props.routeName == 'index.filter') { + sort = 'hot' + } + + items.add('sort', + Select.component({ + options: sortOptions, + value: sort || Object.keys(sortMap)[0], + onchange: this.changeSort.bind(this) + }) + ); + + return items; + }; + + IndexPage.prototype.changeSort = function (sort) { + const params = this.params(); + + if (sort === 'hot') { + m.route('/hot'); + } else { + if (sort === Object.keys(app.cache.discussionList.sortMap())[0]) { + delete params.sort; + } else { + params.sort = sort; + } + if (params.filter = 'hot') { + delete params.filter; + } + m.route(app.route('index', params)); + } + }; + + extend(DiscussionList.prototype, 'sortMap', function (map) { + map.hot = 'hot'; + }); + + extend(DiscussionList.prototype, 'requestParams', function(params) { + if (this.props.params.filter === 'hot') { + params.filter.q = ' is:hot'; + } + }); +} \ No newline at end of file diff --git a/js/forum/src/components/AddVoteButtons.js b/js/forum/src/components/AddVoteButtons.js new file mode 100644 index 0000000..dc94a8f --- /dev/null +++ b/js/forum/src/components/AddVoteButtons.js @@ -0,0 +1,93 @@ +import {extend} from 'flarum/extend'; +import app from 'flarum/app'; +import Button from 'flarum/components/Button'; +import CommentPost from 'flarum/components/CommentPost'; + +export default function () { + extend(CommentPost.prototype, 'actionItems', function(items) { + const post = this.props.post; + + if (post.isHidden()) return; + + let isUpvoted = app.session.user && post.upvotes().some(user => user === app.session.user); + let isDownvoted = app.session.user && post.downvotes().some(user => user === app.session.user); + + items.add('upvote', + Button.component({ + icon: 'thumbs-up', + className: 'Post-vote Post-upvote', + style: isUpvoted !== false ? 'color:' + app.forum.attribute('themePrimaryColor') : 'color:', + onclick: () => { + var upData = post.data.relationships.upvotes.data; + var downData = post.data.relationships.downvotes.data; + + isUpvoted = !isUpvoted; + + isDownvoted = false; + + post.save({isUpvoted, isDownvoted}); + + upData.some((upvote, i) => { + if (upvote.id === app.session.user.id()) { + upData.splice(i, 1); + return true; + } + }); + + downData.some((downvote, i) => { + if (downvote.id === app.session.user.id()) { + downData.splice(i, 1); + return true; + } + }); + + if (isUpvoted) { + upData.unshift({type: 'users', id: app.session.user.id()}); + } + } + }) + ); + + items.add('points', ( +
+ {post.data.relationships.upvotes.data.length - post.data.relationships.downvotes.data.length} +
+ )); + + items.add('downvote', + Button.component({ + icon: 'thumbs-down', + className: 'Post-vote Post-downvote', + style: isDownvoted !== false ? 'color:' + app.forum.attribute('themePrimaryColor') : '', + onclick: () => { + var upData = post.data.relationships.upvotes.data; + var downData = post.data.relationships.downvotes.data + + isDownvoted = !isDownvoted; + + isUpvoted = false; + + post.save({isUpvoted, isDownvoted}); + + upData.some((upvote, i) => { + if (upvote.id === app.session.user.id()) { + upData.splice(i, 1); + return true; + } + }); + + downData.some((downvote, i) => { + if (downvote.id === app.session.user.id()) { + downData.splice(i, 1); + return true; + } + }); + + if (isDownvoted) { + downData.unshift({type: 'users', id: app.session.user.id()}); + } + } + }) + ); + }); +} diff --git a/js/forum/src/components/RankingsPage.js b/js/forum/src/components/RankingsPage.js new file mode 100644 index 0000000..491f1e1 --- /dev/null +++ b/js/forum/src/components/RankingsPage.js @@ -0,0 +1,116 @@ +import avatar from 'flarum/helpers/avatar'; +import Component from 'flarum/Component'; +import IndexPage from 'flarum/components/IndexPage'; +import listItems from 'flarum/helpers/listItems'; +import icon from 'flarum/helpers/icon'; +import username from 'flarum/helpers/username'; +import UserCard from 'flarum/components/UserCard'; + +export default class RankingsPage extends Component { + init() { + app.current = this; + this.cardVisible = false; + + app.request({ + method: 'GET', + url: app.forum.attribute('apiUrl') + '/rankings' + }).then( + response => { + this.data = response.data; + this.users = []; + for (i = 0; i < this.data.length; i++) { + this.users[i] = []; + this.users[i]['user'] = this.findRecipient(this.data[i].id); + this.users[i]['class'] = i+1; + } + console.log(this.users); + console.log(this.users[1]); + this.loading = false; + m.redraw(); + } + ) + } + + view() { + return ( +
+ {IndexPage.prototype.hero()} +
+ + +
+ + + + + + + {this.users.map((user) => { + + user['user'].then(function(user) { + + let card = ''; + + return [ + + + + + + ] + }) + })} +
{app.translator.trans('reflar-gamification.forum.ranking.rank')}{app.translator.trans('reflar-gamification.forum.ranking.name')}{app.translator.trans('reflar-gamification.forum.ranking.amount')}
{icon("trophy")} + + {user.data.attributes['antoinefr-money.money']}
+
+
+
+ ); + } + + findRecipient(id) { + return app.store.find('users', id); + } + + config(isInitialized) { + if (isInitialized) return; + + let timeout; + + this.$() + .on('mouseover', 'h3 a, .UserCard', () => { + clearTimeout(timeout); + timeout = setTimeout(this.showCard.bind(this), 500); + }) + .on('mouseout', 'h3 a, .UserCard', () => { + clearTimeout(timeout); + timeout = setTimeout(this.hideCard.bind(this), 250); + }); + } + + showCard() { + this.cardVisible = true; + + m.redraw(); + + setTimeout(() => this.$('.UserCard').addClass('in')); + } + + hideCard() { + this.$('.UserCard').removeClass('in') + .one('transitionend webkitTransitionEnd oTransitionEnd', () => { + this.cardVisible = false; + m.redraw(); + }); + } +} diff --git a/js/forum/src/components/UserPromotedNotification.js b/js/forum/src/components/UserPromotedNotification.js new file mode 100644 index 0000000..6b80b99 --- /dev/null +++ b/js/forum/src/components/UserPromotedNotification.js @@ -0,0 +1,31 @@ +import Notification from 'flarum/components/Notification'; +import username from 'flarum/helpers/username'; +import punctuateSeries from 'flarum/helpers/punctuateSeries'; + +export default class UserPromotedNotification extends Notification { + icon() { + return 'thumbs-o-up'; + } + + href() { + return app.route.post(this.props.notification.subject()); + } + + content() { + const notification = this.props.notification; + const user = notification.sender(); + const auc = notification.additionalUnreadCount(); + + return app.translator.transChoice('flarum-likes.forum.notifications.post_liked_text', auc + 1, { + user, + username: auc ? punctuateSeries([ + username(user), + app.translator.transChoice('flarum-likes.forum.notifications.others_text', auc, {count: auc}) + ]) : undefined + }); + } + + excerpt() { + return this.props.notification.subject().contentPlain(); + } +} \ No newline at end of file diff --git a/js/forum/src/main.js b/js/forum/src/main.js new file mode 100644 index 0000000..64bb967 --- /dev/null +++ b/js/forum/src/main.js @@ -0,0 +1,30 @@ +import {extend} from 'flarum/extend'; +import app from 'flarum/app'; +// import NotificationGrid from 'flarum/components/NotificationGrid'; + +import AddAttributes from 'Reflar/gamification/components/AddAttributes'; +import AddHotnessFilter from 'Reflar/gamification/components/AddHotnessSort'; +import AddVoteButtons from 'Reflar/gamification/components/AddVoteButtons'; +// import UserPromotedNotification from 'Reflar/gamification/components/UserPromotedNotification'; +// import RankingsPage from 'Reflar/gamification/components/RankingsPage'; + + +app.initializers.add('Reflar-gamification', () => { + + // app.notificationComponents.userPromoted = UserPromotedNotification; + + // app.routes.page = {path: '/rankings', component: RankingsPage.component()}; + + AddVoteButtons(); + AddHotnessFilter(); + AddAttributes(); + + /** + extend(NotificationGrid.prototype, 'notificationTypes', function (items) { + items.add('userPromoted', { + name: 'userPromoted', + icon: 'arrow-up', + label: ['hi'] + }); + });*/ +}); diff --git a/less/admin/extension.less b/less/admin/extension.less new file mode 100644 index 0000000..cd344e9 --- /dev/null +++ b/less/admin/extension.less @@ -0,0 +1,43 @@ +.SettingsPage { + padding: 20px 0; + + @media @desktop-up { + .container { + max-width: 600px; + margin: 0; + } + + fieldset { + margin-bottom: 30px; + + > ul { + list-style: none; + margin: 0; + padding: 0; + } + } + } + + .SettingsPage-ranks { + margin-top: 20px; + } + + .Ranks-number { + width: 15%; + float: left; + margin: 5px 5px 5px; + } + .Ranks-name { + width: 30%; + float: left; + margin: 5px 5px 5px; + } + .Ranks-button { + margin: 5px 0px 5px; + } + .Ranks-default { + width: 30%; + margin-top: 5px; + margin-bottom: 5px + } +} \ No newline at end of file diff --git a/less/forum/extension.less b/less/forum/extension.less new file mode 100644 index 0000000..f5219d9 --- /dev/null +++ b/less/forum/extension.less @@ -0,0 +1,71 @@ +.Post-upvote { + margin-right: 5px; + margin-top: 6px; + } + +.Post-downvote { + margin-left: 10px; + margin-top: 7px; + } + +.Post-vote { + background-color: #fff; + border: none; +} + +.Post-Rank { + color: gray; + font-size: 10px; + margin-left: 10px; +} + +.Post-vote:focus { + outline:0; +} + +.Post-points { + margin-top: 7px; +} + + .rankings { + width: 100%; + td, th { + padding: 5px; + } + &-1 { + font-size: 7.5em; + color: gold; + } + &-1-avatar { + width: 80px; + height: 80px; + font-size: 40px; + line-height: 80px; + } + &-2 { + font-size: 4em; + color: silver; + } + &-2-avatar { + width: 60px; + height: 60px; + font-size: 30px; + line-height: 60px; + } + &-3 { + font-size: 2em; + color: #CD7F32; + } + &-3-avatar { + width: 40px; + height: 40px; + font-size: 20px; + line-height: 20px; + } + &-info { + padding-left: 38.6%; + } + .info-avatar { + position: inherit; + } + } \ No newline at end of file diff --git a/locale/en.yml b/locale/en.yml new file mode 100644 index 0000000..9c744e5 --- /dev/null +++ b/locale/en.yml @@ -0,0 +1,28 @@ +core: + forum: + index_sort: + hot_button: Hot + +reflar-gamification: + forum: + user: + points: "Points: {points}" + rank: "{rank}" + admin: + nav: + desc: Gamification for your Flarum community + page: + save_settings: Save settings + convert: + button: Convert likes to upvotes + help: Convert your previous likes from flarum-ext-likes into upvotes, as well as calculate the hotness for all current discussions. + converting: Your likes are now being converted. + converted: "Successfully converted all {number} likes" + ranks: + title: Ranks + ranks: Custom ranks + help: "Input the required number of upvotes, the name of the rank followed by ': ' and the hex of the rank's color" + default: Default rank + default_help: This rank will be shown if a user does not have a rank. Leave blank to show none. + + \ No newline at end of file diff --git a/migrations/2017_04_09_224815_create_posts_votes_table.php b/migrations/2017_04_09_224815_create_posts_votes_table.php new file mode 100644 index 0000000..061a2c2 --- /dev/null +++ b/migrations/2017_04_09_224815_create_posts_votes_table.php @@ -0,0 +1,31 @@ + function (Builder $schema) { + $schema->create('posts_votes', function (Blueprint $table) { + $table->increments('id'); + $table->integer('post_id')->unsigned(); + $table->integer('user_id')->unsigned(); + $table->string('type'); + }); + }, + 'down' => function (Builder $schema) { + $schema->drop('posts_votes'); + }, +]; diff --git a/migrations/2017_04_09_225024_add_votes_to_users.php b/migrations/2017_04_09_225024_add_votes_to_users.php new file mode 100644 index 0000000..08c65b0 --- /dev/null +++ b/migrations/2017_04_09_225024_add_votes_to_users.php @@ -0,0 +1,31 @@ + function (Builder $schema) { + $schema->table('users', function (Blueprint $table) { + $table->integer('votes'); + $table->string('rank'); + }); + }, + 'down' => function (Builder $schema) { + $schema->table('users', function (Blueprint $table) { + $table->dropColumn('votes'); + $table->dropColumn('rank'); + }); + }, +]; diff --git a/migrations/2017_04_24_094425_add_hotness_to_discussions.php b/migrations/2017_04_24_094425_add_hotness_to_discussions.php new file mode 100644 index 0000000..5fb844a --- /dev/null +++ b/migrations/2017_04_24_094425_add_hotness_to_discussions.php @@ -0,0 +1,31 @@ + function (Builder $schema) { + $schema->table('discussions', function (Blueprint $table) { + $table->integer('votes'); + $table->float('hotness', 10, 4); + }); + }, + 'down' => function (Builder $schema) { + $schema->table('discussions', function (Blueprint $table) { + $table->dropColumn('votes'); + $table->dropColumn('hotness'); + }); + }, +]; diff --git a/src/Api/Controllers/ConvertLikesController.php b/src/Api/Controllers/ConvertLikesController.php new file mode 100644 index 0000000..7c5b947 --- /dev/null +++ b/src/Api/Controllers/ConvertLikesController.php @@ -0,0 +1,81 @@ +settings = $settings; + $this->gamification = $gamification; + } + + /** + * @param ServerRequestInterface $request + * @return int + */ + public function handle(ServerRequestInterface $request) + { + $actor = $request->getAttribute('actor'); + + if ($actor !== null && $actor->isAdmin() && $request->getMethod() === 'POST' && $this->settings->get('reflar.gamification.convertedLikes') == false) { + $likes = Likes::all(); + + $this->settings->set('reflar.gamification.convertedLikes', 'converting'); + + $counter = 0; + + foreach ($likes as $like) { + $this->gamification->convertLike($like->post_id, $like->user_id, $actor); + $counter++; + } + + $discussions = Discussion::all(); + + foreach ($discussions as $discussion) { + $this->gamification->calculateHotness($discussion); + } + + $this->settings->set('reflar.gamification.convertedLikes', $counter); + return new JsonResponse($counter, 200); + } + } +} \ No newline at end of file diff --git a/src/Api/Controllers/ListTopThreeController.php b/src/Api/Controllers/ListTopThreeController.php new file mode 100644 index 0000000..cb17a19 --- /dev/null +++ b/src/Api/Controllers/ListTopThreeController.php @@ -0,0 +1,59 @@ +gamification = $gamification; + $this->floodgate = $floodgate; + } + + /** + * @param ServerRequestInterface $request + * @return mixed + */ + protected function data(ServerRequestInterface $request, Document $document) + { + if (! $request->getAttribute('bypassFloodgate')) { + $this->floodgate->assertNotFlooding($request->getAttribute('actor')); + } + return $this->gamification->findTopThree(); + } +} \ No newline at end of file diff --git a/src/Commands/Vote.php b/src/Commands/Vote.php new file mode 100644 index 0000000..1f2eb85 --- /dev/null +++ b/src/Commands/Vote.php @@ -0,0 +1,46 @@ +post_id = $post_id; + $this->actor = $actor; + $this->type = $type; + } +} \ No newline at end of file diff --git a/src/Commands/VoteHandler.php b/src/Commands/VoteHandler.php new file mode 100644 index 0000000..6dcba7a --- /dev/null +++ b/src/Commands/VoteHandler.php @@ -0,0 +1,48 @@ +gamification = $gamification; + } + + /** + * @param Vote $command + */ + public function handle(Vote $command) + { + $postId = $command->post_id; + $actor = $command->actor; + + if ($command->type == 'Up') { + $this->gamification->upvote($postId, $actor); + } else { + $this->gamification->downvote($postId, $actor); + } + } +} \ No newline at end of file diff --git a/src/Events/PostWasDownvoted.php b/src/Events/PostWasDownvoted.php new file mode 100644 index 0000000..d8843cd --- /dev/null +++ b/src/Events/PostWasDownvoted.php @@ -0,0 +1,48 @@ +post = $post; + $this->user = $user; + $this->actor = $actor; + } +} \ No newline at end of file diff --git a/src/Events/PostWasUpvoted.php b/src/Events/PostWasUpvoted.php new file mode 100644 index 0000000..3018981 --- /dev/null +++ b/src/Events/PostWasUpvoted.php @@ -0,0 +1,48 @@ +post = $post; + $this->user = $user; + $this->actor = $actor; + } +} \ No newline at end of file diff --git a/src/Gambit/HotGambit.php b/src/Gambit/HotGambit.php new file mode 100644 index 0000000..ce29cd1 --- /dev/null +++ b/src/Gambit/HotGambit.php @@ -0,0 +1,37 @@ +getQuery()->orderBy('hotness', 'desc'); + } +} \ No newline at end of file diff --git a/src/Likes.php b/src/Likes.php new file mode 100644 index 0000000..97d5902 --- /dev/null +++ b/src/Likes.php @@ -0,0 +1,22 @@ +listen(ConfigureApiRoutes::class, [$this, 'configureApiRoutes']); + // $events->listen(ConfigureForumRoutes::class, [$this, 'configureForumRoutes']); + } + + /** + * @param ConfigureApiRoutes $event + */ + public function configureApiRoutes(ConfigureApiRoutes $event) + { + $event->post('/reflar/gamification/convert', 'reflar.gamification.convert', ConvertLikesController::class); + // $event->get('/rankings', 'rankings', ListTopThreeController::class); + } + + /** + * @param ConfigureForumRoutes $event + */ + /** public function configureForumRoutes(ConfigureForumRoutes $event) + { + $event->get('/rankings', 'rankings'); + } **/ +} \ No newline at end of file diff --git a/src/Listeners/AddClientAssets.php b/src/Listeners/AddClientAssets.php new file mode 100644 index 0000000..749d9c1 --- /dev/null +++ b/src/Listeners/AddClientAssets.php @@ -0,0 +1,66 @@ +listen(ConfigureClientView::class, [$this, 'configureClientView']); + $events->listen(ConfigureLocales::class, [$this, 'configLocales']); + } + + /** + * @param ConfigureClientView $event + */ + public function configureClientView(ConfigureClientView $event) + { + if ($event->isAdmin()) { + $event->addAssets([ + __DIR__.'/../../js/admin/dist/extension.js', + __DIR__.'/../../less/admin/extension.less' + ]); + $event->addBootstrapper('Reflar/gamification/main'); + } + if ($event->isForum()) { + $event->addAssets([ + __DIR__.'/../../js/forum/dist/extension.js', + __DIR__.'/../../less/forum/extension.less' + ]); + $event->addBootstrapper('Reflar/gamification/main'); + } + + } + + /** + * @param ConfigureLocales $event + */ + public function configLocales(ConfigureLocales $event) + { + foreach (new DirectoryIterator(__DIR__.'/../../locale') as $file) { + if ($file->isFile() && in_array($file->getExtension(), ['yml', 'yaml'], false)) { + $event->locales->addTranslations($file->getBasename('.'.$file->getExtension()), $file->getPathname()); + } + } + } +} \ No newline at end of file diff --git a/src/Listeners/AddRelationships.php b/src/Listeners/AddRelationships.php new file mode 100644 index 0000000..fc03595 --- /dev/null +++ b/src/Listeners/AddRelationships.php @@ -0,0 +1,114 @@ +settings = $settings; + } + + /** + * @param Dispatcher $events + */ + public function subscribe(Dispatcher $events) + { + $events->listen(GetModelRelationship::class, [$this, 'getModelRelationship']); + $events->listen(GetApiRelationship::class, [$this, 'getApiAttributes']); + $events->listen(PrepareApiAttributes::class, [$this, 'prepareApiAttributes']); + $events->listen(ConfigureApiController::class, [$this, 'includeLikes']); + } + /** + * @param GetModelRelationship $event + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany|null + */ + public function getModelRelationship(GetModelRelationship $event) + { + if ($event->isRelationship(Post::class, 'upvotes')) { + return $event->model->belongsToMany(User::class, 'posts_votes', 'post_id', 'user_id', 'upvotes')->where('type', 'Up'); + } + + if ($event->isRelationship(Post::class, 'downvotes')) { + return $event->model->belongsToMany(User::class, 'posts_votes', 'post_id', 'user_id', 'downvotes')->where('type', 'Down'); + } + } + + /** + * @param GetApiRelationship $event + * @return \Tobscure\JsonApi\Relationship|null + */ + public function getApiAttributes(GetApiRelationship $event) + { + if ($event->isRelationship(PostSerializer::class, 'upvotes')) { + return $event->serializer->hasMany($event->model, UserBasicSerializer::class, 'upvotes'); + } + + if ($event->isRelationship(PostSerializer::class, 'downvotes')) { + return $event->serializer->hasMany($event->model, UserBasicSerializer::class, 'downvotes'); + } + } + + /** + * @param PrepareApiAttributes $event + */ + public function prepareApiAttributes(PrepareApiAttributes $event) + { + if ($event->isSerializer(UserSerializer::class)) { + $event->attributes['Points'] = $event->model->votes; + $event->attributes['Rank'] = $event->model->rank; + } + if ($event->isSerializer(ForumSerializer::class)) { + $event->attributes['DefaultRank'] = $this->settings->get('reflar.gamification.defaultRank'); + } + } + + /** + * @param ConfigureApiController $event + */ + public function includeLikes(ConfigureApiController $event) + { + if ($event->isController(Controller\ShowDiscussionController::class)) { + $event->addInclude('posts.upvotes'); + $event->addInclude('posts.downvotes'); + } + if ($event->isController(Controller\ListPostsController::class) + || $event->isController(Controller\ShowPostController::class) + || $event->isController(Controller\CreatePostController::class) + || $event->isController(Controller\UpdatePostController::class)) { + $event->addInclude('upvotes'); + $event->addInclude('downvotes'); + } + } +} \ No newline at end of file diff --git a/src/Listeners/EventHandlers.php b/src/Listeners/EventHandlers.php new file mode 100644 index 0000000..e9d5165 --- /dev/null +++ b/src/Listeners/EventHandlers.php @@ -0,0 +1,107 @@ +settings = $settings; + $this->notifications = $notifications; + $this->gamification = $gamification; + } + + /** + * @param Dispatcher $events + */ + public function subscribe(Dispatcher $events) + { + $events->listen(PostWasUpvoted::class, [$this, 'checkUser']); + $events->listen(PostWasPosted::class, [$this, 'addVote']); + $events->listen(PostWasDeleted::class, [$this, 'removeVote']); + } + + /** + * @param PostWasUpvoted $event + */ + public function checkUser(PostWasUpvoted $event) + { + $this->checkUserVotes($event->user, $event->actor); + } + + /** + * @param PostWasPosted $event + */ + public function addVote(PostWasPosted $event) + { + $event->actor->increment('votes'); + $event->post->discussion->increment('votes'); + $this->gamification->calculateHotness($event->post->discussion); + $this->gamification->upvote($event->post->id, $event->actor); + + $this->checkUserVotes($event->actor, $event->actor); + } + + /** + * @param PostWasDeleted $event + */ + public function removeVote(PostWasDeleted $event) + { + $event->post->user->decrement('votes'); + } + + protected function checkUserVotes($user, $actor) { + + $ranks = json_decode($this->settings->get('reflar.gamification.ranks'), true); + + if (isset($ranks[$user->votes])) { + $user->rank = $ranks[$user->votes]; + $user->save(); + /** + $this->notifications->sync( + new RankupBlueprint($ranks[$user->votes], $actor), + [$user]); + */ + } + } +} \ No newline at end of file diff --git a/src/Listeners/FilterDiscussionListByHotness.php b/src/Listeners/FilterDiscussionListByHotness.php new file mode 100644 index 0000000..ef12f32 --- /dev/null +++ b/src/Listeners/FilterDiscussionListByHotness.php @@ -0,0 +1,49 @@ +listen(ConfigureDiscussionGambits::class, [$this, 'ConfigureDiscussionGambits']); + $events->listen(ConfigureForumRoutes::class, [$this, 'ConfigureForumRoutes']); + } + + /** + * @param ConfigureDiscussionGambits $event + */ + public function ConfigureDiscussionGambits(ConfigureDiscussionGambits $event) + { + $event->gambits->add(HotGambit::class); + } + + /** + * @param ConfigureForumRoutes $event + */ + public function ConfigureForumRoutes(ConfigureForumRoutes $event) + { + $event->get('/hot', 'hot'); + } +} \ No newline at end of file diff --git a/src/Listeners/SaveVotesToDatabase.php b/src/Listeners/SaveVotesToDatabase.php new file mode 100644 index 0000000..92798fd --- /dev/null +++ b/src/Listeners/SaveVotesToDatabase.php @@ -0,0 +1,165 @@ +gamification = $gamification; + $this->events = $events; + } + + /** + * @param Dispatcher $events + */ + public function subscribe(Dispatcher $events) + { + $events->listen(PostWillBeSaved::class, [$this, 'whenPostWillBeSaved']); + $events->listen(PostWasDeleted::class, [$this, 'whenPostWasDeleted']); + } + + /** + * @param PostWillBeSaved $event + */ + public function whenPostWillBeSaved(PostWillBeSaved $event) + { + $post = $event->post; + $data = $event->data; + $actor = $event->actor; + $user = $post->user; + $discussion = $post->discussion; + + if (isset($data['attributes']['isUpvoted'])) { + $isUpvoted = $data['attributes']['isUpvoted']; + } else { + $isUpvoted = false; + } + + if (isset($data['attributes']['isDownvoted'])) { + $isDownvoted = $data['attributes']['isDownvoted']; + } else { + $isDownvoted = false; + } + + if ($post->exists) { + + $vote = $this->gamification->findVote($post->id, $actor->id); + + if (isset($vote)) { + + if ($isUpvoted == false && $isDownvoted == false) { + if ($vote->type == 'Up') + { + $post->user->decrement('votes'); + + if ($post->number = 1) { + $discussion->decrement('votes'); + } + + $this->events->fire( + new PostWasDownvoted($post, $user, $actor) + ); + + } else { + $post->user->increment('votes'); + + if ($post->number = 1) { + $discussion->increment('votes'); + } + + $this->events->fire( + new PostWasUpvoted($post, $user, $actor) + ); + } + $vote->delete(); + + } elseif ($vote->type == 'Up') { + $vote->type = 'Down'; + + $vote->save(); + + $post->user->votes = $post->user->votes - 2; + + if ($post->number = 1) { + $discussion->votes = $discussion->votes - 2; + } + + $this->events->fire( + new PostWasDownvoted($post, $user, $actor) + ); + + } elseif ($vote->type == 'Down') { + $vote->type = 'Up'; + + $vote->save(); + + $post->user->votes = $post->user->votes + 2; + + if ($post->number = 1) { + $discussion->votes = $discussion->votes + 2; + } + + $this->events->fire( + new PostWasUpvoted($post, $user, $actor) + ); + } + + } elseif ($isDownvoted == true) { + $this->gamification->downvote($post->id, $actor); + + $post->user->decrement('votes'); + + if ($post->number = 1) { + $discussion->decrement('votes'); + } + + $this->events->fire( + new PostWasDownvoted($post, $user, $actor) + ); + + } elseif ($isUpvoted == true) { + $this->gamification->upvote($post->id, $actor); + + $post->user->increment('votes'); + + if ($post->number = 1) { + $discussion->increment('votes'); + } + + $this->events->fire( + new PostWasUpvoted($post, $user, $actor) + ); + } + $this->gamification->calculateHotness($post->discussion); + } + } +} \ No newline at end of file diff --git a/src/Notification/RankupBlueprint.php b/src/Notification/RankupBlueprint.php new file mode 100644 index 0000000..9047024 --- /dev/null +++ b/src/Notification/RankupBlueprint.php @@ -0,0 +1,82 @@ +rank = $rank; + $this->user = $user; + } + + /** + * {@inheritdoc} + */ + public function getSubject() + { + return $this->user; + } + + /** + * {@inheritdoc} + */ + public function getSender() + { + return $this->user; + } + + /** + * {@inheritdoc} + */ + public function getData() + { + return ['rank' => $this->rank]; + } + + /** + * {@inheritdoc} + */ + public static function getType() + { + return 'userPromoted'; + } + + /** + * {@inheritdoc} + */ + public static function getSubjectModel() + { + return User::class; + } +} \ No newline at end of file diff --git a/src/Repository/Gamification.php b/src/Repository/Gamification.php new file mode 100644 index 0000000..c2381b2 --- /dev/null +++ b/src/Repository/Gamification.php @@ -0,0 +1,172 @@ +posts = $posts; + $this->users = $users; + } + + /** + * @return mixed + */ + public function query() + { + return posts_votes::query(); + } + + /** + * @param $post_id + * @param $user_id + * @param $type + */ + public function saveVote($post_id, $user_id, $type) + { + $vote = new Vote(); + $vote->post_id = $post_id; + $vote->user_id = $user_id; + $vote->type = $type; + $vote->save(); + } + + /** + * @param $post_id + * @param User $actor + */ + public function upvote($post_id, User $actor) + { + $post = $this->posts->findOrFail($post_id, $actor); + $user = $post->user; + + $this->saveVote($post->id, $actor->id, 'Up'); + } + + + /** + * @param $post_id + * @param User $actor + */ + public function downvote($post_id, User $actor) + { + $post = $this->posts->findOrFail($post_id, $actor); + $user = $post->user; + + $this->saveVote($post->id, $actor->id, 'Down'); + + } + + + /** + * The Reddit hotness algorithm from https://github.com/reddit/reddit + * @param $discussion + */ + public function calculateHotness($discussion) + { + $date = strtotime($discussion->start_time); + + $s = $discussion->votes; + $order = log10(max(abs($s), 1)); + + if ($s > 0) + $sign = 1; + elseif ($s < 0) + $sign = -1; + else + $sign = 0; + + $seconds = $date - 1134028003; + + $discussion->hotness = round($sign * $order + $seconds / 45000, 10); + + $discussion->save(); + } + + /** + * @param $post_id + * @param $user_id + * @return mixed + */ + public function findVote($post_id, $user_id) + { + return Vote::where([ + 'post_id' => $post_id, + 'user_id' => $user_id + ])->first(); + } + + /** + * @return mixed + */ + public function findTopThree() + { + $query = User::query() + ->orderBy('votes', 'desc') + ->take(3) + ->get(); + + return $query; + } + + public function findColorForRank($rank) { + + } + + /** + * @param $post_id + * @param $user_id + * @param User $actor + */ + public function convertLike($post_id, $user_id, User $actor) + { + $user = $this->users->query()->where('id', $user_id)->first(); + $post = $this->posts->query()->where('id', $post_id)->first(); + + if ($post !== null && $user !== null) { + + $user->increment('votes'); + + if ($post->number = 1) { + $post->discussion->increment('votes'); + } + + + $this->upvote($post_id, $actor); + } + } +} \ No newline at end of file diff --git a/src/Vote.php b/src/Vote.php new file mode 100644 index 0000000..b2a32d5 --- /dev/null +++ b/src/Vote.php @@ -0,0 +1,22 @@ +