diff --git a/js/admin/Gulpfile.js b/js/admin/Gulpfile.js index 466135e1f..c695c9a1e 100644 --- a/js/admin/Gulpfile.js +++ b/js/admin/Gulpfile.js @@ -1,51 +1,22 @@ -var gulp = require('gulp'); -var livereload = require('gulp-livereload'); -var concat = require('gulp-concat'); -var argv = require('yargs').argv; -var uglify = require('gulp-uglify'); -var gulpif = require('gulp-if'); -var merge = require('merge-stream'); -var babel = require('gulp-babel'); -var cached = require('gulp-cached'); -var remember = require('gulp-remember'); +var gulp = require('flarum-gulp'); -var vendorFiles = [ - '../bower_components/loader.js/loader.js', - '../bower_components/mithril/mithril.js', - '../bower_components/jquery/dist/jquery.js', - '../bower_components/moment/moment.js', - '../bower_components/bootstrap/dist/js/bootstrap.js', - '../bower_components/spin.js/spin.js', - '../bower_components/spin.js/jquery.spin.js' -]; - -var moduleFiles = [ - 'src/**/*.js', - '../lib/**/*.js' -]; -var modulePrefix = 'flarum'; - -gulp.task('default', function() { - return merge( - gulp.src(vendorFiles), - gulp.src(moduleFiles) - .pipe(cached('scripts')) - .pipe(babel({ modules: 'amd', moduleIds: true, moduleRoot: modulePrefix })) - .pipe(remember('scripts')) - ) - .pipe(concat('app.js')) - .pipe(gulpif(argv.production, uglify())) - .pipe(gulp.dest('dist')) - .pipe(livereload()); -}); - -gulp.task('watch', ['default'], function () { - livereload.listen(); - var watcher = gulp.watch(moduleFiles.concat(vendorFiles), ['default']); - watcher.on('change', function (event) { - if (event.type === 'deleted') { - delete cached.caches.scripts[event.path]; - remember.forget('scripts', event.path); - } - }); +gulp({ + files: [ + 'node_modules/babel-core/external-helpers.js', + '../bower_components/loader.js/loader.js', + '../bower_components/mithril/mithril.js', + '../bower_components/jquery/dist/jquery.js', + '../bower_components/moment/moment.js', + '../bower_components/bootstrap/dist/js/bootstrap.js', + '../bower_components/spin.js/spin.js', + '../bower_components/spin.js/jquery.spin.js' + ], + moduleFiles: [ + 'src/**/*.js', + '../lib/**/*.js' + ], + bootstrapFiles: [], + modulePrefix: 'flarum', + externalHelpers: true, + outputFile: 'dist/app.js' }); diff --git a/js/admin/package.json b/js/admin/package.json index 51b539e61..81fb6266e 100644 --- a/js/admin/package.json +++ b/js/admin/package.json @@ -1,14 +1,8 @@ { + "private": true, "devDependencies": { "gulp": "^3.8.11", - "gulp-babel": "^5.1.0", - "gulp-cached": "^1.0.4", - "gulp-concat": "^2.5.2", - "gulp-if": "^1.2.5", - "gulp-livereload": "^3.8.0", - "gulp-remember": "^0.3.0", - "gulp-uglify": "^1.2.0", - "merge-stream": "^0.1.7", - "yargs": "^3.7.2" + "flarum-gulp": "git+https://github.com/flarum/gulp.git", + "babel-core": "^5.0.0" } } diff --git a/js/forum/Gulpfile.js b/js/forum/Gulpfile.js index 54b296bc8..37f0b0332 100644 --- a/js/forum/Gulpfile.js +++ b/js/forum/Gulpfile.js @@ -1,53 +1,24 @@ -var gulp = require('gulp'); -var livereload = require('gulp-livereload'); -var concat = require('gulp-concat'); -var argv = require('yargs').argv; -var uglify = require('gulp-uglify'); -var gulpif = require('gulp-if'); -var merge = require('merge-stream'); -var babel = require('gulp-babel'); -var cached = require('gulp-cached'); -var remember = require('gulp-remember'); +var gulp = require('flarum-gulp'); -var vendorFiles = [ - '../bower_components/loader.js/loader.js', - '../bower_components/mithril/mithril.js', - '../bower_components/jquery/dist/jquery.js', - '../bower_components/jquery.hotkeys/jquery.hotkeys.js', - '../bower_components/color-thief/js/color-thief.js', - '../bower_components/moment/moment.js', - '../bower_components/bootstrap/dist/js/bootstrap.js', - '../bower_components/spin.js/spin.js', - '../bower_components/spin.js/jquery.spin.js' -]; - -var moduleFiles = [ - 'src/**/*.js', - '../lib/**/*.js' -]; -var modulePrefix = 'flarum'; - -gulp.task('default', function() { - return merge( - gulp.src(vendorFiles), - gulp.src(moduleFiles) - .pipe(cached('scripts')) - .pipe(babel({ modules: 'amd', moduleIds: true, moduleRoot: modulePrefix })) - .pipe(remember('scripts')) - ) - .pipe(concat('app.js')) - .pipe(gulpif(argv.production, uglify())) - .pipe(gulp.dest('dist')) - .pipe(livereload()); -}); - -gulp.task('watch', ['default'], function () { - livereload.listen(); - var watcher = gulp.watch(moduleFiles.concat(vendorFiles), ['default']); - watcher.on('change', function (event) { - if (event.type === 'deleted') { - delete cached.caches.scripts[event.path]; - remember.forget('scripts', event.path); - } - }); +gulp({ + files: [ + 'node_modules/babel-core/external-helpers.js', + '../bower_components/loader.js/loader.js', + '../bower_components/mithril/mithril.js', + '../bower_components/jquery/dist/jquery.js', + '../bower_components/jquery.hotkeys/jquery.hotkeys.js', + '../bower_components/color-thief/js/color-thief.js', + '../bower_components/moment/moment.js', + '../bower_components/bootstrap/dist/js/bootstrap.js', + '../bower_components/spin.js/spin.js', + '../bower_components/spin.js/jquery.spin.js' + ], + moduleFiles: [ + 'src/**/*.js', + '../lib/**/*.js' + ], + bootstrapFiles: [], + modulePrefix: 'flarum', + externalHelpers: true, + outputFile: 'dist/app.js' }); diff --git a/js/forum/package.json b/js/forum/package.json index 51b539e61..81fb6266e 100644 --- a/js/forum/package.json +++ b/js/forum/package.json @@ -1,14 +1,8 @@ { + "private": true, "devDependencies": { "gulp": "^3.8.11", - "gulp-babel": "^5.1.0", - "gulp-cached": "^1.0.4", - "gulp-concat": "^2.5.2", - "gulp-if": "^1.2.5", - "gulp-livereload": "^3.8.0", - "gulp-remember": "^0.3.0", - "gulp-uglify": "^1.2.0", - "merge-stream": "^0.1.7", - "yargs": "^3.7.2" + "flarum-gulp": "git+https://github.com/flarum/gulp.git", + "babel-core": "^5.0.0" } } diff --git a/js/forum/src/components/change-email-modal.js b/js/forum/src/components/change-email-modal.js index 99da605d2..6dd356df2 100644 --- a/js/forum/src/components/change-email-modal.js +++ b/js/forum/src/components/change-email-modal.js @@ -18,7 +18,7 @@ export default class ChangeEmailModal extends FormModal { return super.view({ className: 'modal-sm change-email-modal', title: 'Change Email', - body: this.success() + body: m('div.form-centered', this.success() ? [ m('p.help-text', 'We\'ve sent a confirmation email to ', m('strong', this.email()), '. If it doesn\'t arrive soon, check your spam folder.'), m('div.form-group', [ @@ -37,7 +37,7 @@ export default class ChangeEmailModal extends FormModal { m('div.form-group', [ m('button.btn.btn-primary.btn-block[type=submit]', {disabled}, 'Save Changes') ]) - ] + ]) }); } diff --git a/js/forum/src/components/change-password-modal.js b/js/forum/src/components/change-password-modal.js index d8103466b..76a995bf7 100644 --- a/js/forum/src/components/change-password-modal.js +++ b/js/forum/src/components/change-password-modal.js @@ -5,12 +5,12 @@ export default class ChangePasswordModal extends FormModal { return super.view({ className: 'modal-sm change-password-modal', title: 'Change Password', - body: [ + body: m('div.form-centered', [ m('p.help-text', 'Click the button below and check your email for a link to change your password.'), m('div.form-group', [ m('button.btn.btn-primary.btn-block[type=submit]', {disabled: this.loading()}, 'Send Password Reset Email') ]) - ] + ]) }); } diff --git a/js/forum/src/components/composer.js b/js/forum/src/components/composer.js index 5534ba263..9398c6b59 100644 --- a/js/forum/src/components/composer.js +++ b/js/forum/src/components/composer.js @@ -117,7 +117,7 @@ class Composer extends Component { this.updateHeight(); var scrollTop = $(window).scrollTop(); - this.updateBodyPadding(false, scrollTop > 0 && scrollTop + $(window).height() >= $(document).height()); + this.updateBodyPadding(scrollTop > 0 && scrollTop + $(window).height() >= $(document).height()); localStorage.setItem('composerHeight', height); } @@ -136,9 +136,9 @@ class Composer extends Component { var $composer = this.$().stop(true); var oldHeight = $composer.is(':visible') ? $composer.outerHeight() : 0; - if (this.position() !== Composer.PositionEnum.HIDDEN) { - m.redraw(true); - } + var scrollTop = $(window).scrollTop(); + + m.redraw(true); this.$().height(this.computedHeight()); var newHeight = $composer.outerHeight(); @@ -167,7 +167,8 @@ class Composer extends Component { } if (this.position() !== Composer.PositionEnum.FULLSCREEN) { - this.updateBodyPadding(true, anchorToBottom); + this.updateBodyPadding(); + $('html, body').scrollTop(anchorToBottom ? $(document).height() : scrollTop); } else { this.component.focus(); } @@ -182,18 +183,11 @@ class Composer extends Component { // Update the amount of padding-bottom on the body so that the page's // content will still be visible above the composer when the page is // scrolled right to the bottom. - updateBodyPadding(animate, anchorToBottom) { - var func = animate ? 'animate' : 'css'; - var paddingBottom = this.position() !== Composer.PositionEnum.HIDDEN ? this.computedHeight() - parseInt($('#page').css('padding-bottom')) : 0; - $('#content')[func]({paddingBottom}, 'fast'); - - if (anchorToBottom) { - if (animate) { - $('html, body').stop(true).animate({scrollTop: $(document).height()}, 'fast'); - } else { - $('html, body').scrollTop($(document).height()); - } - } + updateBodyPadding() { + var paddingBottom = this.position() !== Composer.PositionEnum.HIDDEN && this.position() !== Composer.PositionEnum.MINIMIZED + ? this.computedHeight() - parseInt($('#page').css('padding-bottom')) + : 0; + $('#content').css({paddingBottom}); } // Update the height of the stuff inside of the composer. There should be diff --git a/js/forum/src/components/delete-account-modal.js b/js/forum/src/components/delete-account-modal.js index 14300a746..8c82fe252 100644 --- a/js/forum/src/components/delete-account-modal.js +++ b/js/forum/src/components/delete-account-modal.js @@ -11,7 +11,7 @@ export default class DeleteAccountModal extends FormModal { return super.view({ className: 'modal-sm change-password-modal', title: 'Delete Account', - body: [ + body: m('div.form-centered', [ m('p.help-text', 'Hold up there skippy! If you delete your account, there\'s no going back. All of your posts will be kept, but no longer associated with your account.'), m('div.form-group', [ m('input.form-control[name=confirm][placeholder=Type "DELETE" to proceed]', {oninput: m.withAttr('value', this.confirmation)}) @@ -19,7 +19,7 @@ export default class DeleteAccountModal extends FormModal { m('div.form-group', [ m('button.btn.btn-primary.btn-block[type=submit]', {disabled: this.loading() || this.confirmation() != 'DELETE'}, 'Delete Account') ]) - ] + ]) }); } diff --git a/js/forum/src/components/discussion-composer.js b/js/forum/src/components/discussion-composer.js index eb987b48f..3703b156e 100644 --- a/js/forum/src/components/discussion-composer.js +++ b/js/forum/src/components/discussion-composer.js @@ -41,12 +41,32 @@ export default class DiscussionComposer extends ComposerBody { if (empty) { $this.val(''); } }); setTimeout(() => $(element).trigger('input')); + }, + onkeydown: (e) => { + if (e.which === 13) { // return + e.preventDefault(); + this.editor.setSelectionRange(0, 0); + } + m.redraw.strategy('none'); } }))); return items; } + onload(element) { + super.onload(element); + + this.editor.$('textarea').keydown((e) => { + if (e.which === 8 && e.target.selectionStart == 0 && e.target.selectionEnd == 0) { // Backspace + e.preventDefault(); + var title = this.$(':input:enabled:visible:first')[0]; + title.focus(); + title.selectionStart = title.selectionEnd = title.value.length; + } + }); + } + preventExit() { return (this.title() || this.content()) && !confirm(this.props.confirmExit); } diff --git a/js/forum/src/components/forgot-password-modal.js b/js/forum/src/components/forgot-password-modal.js index 92a7949e0..befa231e5 100644 --- a/js/forum/src/components/forgot-password-modal.js +++ b/js/forum/src/components/forgot-password-modal.js @@ -19,7 +19,7 @@ export default class ForgotPasswordModal extends FormModal { return super.view({ className: 'modal-sm forgot-password', title: 'Forgot Password', - body: this.success() + body: m('div.form-centered', this.success() ? [ m('p.help-text', 'We\'ve sent you an email containing a link to reset your password. Check your spam folder if you don\'t receive it within the next minute or two.'), m('div.form-group', [ @@ -34,7 +34,7 @@ export default class ForgotPasswordModal extends FormModal { m('div.form-group', [ m('button.btn.btn-primary.btn-block[type=submit]', {disabled: this.loading()}, 'Recover Password') ]) - ] + ]) }); } diff --git a/js/forum/src/components/form-modal.js b/js/forum/src/components/form-modal.js index bfae1bbf7..e2b11892b 100644 --- a/js/forum/src/components/form-modal.js +++ b/js/forum/src/components/form-modal.js @@ -23,9 +23,7 @@ export default class FormModal extends Component { m('form', {onsubmit: this.onsubmit.bind(this)}, [ m('div.modal-header', m('h3.title-control', options.title)), alert ? m('div.modal-alert', alert) : '', - m('div.modal-body', [ - m('div.form-centered', options.body) - ]), + m('div.modal-body', options.body), options.footer ? m('div.modal-footer', options.footer) : '' ]) ]), diff --git a/js/forum/src/components/index-page.js b/js/forum/src/components/index-page.js index fbb82b025..086998d85 100644 --- a/js/forum/src/components/index-page.js +++ b/js/forum/src/components/index-page.js @@ -160,7 +160,7 @@ export default class IndexPage extends Component { items.add('sort', SelectInput.component({ options: sortOptions, - value: this.params.sort, + value: this.params().sort, onchange: this.reorder.bind(this) }) ); @@ -271,6 +271,7 @@ export default class IndexPage extends Component { $('body').addClass('index-page'); context.onunload = function() { $('body').removeClass('index-page'); + $('.global-page').css('min-height', ''); }; app.setTitle(''); @@ -339,16 +340,30 @@ export default class IndexPage extends Component { /** * Initialize the composer for a new discussion. * - * @todo return a promise - * @return void + * @return {Promise} */ newDiscussion() { + var deferred = m.deferred(); + if (app.session.user()) { - app.composer.load(new DiscussionComposer({ user: app.session.user() })); - app.composer.show(); - return true; + this.composeNewDiscussion(deferred); + } else { + app.modal.show( + new LoginModal({ onlogin: this.composeNewDiscussion.bind(this, deferred) }) + ); } - app.modal.show(new LoginModal({ onlogin: this.newDiscussion.bind(this) })); + + return deferred.promise; + } + + composeNewDiscussion(deferred) { + // @todo check global permissions + var component = new DiscussionComposer({ user: app.session.user() }); + app.composer.load(component); + app.composer.show(); + deferred.resolve(component); + + return deferred.promise; } /** diff --git a/js/forum/src/components/login-modal.js b/js/forum/src/components/login-modal.js index 3e0897100..6a66cf60b 100644 --- a/js/forum/src/components/login-modal.js +++ b/js/forum/src/components/login-modal.js @@ -18,7 +18,7 @@ export default class LoginModal extends FormModal { return super.view({ className: 'modal-sm login-modal', title: 'Log In', - body: [ + body: m('div.form-centered', [ m('div.form-group', [ m('input.form-control[name=email][placeholder=Username or Email]', {value: this.email(), onchange: m.withAttr('value', this.email), disabled: this.loading()}) ]), @@ -28,7 +28,7 @@ export default class LoginModal extends FormModal { m('div.form-group', [ m('button.btn.btn-primary.btn-block[type=submit]', {disabled: this.loading()}, 'Log In') ]) - ], + ]), footer: [ m('p.forgot-password-link', m('a[href=javascript:;]', {onclick: () => { var email = this.email(); diff --git a/js/forum/src/components/post-preview.js b/js/forum/src/components/post-preview.js index 592916cb9..30ace129a 100644 --- a/js/forum/src/components/post-preview.js +++ b/js/forum/src/components/post-preview.js @@ -12,14 +12,14 @@ export default class PostPreview extends Component { var excerpt = post.contentPlain(); var start = 0; - if (highlight) { + if (this.props.highlight) { var regexp = new RegExp(this.props.highlight, 'gi'); start = Math.max(0, excerpt.search(regexp) - 100); } excerpt = (start > 0 ? '...' : '')+excerpt.substring(start, start + 200)+(excerpt.length > start + 200 ? '...' : ''); - if (highlight) { + if (this.props.highlight) { excerpt = highlight(excerpt, regexp); } diff --git a/js/forum/src/components/post-scrubber.js b/js/forum/src/components/post-scrubber.js index d11cb116b..362988523 100644 --- a/js/forum/src/components/post-scrubber.js +++ b/js/forum/src/components/post-scrubber.js @@ -134,7 +134,7 @@ export default class PostScrubber extends Component { // properties to a 'default' state. These values reflect what would be // seen if the browser were scrolled right up to the top of the page, // and the viewport had a height of 0. - var $items = stream.$('> .item'); + var $items = stream.$('> .item[data-index]'); var index = $items.first().data('index'); var visible = 0; var period = ''; diff --git a/js/forum/src/components/post-stream.js b/js/forum/src/components/post-stream.js index 6a3e6049d..61996411b 100644 --- a/js/forum/src/components/post-stream.js +++ b/js/forum/src/components/post-stream.js @@ -88,7 +88,7 @@ class PostStream extends mixin(Component, evented) { stream is not visible. */ pushPost(post) { - if (this.visibleEnd >= this.count() - 1) { + if (this.visibleEnd >= this.count() - 1 && this.posts.indexOf(post) === -1) { this.posts.push(post); this.visibleEnd++; } @@ -295,7 +295,7 @@ class PostStream extends mixin(Component, evented) { var redraw = () => { if (start < this.visibleStart || end > this.visibleEnd) return; - var anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart; + var anchorIndex = backwards && $(window).scrollTop() > 0 ? this.visibleEnd - 1 : this.visibleStart; anchorScroll(this.$('.item[data-index='+anchorIndex+']'), () => m.redraw(true)); this.unpause(); @@ -422,7 +422,7 @@ class PostStream extends mixin(Component, evented) { scrollToIndex(index, noAnimation, bottom) { var $item = this.$('.item[data-index='+index+']'); - return this.scrollToItem($item, noAnimation, true, true); + return this.scrollToItem($item, noAnimation, true, bottom); } /** diff --git a/js/forum/src/components/posted-activity.js b/js/forum/src/components/posted-activity.js index 9b62d620a..e2e65ab68 100644 --- a/js/forum/src/components/posted-activity.js +++ b/js/forum/src/components/posted-activity.js @@ -23,7 +23,7 @@ export default class PostedActivity extends Component { near: post.number() }), config: m.route}, [ m('ul.list-inline', listItems(this.headerItems().toArray())), - m('div.body', m.trust(post.excerpt())) + m('div.body', m.trust(post.contentPlain().substring(0, 200))) ]) ]); } diff --git a/js/forum/src/components/signup-modal.js b/js/forum/src/components/signup-modal.js index 6b8ddd545..4884462a4 100644 --- a/js/forum/src/components/signup-modal.js +++ b/js/forum/src/components/signup-modal.js @@ -22,7 +22,7 @@ export default class SignupModal extends FormModal { var vdom = super.view({ className: 'modal-sm signup-modal'+(welcomeUser ? ' signup-modal-success' : ''), title: 'Sign Up', - body: [ + body: m('div.form-centered', [ m('div.form-group', [ m('input.form-control[name=username][placeholder=Username]', {value: this.username(), onchange: m.withAttr('value', this.username), disabled: this.loading()}) ]), @@ -35,7 +35,7 @@ export default class SignupModal extends FormModal { m('div.form-group', [ m('button.btn.btn-primary.btn-block[type=submit]', {disabled: this.loading()}, 'Sign Up') ]) - ], + ]), footer: [ m('p.log-in-link', [ 'Already have an account? ', diff --git a/js/forum/src/components/terminal-post.js b/js/forum/src/components/terminal-post.js index da9fa00de..fdabecd6d 100644 --- a/js/forum/src/components/terminal-post.js +++ b/js/forum/src/components/terminal-post.js @@ -1,5 +1,5 @@ import Component from 'flarum/component'; -import humanTime from 'flarum/utils/human-time'; +import humanTime from 'flarum/helpers/human-time'; import username from 'flarum/helpers/username'; /** @@ -16,10 +16,13 @@ export default class TerminalPost extends Component { var discussion = this.props.discussion; var lastPost = this.props.lastPost && discussion.repliesCount(); + var user = discussion[lastPost ? 'lastUser' : 'startUser'](); + var time = discussion[lastPost ? 'lastTime' : 'startTime'](); + return m('span', [ - username(discussion[lastPost ? 'lastUser' : 'startUser']()), + username(user), lastPost ? ' replied ' : ' started ', - m('time', humanTime(discussion[lastPost ? 'lastTime' : 'startTime']())) + humanTime(time) ]) } } diff --git a/js/forum/src/initializers/discussion-controls.js b/js/forum/src/initializers/discussion-controls.js index 1b1a031fd..57d076836 100644 --- a/js/forum/src/initializers/discussion-controls.js +++ b/js/forum/src/initializers/discussion-controls.js @@ -87,6 +87,8 @@ export default function(app) { } if (this.canDelete()) { + items.add('separator', Separator.component()); + items.add('delete', ActionButton.component({ icon: 'times', label: 'Delete', onclick: this.deleteAction.bind(this) })); } diff --git a/js/forum/src/initializers/state-helpers.js b/js/forum/src/initializers/state-helpers.js index 3363a6ff6..643b86a06 100644 --- a/js/forum/src/initializers/state-helpers.js +++ b/js/forum/src/initializers/state-helpers.js @@ -1,10 +1,12 @@ +import Composer from 'flarum/components/composer'; import ReplyComposer from 'flarum/components/reply-composer'; import DiscussionPage from 'flarum/components/discussion-page'; export default function(app) { app.composingReplyTo = function(discussion) { return this.composer.component instanceof ReplyComposer && - this.composer.component.props.discussion === discussion; + this.composer.component.props.discussion === discussion && + this.composer.position() !== Composer.PositionEnum.HIDDEN; }; app.viewingDiscussion = function(discussion) { diff --git a/js/lib/model.js b/js/lib/model.js index 156bafc9c..6e3ba9dd4 100644 --- a/js/lib/model.js +++ b/js/lib/model.js @@ -30,7 +30,14 @@ export default class Model { 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}}; + var linkage = model => { + return {type: model.data().type, id: model.data().id}; + }; + if (model instanceof Array) { + data.links[i] = {linkage: model.map(linkage)}; + } else { + data.links[i] = {linkage: linkage(model)}; + } } } diff --git a/js/lib/models/discussion.js b/js/lib/models/discussion.js index ed2cdbeaf..20dee1926 100644 --- a/js/lib/models/discussion.js +++ b/js/lib/models/discussion.js @@ -17,7 +17,11 @@ class Discussion extends Model { } if (newData.links && newData.links.addedPosts) { - [].push.apply(posts.linkage, newData.links.addedPosts.linkage); + newData.links.addedPosts.linkage.forEach(linkage => { + if (posts.linkage[posts.linkage.length - 1].id != linkage.id) { + posts.linkage.push(linkage); + } + }); } } } diff --git a/js/lib/utils/app.js b/js/lib/utils/app.js index 8857a96d7..998b2d224 100644 --- a/js/lib/utils/app.js +++ b/js/lib/utils/app.js @@ -1,10 +1,12 @@ import ItemList from 'flarum/utils/item-list'; import Alert from 'flarum/components/alert'; import ServerError from 'flarum/utils/server-error'; +import Translator from 'flarum/utils/translator'; class App { constructor() { this.initializers = new ItemList(); + this.translator = new Translator(); this.cache = {}; this.serverError = null; } @@ -55,6 +57,10 @@ class App { var queryString = m.route.buildQueryString(params); return url+(queryString ? '?'+queryString : ''); } + + translate(key, input) { + return this.translator.translate(key, input); + } } export default App; diff --git a/js/lib/utils/item-list.js b/js/lib/utils/item-list.js index 8d7f8c511..0b5bfb9fb 100644 --- a/js/lib/utils/item-list.js +++ b/js/lib/utils/item-list.js @@ -41,21 +41,17 @@ export default class ItemList { addItems('push', false); addItems('push', 'last'); - items = items.filter(function(item) { + items.forEach(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); + index = type === 'before' ? 0 : array.length; } + array.splice(index + (type === 'after' ? 1 : 0), 0, item); } - }.bind(this)); - - array = array.concat(items); + }); return array.map((item) => item.content); } diff --git a/js/lib/utils/translator.js b/js/lib/utils/translator.js new file mode 100644 index 000000000..e8235e864 --- /dev/null +++ b/js/lib/utils/translator.js @@ -0,0 +1,32 @@ +export default class Translator { + constructor() { + this.translations = {}; + } + + plural(count) { + return count == 1 ? 'one' : 'other'; + } + + translate(key, input) { + var parts = key.split('.'); + var translation = this.translations; + + parts.forEach(function(part) { + translation = translation && translation[part]; + }); + + if (typeof translation === 'object' && typeof input.count !== 'undefined') { + translation = translation[this.plural(input.count)]; + } + + if (typeof translation === 'string') { + for (var i in input) { + translation = translation.replace(new RegExp('{'+i+'}', 'gi'), input[i]); + } + + return translation; + } else { + return key; + } + } +} diff --git a/less/forum/index.less b/less/forum/index.less index c1fc1ec2e..bb57786f4 100644 --- a/less/forum/index.less +++ b/less/forum/index.less @@ -210,10 +210,9 @@ & .title { margin: 0 0 6px; line-height: 1.3; - color: @fl-secondary-color; + color: @fl-body-heading-color; } &.unread .title { - color: @fl-body-heading-color; font-weight: bold; } & .info { diff --git a/less/lib/variables.less b/less/lib/variables.less index d85161fd7..7dc335727 100644 --- a/less/lib/variables.less +++ b/less/lib/variables.less @@ -21,8 +21,8 @@ @fl-body-secondary-color: hsl(@fl-secondary-hue, min(50%, @fl-secondary-sat), 93%); @fl-body-bg: #fff; - @fl-body-color: #444; - @fl-body-muted-color: hsl(@fl-secondary-hue, min(25%, @fl-secondary-sat), 66%); + @fl-body-color: #333; + @fl-body-muted-color: hsl(@fl-secondary-hue, min(25%, @fl-secondary-sat), 60%); @fl-body-muted-more-color: #bbb; @fl-shadow-color: rgba(0, 0, 0, 0.35); } diff --git a/locale/en/config.js b/locale/en/config.js new file mode 100644 index 000000000..42739e270 --- /dev/null +++ b/locale/en/config.js @@ -0,0 +1,3 @@ +app.translator.plural = function(count) { + return count == 1 ? 'one' : 'other'; +}; diff --git a/locale/en/config.php b/locale/en/config.php new file mode 100644 index 000000000..3ca0f9cf2 --- /dev/null +++ b/locale/en/config.php @@ -0,0 +1,7 @@ + function ($count) { + return $count == 1 ? 'one' : 'other'; + } +]; diff --git a/locale/en/translations.yml b/locale/en/translations.yml new file mode 100644 index 000000000..898cbf708 --- /dev/null +++ b/locale/en/translations.yml @@ -0,0 +1,2 @@ +core: + diff --git a/migrations/2015_02_24_000000_create_users_discussions_table.php b/migrations/2015_02_24_000000_create_users_discussions_table.php index 060fe5e3a..c0604c03e 100644 --- a/migrations/2015_02_24_000000_create_users_discussions_table.php +++ b/migrations/2015_02_24_000000_create_users_discussions_table.php @@ -15,7 +15,6 @@ class CreateUsersDiscussionsTable extends Migration public function up() { Schema::create('users_discussions', function (Blueprint $table) { - $table->integer('user_id')->unsigned(); $table->integer('discussion_id')->unsigned(); $table->dateTime('read_time')->nullable(); diff --git a/migrations/2015_02_24_000000_create_users_table.php b/migrations/2015_02_24_000000_create_users_table.php index e35e60cf7..8995ece86 100644 --- a/migrations/2015_02_24_000000_create_users_table.php +++ b/migrations/2015_02_24_000000_create_users_table.php @@ -20,6 +20,7 @@ class CreateUsersTable extends Migration $table->string('email', 150)->unique(); $table->boolean('is_activated')->default(0); $table->string('password', 100); + $table->string('locale', 10)->default('en'); $table->text('bio')->nullable(); $table->text('bio_html')->nullable(); $table->string('avatar_path', 100)->nullable(); diff --git a/src/Api/Actions/Discussions/IndexAction.php b/src/Api/Actions/Discussions/IndexAction.php index 9c8a94354..77f6ce578 100644 --- a/src/Api/Actions/Discussions/IndexAction.php +++ b/src/Api/Actions/Discussions/IndexAction.php @@ -84,15 +84,11 @@ class IndexAction extends SerializeCollectionAction $load = array_merge($request->include, ['state']); $results = $this->searcher->search($criteria, $request->limit, $request->offset, $load); - if (($total = $results->getTotal()) !== null) { - $document->addMeta('total', $total); - } - static::addPaginationLinks( $document, $request, $this->url->toRoute('flarum.api.discussions.index'), - $total ?: $results->areMoreResults() + $results->areMoreResults() ); return $results->getDiscussions(); diff --git a/src/Api/Actions/Discussions/ShowAction.php b/src/Api/Actions/Discussions/ShowAction.php index 8fc51a5c4..06e6f1dbf 100644 --- a/src/Api/Actions/Discussions/ShowAction.php +++ b/src/Api/Actions/Discussions/ShowAction.php @@ -93,7 +93,7 @@ class ShowAction extends SerializeResourceAction $discussion = $this->discussions->findOrFail($request->get('id'), $user); - $discussion->posts_ids = $discussion->posts()->whereCan($user, 'view')->get(['id'])->fetch('id')->all(); + $discussion->posts_ids = $discussion->visiblePosts($user)->lists('id'); if (in_array('posts', $request->include)) { $length = strlen($prefix = 'posts.'); diff --git a/src/Api/Actions/Forum/ShowAction.php b/src/Api/Actions/Forum/ShowAction.php new file mode 100644 index 000000000..44a80ebbd --- /dev/null +++ b/src/Api/Actions/Forum/ShowAction.php @@ -0,0 +1,29 @@ +data($request, $document); + $serializer = new static::$serializer($request->actor, $request->include, $request->link); $document->setData($this->serialize($serializer, $data)); + $response = new JsonApiResponse($document); - return new JsonApiResponse($document); + event(new WillRespond($this, $data, $request, $response)); + + return $response; } /** diff --git a/src/Api/Actions/Users/ShowAction.php b/src/Api/Actions/Users/ShowAction.php index 930a33a8a..79a14ce6d 100644 --- a/src/Api/Actions/Users/ShowAction.php +++ b/src/Api/Actions/Users/ShowAction.php @@ -29,6 +29,8 @@ class ShowAction extends SerializeResourceAction 'groups' => true ]; + public static $link = []; + /** * Instantiate the action. * diff --git a/src/Api/Events/WillRespond.php b/src/Api/Events/WillRespond.php new file mode 100644 index 000000000..6519c8d7b --- /dev/null +++ b/src/Api/Events/WillRespond.php @@ -0,0 +1,20 @@ +action = $action; + $this->data = &$data; + $this->request = $request; + $this->response = $response; + } +} diff --git a/src/Api/Serializers/BaseSerializer.php b/src/Api/Serializers/BaseSerializer.php index b62a256f9..a5483d678 100644 --- a/src/Api/Serializers/BaseSerializer.php +++ b/src/Api/Serializers/BaseSerializer.php @@ -4,6 +4,7 @@ use Tobscure\JsonApi\SerializerAbstract; use Flarum\Api\Events\SerializeAttributes; use Flarum\Api\Events\SerializeRelationship; use Flarum\Support\Actor; +use Illuminate\Database\Eloquent\Relations\Relation; use Closure; /** @@ -54,7 +55,7 @@ abstract class BaseSerializer extends SerializerAbstract $data = $relation($model, $include); } else { if ($include) { - $data = !is_null($model->$relation) ? $model->$relation : $model->$relation()->getResults(); + $data = $model->getRelation($relation); } elseif ($many) { $relationIds = $relation.'_ids'; $data = $model->$relationIds ?: $model->$relation()->get(['id'])->fetch('id')->all(); diff --git a/src/Api/Serializers/DiscussionBasicSerializer.php b/src/Api/Serializers/DiscussionBasicSerializer.php index b1c09ed4b..d9f69eedd 100644 --- a/src/Api/Serializers/DiscussionBasicSerializer.php +++ b/src/Api/Serializers/DiscussionBasicSerializer.php @@ -2,6 +2,8 @@ class DiscussionBasicSerializer extends BaseSerializer { + protected static $relationships = []; + /** * The resource type. * diff --git a/src/Api/Serializers/ForumSerializer.php b/src/Api/Serializers/ForumSerializer.php new file mode 100644 index 000000000..56143f844 --- /dev/null +++ b/src/Api/Serializers/ForumSerializer.php @@ -0,0 +1,31 @@ + $forum->title + ]; + + return $this->extendAttributes($forum, $attributes); + } +} diff --git a/src/Api/Serializers/PostBasicSerializer.php b/src/Api/Serializers/PostBasicSerializer.php index 25aa96518..f8cfaae0b 100644 --- a/src/Api/Serializers/PostBasicSerializer.php +++ b/src/Api/Serializers/PostBasicSerializer.php @@ -2,6 +2,8 @@ class PostBasicSerializer extends BaseSerializer { + protected static $relationships = []; + /** * The resource type. * diff --git a/src/Assets/AssetManager.php b/src/Assets/AssetManager.php new file mode 100644 index 000000000..1e6df4fc6 --- /dev/null +++ b/src/Assets/AssetManager.php @@ -0,0 +1,60 @@ +js = $js; + $this->less = $less; + } + + public function addFile($file) + { + $ext = pathinfo($file, PATHINFO_EXTENSION); + + switch ($ext) { + case 'js': + $this->js->addFile($file); + break; + + case 'css': + case 'less': + $this->less->addFile($file); + break; + + default: + throw new RuntimeException('Unsupported asset type: '.$ext); + } + } + + public function addFiles(array $files) + { + array_walk($files, [$this, 'addFile']); + } + + public function addLess($string) + { + $this->less->addString($string); + } + + public function addJs($strings) + { + $this->js->addString($string); + } + + public function getCssFile() + { + return $this->less->getFile(); + } + + public function getJsFile() + { + return $this->js->getFile(); + } +} diff --git a/src/Assets/CompilerInterface.php b/src/Assets/CompilerInterface.php new file mode 100644 index 000000000..a5946dde0 --- /dev/null +++ b/src/Assets/CompilerInterface.php @@ -0,0 +1,10 @@ + true, + 'cache_dir' => storage_path().'/less' + ]); + + foreach ($this->files as $file) { + $parser->parseFile($file); + } + + foreach ($this->strings as $string) { + $parser->parse($string); + } + + return $parser->getCss(); + } +} diff --git a/src/Assets/RevisionCompiler.php b/src/Assets/RevisionCompiler.php new file mode 100644 index 000000000..171732630 --- /dev/null +++ b/src/Assets/RevisionCompiler.php @@ -0,0 +1,95 @@ +path = $path; + $this->filename = $filename; + } + + public function addFile($file) + { + $this->files[] = $file; + } + + public function addString($string) + { + $this->strings[] = $string; + } + + public function getFile() + { + if (! ($revision = $this->getRevision())) { + $revision = Str::quickRandom(); + $this->putRevision($revision); + } + + $lastModTime = 0; + foreach ($this->files as $file) { + $lastModTime = max($lastModTime, filemtime($file)); + } + + $ext = pathinfo($this->filename, PATHINFO_EXTENSION); + $file = $this->path.'/'.substr_replace($this->filename, '-'.$revision, -strlen($ext) - 1, 0); + + if (! file_exists($file) + || filemtime($file) < $lastModTime) { + file_put_contents($file, $this->compile()); + } + + return $file; + } + + protected function format($string) + { + return $string; + } + + protected function compile() + { + $output = ''; + + foreach ($this->files as $file) { + $output .= $this->format(file_get_contents($file)); + } + + foreach ($this->strings as $string) { + $output .= $this->format($string); + } + + return $output; + } + + protected function getRevisionFile() + { + return $this->path.'/rev-manifest.json'; + } + + protected function getRevision() + { + if (file_exists($file = $this->getRevisionFile())) { + $manifest = json_decode(file_get_contents($file), true); + return array_get($manifest, $this->filename); + } + } + + protected function putRevision($revision) + { + if (file_exists($file = $this->getRevisionFile())) { + $manifest = json_decode(file_get_contents($file), true); + } else { + $manifest = []; + } + + $manifest[$this->filename] = $revision; + + return file_put_contents($this->getRevisionFile(), json_encode($manifest)); + } +} diff --git a/src/Console/ConsoleServiceProvider.php b/src/Console/ConsoleServiceProvider.php index 4d0eafb41..00111c96b 100644 --- a/src/Console/ConsoleServiceProvider.php +++ b/src/Console/ConsoleServiceProvider.php @@ -13,6 +13,7 @@ class ConsoleServiceProvider extends ServiceProvider { $this->commands('Flarum\Console\InstallCommand'); $this->commands('Flarum\Console\SeedCommand'); + $this->commands('Flarum\Console\GenerateExtensionCommand'); } public function register() diff --git a/src/Console/GenerateExtensionCommand.php b/src/Console/GenerateExtensionCommand.php new file mode 100644 index 000000000..f07da4b4c --- /dev/null +++ b/src/Console/GenerateExtensionCommand.php @@ -0,0 +1,127 @@ +app = $app; + } + + /** + * Execute the console command. + * + * @return mixed + */ + public function fire() + { + do { + $name = $this->ask('Extension name (-):'); + } while (! preg_match('/^([a-z0-9]+)-([a-z0-9-]+)$/i', $name, $match)); + + list(, $vendor, $package) = $match; + + do { + $title = $this->ask('Title:'); + } while (! $title); + + $description = $this->ask('Description:'); + + $authorName = $this->ask('Author name:'); + + $authorEmail = $this->ask('Author email:'); + + $license = $this->ask('License:'); + + $this->info('Generating extension skeleton for "'.$name.'"...'); + + $dir = public_path().'/extensions/'.$name; + + $replacements = [ + '{{namespace}}' => ucfirst($vendor).'\\'.ucfirst($package), + '{{escapedNamespace}}' => ucfirst($vendor).'\\\\'.ucfirst($package), + '{{classPrefix}}' => ucfirst($package), + '{{name}}' => $name + ]; + + $this->copyStub($dir, $replacements); + + rename($dir.'/src/ServiceProvider.php', $dir.'/src/'.ucfirst($package).'ServiceProvider.php'); + + $manifest = [ + 'name' => $name, + 'title' => $title, + 'description' => $description, + 'tags' => [], + 'version' => '0.1.0', + 'author' => [ + 'name' => $authorName, + 'email' => $authorEmail + ], + 'license' => $license, + 'require' => [ + 'php' => '>=5.4.0', + 'flarum' => '>0.1.0' + ] + ]; + + file_put_contents($dir.'/flarum.json', json_encode($manifest, JSON_PRETTY_PRINT)); + + passthru("cd $dir; composer install; cd js; npm install; gulp"); + + $this->info('Extension "'.$name.'" generated!'); + } + + protected function copyStub($destination, $replacements = []) + { + $this->recursiveCopy(__DIR__.'/../../stubs/extension', $destination, $replacements); + } + + protected function recursiveCopy($src, $dst, $replacements = []) + { + $dir = opendir($src); + @mkdir($dst); + + while (($file = readdir($dir)) !== false) { + if ($file != '.' && $file != '..') { + if (is_dir($src.'/'.$file)) { + $this->recursiveCopy($src.'/'.$file, $dst.'/'.$file, $replacements); + } + else { + $contents = file_get_contents($src.'/'.$file); + $contents = str_replace(array_keys($replacements), array_values($replacements), $contents); + + file_put_contents($dst.'/'.$file, $contents); + } + } + } + + closedir($dir); + } +} diff --git a/src/Core/CoreServiceProvider.php b/src/Core/CoreServiceProvider.php index b7e2dba16..e5a266362 100644 --- a/src/Core/CoreServiceProvider.php +++ b/src/Core/CoreServiceProvider.php @@ -16,6 +16,7 @@ use Flarum\Core\Events\RegisterUserGambits; use Flarum\Extend\Permission; use Flarum\Extend\ActivityType; use Flarum\Extend\NotificationType; +use Flarum\Extend\Locale; class CoreServiceProvider extends ServiceProvider { @@ -54,6 +55,17 @@ class CoreServiceProvider extends ServiceProvider (new ActivityType('Flarum\Core\Activity\StartedDiscussionActivity', 'Flarum\Api\Serializers\PostBasicSerializer')), (new ActivityType('Flarum\Core\Activity\JoinedActivity', 'Flarum\Api\Serializers\UserBasicSerializer')) ); + + foreach (['en'] as $locale) { + $dir = __DIR__.'/../../locale/'.$locale; + + $this->extend( + (new Locale($locale)) + ->translations($dir.'/translations.yml') + ->config($dir.'/config.php') + ->js($dir.'/config.js') + ); + } } /** @@ -70,6 +82,8 @@ class CoreServiceProvider extends ServiceProvider $this->app->singleton('flarum.formatter', 'Flarum\Core\Formatter\FormatterManager'); + $this->app->singleton('flarum.localeManager', 'Flarum\Locale\LocaleManager'); + $this->app->bind( 'Flarum\Core\Repositories\DiscussionRepositoryInterface', 'Flarum\Core\Repositories\EloquentDiscussionRepository' @@ -169,71 +183,78 @@ class CoreServiceProvider extends ServiceProvider $this->extend( new Permission('forum.view'), new Permission('forum.startDiscussion'), - new Permission('discussion.rename'), - new Permission('discussion.delete'), new Permission('discussion.reply'), - new Permission('post.edit'), - new Permission('post.delete') + new Permission('discussion.editPosts'), + new Permission('discussion.deletePosts'), + new Permission('discussion.rename'), + new Permission('discussion.delete') ); - Forum::grantPermission(function ($grant, $user, $permission) { - return $user->hasPermission('forum.'.$permission); + Forum::allow('*', function ($forum, $user, $action) { + if ($user->hasPermission('forum.'.$action)) { + return true; + } }); - Post::grantPermission(function ($grant, $user, $permission) { - return $user->hasPermission('post'.$permission); + Post::allow('*', function ($post, $user, $action) { + if ($user->hasPermission('post.'.$action)) { + return true; + } }); - // Grant view access to a post only if the user can also view the - // discussion which the post is in. Also, the if the post is hidden, - // the user must have edit permissions too. - Post::grantPermission('view', function ($grant) { - $grant->whereCan('view', 'discussion'); - }); - - Post::demandPermission('view', function ($demand) { - $demand->whereNull('hide_user_id') - ->orWhereCan('edit'); - }); - - // Allow a user to edit their own post, unless it has been hidden by - // someone else. - Post::grantPermission('edit', function ($grant, $user) { - $grant->where('user_id', $user->id) - ->where(function ($query) use ($user) { + // When fetching a discussion's posts: if the user doesn't have permission + // to moderate the discussion, then they can't see posts that have been + // hidden by someone other than themself. + Discussion::scopeVisiblePosts(function ($query, User $user, Discussion $discussion) { + if (! $discussion->can($user, 'editPosts')) { + $query->where(function ($query) use ($user) { $query->whereNull('hide_user_id') ->orWhere('hide_user_id', $user->id); - }); - // @todo add limitations to time etc. according to a config setting + }); + } }); - User::grantPermission(function ($grant, $user, $permission) { - return $user->hasPermission('user.'.$permission); + Post::allow('view', function ($post, $user) { + if (! $post->hide_user_id || $post->can($user, 'edit')) { + return true; + } }); - // Grant view access to a user if the user can view the forum. - User::grantPermission('view', function ($grant, $user) { - $grant->whereCan('view', 'forum'); + // A post is allowed to be edited if the user has permission to moderate + // the discussion which it's in, or if they are the author and the post + // hasn't been deleted by someone else. + Post::allow('edit', function ($post, $user) { + if ($post->discussion->can($user, 'editPosts') || + ($post->user_id == $user->id && (! $post->hide_user_id || $post->hide_user_id == $user->id)) + ) { + return true; + } }); - // Allow a user to edit their own account. - User::grantPermission(['edit', 'delete'], function ($grant, $user) { - $grant->where('id', $user->id); + User::allow('*', function ($discussion, $user, $action) { + if ($user->hasPermission('user.'.$action)) { + return true; + } }); - Discussion::grantPermission(function ($grant, $user, $permission) { - return $user->hasPermission('discussion.'.$permission); + User::allow(['edit', 'delete'], function ($user, $actor) { + if ($user->id == $actor->id) { + return true; + } }); - // Grant view access to a discussion if the user can view the forum. - Discussion::grantPermission('view', function ($grant, $user) { - $grant->whereCan('view', 'forum'); + Discussion::allow('*', function ($discussion, $user, $action) { + if ($user->hasPermission('discussion.'.$action)) { + return true; + } }); // Allow a user to rename their own discussion. - Discussion::grantPermission('rename', function ($grant, $user) { - $grant->where('start_user_id', $user->id); - // @todo add limitations to time etc. according to a config setting + Discussion::allow('rename', function ($discussion, $user) { + if ($discussion->start_user_id == $user->id) { + return true; + // @todo add limitations to time etc. according to a config setting + } }); } } diff --git a/src/Core/Formatter/FormatterManager.php b/src/Core/Formatter/FormatterManager.php index f52ca405e..4e085b432 100644 --- a/src/Core/Formatter/FormatterManager.php +++ b/src/Core/Formatter/FormatterManager.php @@ -15,6 +15,8 @@ class FormatterManager */ protected $container; + public $config; + /** * Create a new formatter manager instance. * @@ -23,6 +25,17 @@ class FormatterManager public function __construct(Container $container) { $this->container = $container; + + // Studio does not yet merge autoload_files... + // https://github.com/franzliedke/studio/commit/4f0f4314db4ed3e36c869a5f79b855c97bdd1be7 + require __DIR__.'/../../../vendor/ezyang/htmlpurifier/library/HTMLPurifier.composer.php'; + + $this->config = HTMLPurifier_Config::createDefault(); + $this->config->set('Core.Encoding', 'UTF-8'); + $this->config->set('Core.EscapeInvalidTags', true); + $this->config->set('HTML.Doctype', 'HTML 4.01 Strict'); + $this->config->set('HTML.Allowed', 'p,em,strong,a[href|title],ul,ol,li,code,pre,blockquote,h1,h2,h3,h4,h5,h6,br,hr,img[src|alt]'); + $this->config->set('HTML.Nofollow', true); } public function add($name, $formatter, $priority = 0) @@ -66,18 +79,7 @@ class FormatterManager $text = $formatter->beforePurification($text, $post); } - // Studio does not yet merge autoload_files... - // https://github.com/franzliedke/studio/commit/4f0f4314db4ed3e36c869a5f79b855c97bdd1be7 - require __DIR__.'/../../../vendor/ezyang/htmlpurifier/library/HTMLPurifier.composer.php'; - - $config = HTMLPurifier_Config::createDefault(); - $config->set('Core.Encoding', 'UTF-8'); - $config->set('Core.EscapeInvalidTags', true); - $config->set('HTML.Doctype', 'HTML 4.01 Strict'); - $config->set('HTML.Allowed', 'p,em,strong,a[href|title],ul,ol,li,code,pre,blockquote,h1,h2,h3,h4,h5,h6,br,hr'); - $config->set('HTML.Nofollow', true); - - $purifier = new HTMLPurifier($config); + $purifier = new HTMLPurifier($this->config); $text = $purifier->purify($text); diff --git a/src/Core/Handlers/Commands/EditDiscussionCommandHandler.php b/src/Core/Handlers/Commands/EditDiscussionCommandHandler.php index 49c1b8ec0..27f98f1ce 100644 --- a/src/Core/Handlers/Commands/EditDiscussionCommandHandler.php +++ b/src/Core/Handlers/Commands/EditDiscussionCommandHandler.php @@ -20,9 +20,8 @@ class EditDiscussionCommandHandler $user = $command->user; $discussion = $this->discussions->findOrFail($command->discussionId, $user); - $discussion->assertCan($user, 'edit'); - if (isset($command->data['title'])) { + $discussion->assertCan($user, 'rename'); $discussion->rename($command->data['title'], $user); } diff --git a/src/Core/Models/Discussion.php b/src/Core/Models/Discussion.php index eb3c72116..7718644ec 100755 --- a/src/Core/Models/Discussion.php +++ b/src/Core/Models/Discussion.php @@ -1,7 +1,8 @@ 'integer' ]; + protected static $relationships = []; + /** * The table associated with the model. * @@ -213,6 +217,24 @@ class Discussion extends Model return $this->hasMany('Flarum\Core\Models\Post'); } + protected static $visiblePostsScopes = []; + + public static function scopeVisiblePosts($scope) + { + static::$visiblePostsScopes[] = $scope; + } + + public function visiblePosts(User $user) + { + $query = $this->posts(); + + foreach (static::$visiblePostsScopes as $scope) { + $scope($query, $user, $this); + } + + return $query; + } + /** * Define the relationship with the discussion's comments. * @@ -295,9 +317,8 @@ class Discussion extends Model */ public function stateFor(User $user) { - $loadedState = array_get($this->relations, 'state'); - if ($loadedState && $loadedState->user_id === $user->id) { - return $loadedState; + if ($this->isRelationLoaded('state')) { + return $this->relations['state']; } $state = $this->state($user)->first(); diff --git a/src/Core/Models/Forum.php b/src/Core/Models/Forum.php index 7996ed5fa..5980317f1 100755 --- a/src/Core/Models/Forum.php +++ b/src/Core/Models/Forum.php @@ -1,11 +1,13 @@ can($user, 'view')) { - throw new ModelNotFoundException; - } + return array_key_exists($relation, $this->relations); } - /** - * Assert that the user has a certain permission for this model, throwing - * an exception if they don't. - * - * @param \Flarum\Core\Models\User $user - * @param string $permission - * @return void - * - * @throws \Flarum\Core\Exceptions\PermissionDeniedException - */ - public function assertCan(User $user, $permission) + public function getRelation($relation) { - if (! $this->can($user, $permission)) { - throw new PermissionDeniedException; + if (isset($this->$relation)) { + return $this->$relation; } + + if (! $this->isRelationLoaded($relation)) { + $this->relations[$relation] = $this->$relation()->getResults(); + } + + return $this->relations[$relation]; } /** diff --git a/src/Core/Models/Post.php b/src/Core/Models/Post.php index 5fb1ade7f..baaef5045 100755 --- a/src/Core/Models/Post.php +++ b/src/Core/Models/Post.php @@ -1,11 +1,11 @@ bio = $bio; + $this->bio_html = null; $this->raise(new UserBioWasChanged($this)); @@ -199,7 +202,7 @@ class User extends Model */ public function getBioHtmlAttribute($value) { - if (! $value) { + if ($value === null) { $this->bio_html = $value = static::formatBio($this->bio); $this->save(); } @@ -309,9 +312,13 @@ class User extends Model return true; } - $count = $this->permissions()->where('permission', $permission)->count(); + static $permissions; - return (bool) $count; + if (!$permissions) { + $permissions = $this->permissions()->get(); + } + + return (bool) $permissions->contains('permission', $permission); } public function getUnreadNotificationsCount() diff --git a/src/Core/Repositories/EloquentDiscussionRepository.php b/src/Core/Repositories/EloquentDiscussionRepository.php index 3f82fb068..25430e57b 100644 --- a/src/Core/Repositories/EloquentDiscussionRepository.php +++ b/src/Core/Repositories/EloquentDiscussionRepository.php @@ -30,7 +30,7 @@ class EloquentDiscussionRepository implements DiscussionRepositoryInterface { $query = Discussion::where('id', $id); - return $this->scopeVisibleForUser($query, $user)->firstOrFail(); + return $this->scopeVisibleTo($query, $user)->firstOrFail(); } /** @@ -54,10 +54,10 @@ class EloquentDiscussionRepository implements DiscussionRepositoryInterface * @param \Flarum\Core\Models\User $user * @return \Illuminate\Database\Eloquent\Builder */ - protected function scopeVisibleForUser(Builder $query, User $user = null) + protected function scopeVisibleTo(Builder $query, User $user = null) { if ($user !== null) { - $query->whereCan($user, 'view'); + $query->whereVisibleTo($user); } return $query; diff --git a/src/Core/Repositories/EloquentPostRepository.php b/src/Core/Repositories/EloquentPostRepository.php index b08d12ffb..393b668d7 100644 --- a/src/Core/Repositories/EloquentPostRepository.php +++ b/src/Core/Repositories/EloquentPostRepository.php @@ -1,10 +1,32 @@ findByIds([$id], $user); - return $this->scopeVisibleForUser($query, $user)->firstOrFail(); + if (! count($posts)) { + throw new ModelNotFoundException; + } + + return $posts->first(); } /** @@ -52,7 +78,9 @@ class EloquentPostRepository implements PostRepositoryInterface $query->orderBy($field, $order); } - return $this->scopeVisibleForUser($query, $user)->get(); + $ids = $query->lists('id'); + + return $this->findByIds($ids, $user); } /** @@ -65,9 +93,11 @@ class EloquentPostRepository implements PostRepositoryInterface */ public function findByIds(array $ids, User $user = null) { - $query = Post::whereIn('id', (array) $ids); + $ids = $this->filterDiscussionVisibleTo($ids, $user); - return $this->scopeVisibleForUser($query, $user)->get(); + $posts = Post::with('discussion')->whereIn('id', (array) $ids)->get(); + + return $this->filterVisibleTo($posts, $user); } /** @@ -82,13 +112,17 @@ class EloquentPostRepository implements PostRepositoryInterface { $ids = $this->fulltext->match($string); + $ids = $this->filterDiscussionVisibleTo($ids, $user); + $query = Post::select('id', 'discussion_id')->whereIn('id', $ids); foreach ($ids as $id) { $query->orderByRaw('id != ?', [$id]); } - return $this->scopeVisibleForUser($query, $user)->get(); + $posts = $query->get(); + + return $this->filterVisibleTo($posts, $user); } /** @@ -103,7 +137,8 @@ class EloquentPostRepository implements PostRepositoryInterface */ public function getIndexForNumber($discussionId, $number, User $user = null) { - $query = Post::where('discussion_id', $discussionId) + $query = Discussion::find($discussionId) + ->visiblePosts($user) ->where('time', '<', function ($query) use ($discussionId, $number) { $query->select('time') ->from('posts') @@ -116,22 +151,32 @@ class EloquentPostRepository implements PostRepositoryInterface ->orderByRaw('ABS(CAST(number AS SIGNED) - '.(int) $number.')'); }); - return $this->scopeVisibleForUser($query, $user)->count(); + return $query->count(); } - /** - * Scope a query to only include records that are visible to a user. - * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Flarum\Core\Models\User $user - * @return \Illuminate\Database\Eloquent\Builder - */ - protected function scopeVisibleForUser(Builder $query, User $user = null) + protected function filterDiscussionVisibleTo($ids, $user) { - if ($user !== null) { - $query->whereCan($user, 'view'); + // For each post ID, we need to make sure that the discussion it's in + // is visible to the user. + if ($user) { + $ids = Discussion::join('posts', 'discussions.id', '=', 'posts.discussion_id') + ->whereIn('posts.id', $ids) + ->whereVisibleTo($user) + ->get(['posts.id']) + ->lists('id'); } - return $query; + return $ids; + } + + protected function filterVisibleTo($posts, $user) + { + if ($user) { + $posts = $posts->filter(function ($post) use ($user) { + return $post->can($user, 'view'); + }); + } + + return $posts; } } diff --git a/src/Core/Repositories/EloquentUserRepository.php b/src/Core/Repositories/EloquentUserRepository.php index ac584950f..89a143083 100644 --- a/src/Core/Repositories/EloquentUserRepository.php +++ b/src/Core/Repositories/EloquentUserRepository.php @@ -29,7 +29,7 @@ class EloquentUserRepository implements UserRepositoryInterface { $query = User::where('id', $id); - return $this->scopeVisibleForUser($query, $user)->firstOrFail(); + return $this->scopeVisibleTo($query, $user)->firstOrFail(); } /** @@ -67,7 +67,7 @@ class EloquentUserRepository implements UserRepositoryInterface { $query = User::where('username', 'like', $username); - return $this->scopeVisibleForUser($query, $user)->pluck('id'); + return $this->scopeVisibleTo($query, $user)->pluck('id'); } /** @@ -85,7 +85,7 @@ class EloquentUserRepository implements UserRepositoryInterface ->orderByRaw('username = ? desc', [$string]) ->orderByRaw('username like ? desc', [$string.'%']); - return $this->scopeVisibleForUser($query, $user)->lists('id'); + return $this->scopeVisibleTo($query, $user)->lists('id'); } /** @@ -95,10 +95,10 @@ class EloquentUserRepository implements UserRepositoryInterface * @param \Flarum\Core\Models\User $user * @return \Illuminate\Database\Eloquent\Builder */ - protected function scopeVisibleForUser(Builder $query, User $user = null) + protected function scopeVisibleTo(Builder $query, User $user = null) { if ($user !== null) { - $query->whereCan($user, 'view'); + $query->whereVisibleTo($user); } return $query; diff --git a/src/Core/Search/Discussions/DiscussionSearchResults.php b/src/Core/Search/Discussions/DiscussionSearchResults.php index eb974f773..58219cde2 100644 --- a/src/Core/Search/Discussions/DiscussionSearchResults.php +++ b/src/Core/Search/Discussions/DiscussionSearchResults.php @@ -6,13 +6,10 @@ class DiscussionSearchResults protected $areMoreResults; - protected $total; - - public function __construct($discussions, $areMoreResults, $total) + public function __construct($discussions, $areMoreResults) { $this->discussions = $discussions; $this->areMoreResults = $areMoreResults; - $this->total = $total; } public function getDiscussions() @@ -20,11 +17,6 @@ class DiscussionSearchResults return $this->discussions; } - public function getTotal() - { - return $this->total; - } - public function areMoreResults() { return $this->areMoreResults; diff --git a/src/Core/Search/Discussions/DiscussionSearcher.php b/src/Core/Search/Discussions/DiscussionSearcher.php index 742ba664c..81e1fbfd5 100644 --- a/src/Core/Search/Discussions/DiscussionSearcher.php +++ b/src/Core/Search/Discussions/DiscussionSearcher.php @@ -59,12 +59,10 @@ class DiscussionSearcher implements SearcherInterface public function search(DiscussionSearchCriteria $criteria, $limit = null, $offset = 0, $load = []) { $this->user = $criteria->user; - $this->query = $this->discussions->query()->whereCan($criteria->user, 'view'); + $this->query = $this->discussions->query()->whereVisibleTo($criteria->user); $this->gambits->apply($criteria->query, $this); - $total = $this->query->count(); - $sort = $criteria->sort ?: $this->defaultSort; foreach ($sort as $field => $order) { @@ -112,6 +110,6 @@ class DiscussionSearcher implements SearcherInterface Discussion::setStateUser($this->user); $discussions->load($load); - return new DiscussionSearchResults($discussions, $areMoreResults, $total); + return new DiscussionSearchResults($discussions, $areMoreResults); } } diff --git a/src/Core/Seeders/ConfigTableSeeder.php b/src/Core/Seeders/ConfigTableSeeder.php index f692379dc..2e2017b86 100644 --- a/src/Core/Seeders/ConfigTableSeeder.php +++ b/src/Core/Seeders/ConfigTableSeeder.php @@ -19,6 +19,7 @@ class ConfigTableSeeder extends Seeder 'welcome_message' => 'Flarum is now at a point where you can have basic conversations, so here is a little demo for you to break.', 'welcome_title' => 'Welcome to Flarum Demo Forum', 'extensions_enabled' => '[]', + 'locale' => 'en', 'theme_primary_color' => '#536F90', 'theme_secondary_color' => '#536F90', 'theme_dark_mode' => false, diff --git a/src/Core/Seeders/PermissionsTableSeeder.php b/src/Core/Seeders/PermissionsTableSeeder.php index ae48890d2..85292636c 100644 --- a/src/Core/Seeders/PermissionsTableSeeder.php +++ b/src/Core/Seeders/PermissionsTableSeeder.php @@ -27,8 +27,8 @@ class PermissionsTableSeeder extends Seeder // Moderators can edit + delete stuff and suspend users [4, 'discussion.delete'], [4, 'discussion.rename'], - [4, 'post.delete'], - [4, 'post.edit'], + [4, 'discussion.editPosts'], + [4, 'discussion.deletePosts'], [4, 'user.suspend'], ]; diff --git a/src/Core/Support/Locked.php b/src/Core/Support/Locked.php new file mode 100644 index 000000000..4d0cbd8a9 --- /dev/null +++ b/src/Core/Support/Locked.php @@ -0,0 +1,57 @@ +getConditions($action) as $condition) { + $can = $condition($this, $user, $action); + + if ($can !== null) { + return $can; + } + } + } + + /** + * Assert that the user has a certain permission for this model, throwing + * an exception if they don't. + * + * @param \Flarum\Core\Models\User $user + * @param string $permission + * @return void + * + * @throws \Flarum\Core\Exceptions\PermissionDeniedException + */ + public function assertCan(User $user, $action) + { + if (! $this->can($user, $action)) { + throw new PermissionDeniedException; + } + } +} diff --git a/src/Core/Support/VisibleScope.php b/src/Core/Support/VisibleScope.php new file mode 100644 index 000000000..cbe1a5b21 --- /dev/null +++ b/src/Core/Support/VisibleScope.php @@ -0,0 +1,20 @@ +keys = $keys; + } + + public function extend(Container $container) + { + + } +} diff --git a/src/Extend/ApiLink.php b/src/Extend/ApiLink.php new file mode 100644 index 000000000..6450ad58e --- /dev/null +++ b/src/Extend/ApiLink.php @@ -0,0 +1,28 @@ +actions = $actions; + $this->relationships = $relationships; + } + + public function extend(Application $app) + { + foreach ((array) $this->actions as $action) { + $parts = explode('.', $action); + $class = 'Flarum\Api\Actions\\'.ucfirst($parts[0]).'\\'.ucfirst($parts[1]).'Action'; + + foreach ((array) $this->relationships as $relationship) { + $class::$link[] = $relationship; + } + } + } +} diff --git a/src/Extend/ForumAssets.php b/src/Extend/ForumAssets.php index f1cad8fde..1294d94fd 100644 --- a/src/Extend/ForumAssets.php +++ b/src/Extend/ForumAssets.php @@ -14,7 +14,7 @@ class ForumAssets implements ExtenderInterface public function extend(Container $container) { $container->make('events')->listen('Flarum\Forum\Events\RenderView', function ($event) { - $event->assets->addFile($this->files); + $event->assets->addFiles($this->files); }); } } diff --git a/src/Extend/ForumTranslations.php b/src/Extend/ForumTranslations.php new file mode 100644 index 000000000..1e56c2a14 --- /dev/null +++ b/src/Extend/ForumTranslations.php @@ -0,0 +1,19 @@ +keys = $keys; + } + + public function extend(Container $container) + { + IndexAction::$translations = array_merge(IndexAction::$translations, $this->keys); + } +} diff --git a/src/Extend/Locale.php b/src/Extend/Locale.php new file mode 100644 index 000000000..6178db770 --- /dev/null +++ b/src/Extend/Locale.php @@ -0,0 +1,57 @@ +locale = $locale; + } + + public function translations($translations) + { + $this->translations = $translations; + + return $this; + } + + public function config($config) + { + $this->config = $config; + + return $this; + } + + public function js($js) + { + $this->js = $js; + + return $this; + } + + public function extend(Container $container) + { + $manager = $container->make('flarum.localeManager'); + + if ($this->translations) { + $manager->addTranslations($this->locale, $this->translations); + } + + if ($this->config) { + $manager->addConfig($this->locale, $this->config); + } + + if ($this->js) { + $manager->addJsFile($this->locale, $this->js); + } + } +} diff --git a/src/Extend/Relationship.php b/src/Extend/Relationship.php index 88d99e355..a5e817f82 100644 --- a/src/Extend/Relationship.php +++ b/src/Extend/Relationship.php @@ -7,17 +7,19 @@ class Relationship implements ExtenderInterface { protected $parent; - protected $type; - protected $name; + protected $type; + protected $child; - public function __construct($parent, $type, $name, $child = null) + protected $table; + + public function __construct($parent, $name, $type, $child = null) { $this->parent = $parent; - $this->type = $type; $this->name = $name; + $this->type = $type; $this->child = $child; } @@ -30,6 +32,8 @@ class Relationship implements ExtenderInterface return call_user_func($this->type, $model); } elseif ($this->type === 'belongsTo') { return $model->belongsTo($this->child, null, null, $this->name); + } elseif ($this->type === 'belongsToMany') { + return $model->belongsToMany($this->child, $this->table, null, null, $this->name); } else { // @todo } diff --git a/src/Forum/Actions/IndexAction.php b/src/Forum/Actions/IndexAction.php index 672061c64..215ce902f 100644 --- a/src/Forum/Actions/IndexAction.php +++ b/src/Forum/Actions/IndexAction.php @@ -1,10 +1,14 @@ session->get('alert'); + $response = $this->apiClient->send('Flarum\Api\Actions\Forum\ShowAction'); + + $data = [$response->data]; + if (isset($response->included)) { + $data = array_merge($data, $response->included); + } + if (($user = $this->actor->getUser()) && $user->exists) { $session = [ 'userId' => $user->id, 'token' => $request->getCookieParams()['flarum_remember'], ]; + // TODO: calling on the API here results in an extra query to get + // the user + their groups, when we already have this information on + // $this->actor. Can we simply run the CurrentUserSerializer + // manually? $response = $this->apiClient->send('Flarum\Api\Actions\Users\ShowAction', ['id' => $user->id]); $data = [$response->data]; @@ -57,23 +74,63 @@ class IndexAction extends HtmlAction ->with('session', $session) ->with('alert', $alert); - $assetManager = app('flarum.forum.assetManager'); $root = __DIR__.'/../../..'; - $assetManager->addFile([ - $root.'/js/forum/dist/app.js', - $root.'/less/forum/app.less' - ]); - $assetManager->addLess(' - @fl-primary-color: '.Core::config('theme_primary_color').'; - @fl-secondary-color: '.Core::config('theme_secondary_color').'; - @fl-dark-mode: '.(Core::config('theme_dark_mode') ? 'true' : 'false').'; - @fl-colored_header: '.(Core::config('theme_colored_header') ? 'true' : 'false').'; - '); + $public = public_path().'/assets'; - event(new RenderView($view, $assetManager, $this)); + $assets = new AssetManager( + new JsCompiler($public, 'forum.js'), + new LessCompiler($public, 'forum.css') + ); + + $assets->addFile($root.'/js/forum/dist/app.js'); + $assets->addFile($root.'/less/forum/app.less'); + + $variables = [ + 'fl-primary-color' => Core::config('theme_primary_color', '#000'), + 'fl-secondary-color' => Core::config('theme_secondary_color', '#000'), + 'fl-dark-mode' => Core::config('theme_dark_mode') ? 'true' : 'false', + 'fl-colored-header' => Core::config('theme_colored_header') ? 'true' : 'false' + ]; + foreach ($variables as $name => $value) { + $assets->addLess("@$name: $value;"); + } + + $locale = $user->locale ?: Core::config('locale', 'en'); + + $localeManager = app('flarum.localeManager'); + $translations = $localeManager->getTranslations($locale); + $jsFiles = $localeManager->getJsFiles($locale); + + $localeCompiler = new LocaleJsCompiler($public, 'locale-'.$locale.'.js'); + $localeCompiler->setTranslations(static::filterTranslations($translations)); + array_walk($jsFiles, [$localeCompiler, 'addFile']); + + event(new RenderView($view, $assets, $this)); return $view - ->with('styles', $assetManager->getCSSFiles()) - ->with('scripts', $assetManager->getJSFiles()); + ->with('styles', [$assets->getCssFile()]) + ->with('scripts', [$assets->getJsFile(), $localeCompiler->getFile()]); + } + + protected static function filterTranslations($translations) + { + $filtered = []; + + foreach (static::$translations as $key) { + $parts = explode('.', $key); + $level = &$filtered; + + foreach ($parts as $part) { + if (! isset($level[$part])) { + $level[$part] = []; + } + + $level = &$level[$part]; + } + + $level = array_get($translations, $key); + } + + return $filtered; } } diff --git a/src/Forum/ForumServiceProvider.php b/src/Forum/ForumServiceProvider.php index 5016bf7f1..f6e64a1e5 100644 --- a/src/Forum/ForumServiceProvider.php +++ b/src/Forum/ForumServiceProvider.php @@ -1,9 +1,10 @@ routes(); + + $this->extend( + new ForumTranslations([ + // + ]) + ); } protected function routes() diff --git a/src/Locale/JsCompiler.php b/src/Locale/JsCompiler.php new file mode 100644 index 000000000..de2d82b3c --- /dev/null +++ b/src/Locale/JsCompiler.php @@ -0,0 +1,24 @@ +translations = $translations; + } + + public function compile() + { + $output = "var app = require('flarum/app')['default']; app.translator.translations = ".json_encode($this->translations).";"; + + foreach ($this->files as $filename) { + $output .= file_get_contents($filename); + } + + return $output; + } +} diff --git a/src/Locale/LocaleManager.php b/src/Locale/LocaleManager.php new file mode 100644 index 000000000..bc9e600e8 --- /dev/null +++ b/src/Locale/LocaleManager.php @@ -0,0 +1,65 @@ +translations[$locale])) { + $this->translations[$locale] = []; + } + + $this->translations[$locale][] = $translations; + } + + public function addJsFile($locale, $js) + { + if (! isset($this->js[$locale])) { + $this->js[$locale] = []; + } + + $this->js[$locale][] = $js; + } + + public function addConfig($locale, $config) + { + if (! isset($this->config[$locale])) { + $this->config[$locale] = []; + } + + $this->config[$locale][] = $config; + } + + public function getTranslations($locale) + { + $files = array_get($this->translations, $locale, []); + + $parts = explode('-', $locale); + + if (count($parts) > 1) { + $files = array_merge(array_get($this->translations, $parts[0], []), $files); + } + + $compiler = new TranslationCompiler($locale, $files); + + return $compiler->getTranslations(); + } + + public function getJsFiles($locale) + { + $files = array_get($this->js, $locale, []); + + $parts = explode('-', $locale); + + if (count($parts) > 1) { + $files = array_merge(array_get($this->js, $parts[0], []), $files); + } + + return $files; + } +} diff --git a/src/Locale/TranslationCompiler.php b/src/Locale/TranslationCompiler.php new file mode 100644 index 000000000..ce625007a --- /dev/null +++ b/src/Locale/TranslationCompiler.php @@ -0,0 +1,29 @@ +locale = $locale; + $this->filenames = $filenames; + } + + public function getTranslations() + { + // @todo caching + + $translations = []; + + foreach ($this->filenames as $filename) { + $translations = array_replace_recursive($translations, Yaml::parse(file_get_contents($filename))); + } + + return $translations; + } +} diff --git a/src/Locale/Translator.php b/src/Locale/Translator.php new file mode 100644 index 000000000..239df50c9 --- /dev/null +++ b/src/Locale/Translator.php @@ -0,0 +1,42 @@ +translations = $translations; + $this->plural = $plural; + } + + public function plural($count) + { + $callback = $this->plural; + + return $callback($count); + } + + public function translate($key, array $input = []) + { + $translation = array_get($this->translations, $key); + + if (is_array($translation) && isset($input['count'])) { + $translation = $translation[$this->plural($input['count'])]; + } + + if (is_string($translation)) { + foreach ($input as $k => $v) { + $translation = str_replace('{'.$k.'}', $v, $translation); + } + + return $translation; + } else { + return $key; + } + } +} diff --git a/src/Support/AssetManager.php b/src/Support/AssetManager.php deleted file mode 100644 index be50e0d6f..000000000 --- a/src/Support/AssetManager.php +++ /dev/null @@ -1,166 +0,0 @@ - [], - 'js' => [], - 'less' => [] - ]; - - protected $less = []; - - protected $publicPath; - - protected $name; - - protected $storage; - - public function __construct(Filesystem $storage, $publicPath, $name) - { - $this->storage = $storage; - $this->publicPath = $publicPath; - $this->name = $name; - } - - public function addFile($files) - { - foreach ((array) $files as $file) { - $ext = pathinfo($file, PATHINFO_EXTENSION); - $this->files[$ext][] = $file; - } - } - - public function addLess($strings) - { - foreach ((array) $strings as $string) { - $this->less[] = $string; - } - } - - protected function getAssetDirectory() - { - $dir = $this->publicPath; - if (! $this->storage->isDirectory($dir)) { - $this->storage->makeDirectory($dir); - } - return $dir; - } - - protected function getRevisionFile() - { - return $this->getAssetDirectory().'/'.$this->name; - } - - protected function getRevision() - { - if (file_exists($file = $this->getRevisionFile())) { - return file_get_contents($file); - } - } - - protected function putRevision($revision) - { - return file_put_contents($this->getRevisionFile(), $revision); - } - - protected function getFiles($type, Closure $callback) - { - $dir = $this->getAssetDirectory(); - - if (! ($revision = $this->getRevision())) { - $revision = Str::quickRandom(); - $this->putRevision($revision); - } - - $lastModTime = 0; - foreach ($this->files[$type] as $file) { - $lastModTime = max($lastModTime, filemtime($file)); - } - $debug = 0; - // $debug = 1; - - if (! file_exists($file = $dir.'/'.$this->name.'-'.$revision.'.'.$type) - || filemtime($file) < $lastModTime - || $debug) { - $this->storage->put($file, $callback()); - } - - return [$file]; - } - - public function clearCache() - { - if ($revision = $this->getRevision()) { - $dir = $this->getAssetDirectory(); - foreach (['css', 'js'] as $type) { - @unlink($dir.'/'.$this->name.'-'.$revision.'.'.$type); - } - } - } - - public function getCSSFiles() - { - return $this->getFiles('css', function () { - return $this->compileCSS(); - }); - } - - public function getJSFiles() - { - return $this->getFiles('js', function () { - return $this->compileJS(); - }); - } - - public function compileLess() - { - ini_set('xdebug.max_nesting_level', 200); - - $parser = new Less_Parser(['compress' => true, 'cache_dir' => storage_path().'/less']); - - $css = []; - $dir = $this->getAssetDirectory(); - foreach ($this->files['less'] as $file) { - $parser->parseFile($file); - } - - foreach ($this->less as $less) { - $parser->parse($less); - } - - return $parser->getCss(); - } - - public function compileCSS() - { - $css = $this->compileLess(); - - foreach ($this->files['css'] as $file) { - $css .= $this->storage->get($file); - } - - // minify - - return $css; - } - - public function compileJS() - { - $js = ''; - - foreach ($this->files['js'] as $file) { - $js .= $this->storage->get($file).';'; - } - - // minify - - return $js; - } -} diff --git a/src/Support/Extensions/ExtensionsServiceProvider.php b/src/Support/Extensions/ExtensionsServiceProvider.php index b8ad60ada..f986dc062 100644 --- a/src/Support/Extensions/ExtensionsServiceProvider.php +++ b/src/Support/Extensions/ExtensionsServiceProvider.php @@ -21,11 +21,12 @@ class ExtensionsServiceProvider extends ServiceProvider $providers = []; foreach ($extensions as $extension) { - if (file_exists($file = base_path().'/extensions/'.$extension.'/bootstrap.php')) { + if (file_exists($file = public_path().'/extensions/'.$extension.'/bootstrap.php') || + file_exists($file = base_path().'/extensions/'.$extension.'/bootstrap.php')) { $providers[$extension] = require $file; } } - // @todo store $providers somewhere so that extensions can talk to each other + // @todo store $providers somewhere (in Core?) so that extensions can talk to each other } } diff --git a/src/Support/ServiceProvider.php b/src/Support/ServiceProvider.php index e538741c2..494778a2a 100644 --- a/src/Support/ServiceProvider.php +++ b/src/Support/ServiceProvider.php @@ -17,8 +17,14 @@ class ServiceProvider extends IlluminateServiceProvider public function extend() { - foreach (func_get_args() as $extender) { - $extender->extend($this->app); + // @todo don't support func_get_args + foreach (func_get_args() as $extenders) { + if (! is_array($extenders)) { + $extenders = [$extenders]; + } + foreach ($extenders as $extender) { + $extender->extend($this->app); + } } } } diff --git a/stubs/extension/bootstrap.php b/stubs/extension/bootstrap.php new file mode 100644 index 000000000..1d29892f9 --- /dev/null +++ b/stubs/extension/bootstrap.php @@ -0,0 +1,9 @@ +app->register('{{namespace}}\{{classPrefix}}ServiceProvider'); diff --git a/stubs/extension/composer.json b/stubs/extension/composer.json new file mode 100644 index 000000000..74f608c32 --- /dev/null +++ b/stubs/extension/composer.json @@ -0,0 +1,7 @@ +{ + "autoload": { + "psr-4": { + "{{escapedNamespace}}\\": "src/" + } + } +} diff --git a/stubs/extension/js/.gitignore b/stubs/extension/js/.gitignore new file mode 100644 index 000000000..372e20a51 --- /dev/null +++ b/stubs/extension/js/.gitignore @@ -0,0 +1,3 @@ +bower_components +node_modules +dist diff --git a/stubs/extension/js/Gulpfile.js b/stubs/extension/js/Gulpfile.js new file mode 100644 index 000000000..e53f28d21 --- /dev/null +++ b/stubs/extension/js/Gulpfile.js @@ -0,0 +1,5 @@ +var gulp = require('flarum-gulp'); + +gulp({ + modulePrefix: '{{name}}' +}); diff --git a/stubs/extension/js/bootstrap.js b/stubs/extension/js/bootstrap.js new file mode 100644 index 000000000..421294ea7 --- /dev/null +++ b/stubs/extension/js/bootstrap.js @@ -0,0 +1,8 @@ +import { extend, override } from 'flarum/extension-utils'; +import app from 'flarum/app'; + +app.initializers.add('{{name}}', function() { + + // @todo + +}); diff --git a/stubs/extension/js/package.json b/stubs/extension/js/package.json new file mode 100644 index 000000000..3e0ef919d --- /dev/null +++ b/stubs/extension/js/package.json @@ -0,0 +1,7 @@ +{ + "private": true, + "devDependencies": { + "gulp": "^3.8.11", + "flarum-gulp": "git+https://github.com/flarum/gulp.git" + } +} diff --git a/stubs/extension/less/extension.less b/stubs/extension/less/extension.less new file mode 100644 index 000000000..e69de29bb diff --git a/stubs/extension/locale/en.yml b/stubs/extension/locale/en.yml new file mode 100644 index 000000000..7d28ca184 --- /dev/null +++ b/stubs/extension/locale/en.yml @@ -0,0 +1,2 @@ +{{name}}: + # hello_world: Hello, world! diff --git a/stubs/extension/src/ServiceProvider.php b/stubs/extension/src/ServiceProvider.php new file mode 100644 index 000000000..e62608409 --- /dev/null +++ b/stubs/extension/src/ServiceProvider.php @@ -0,0 +1,41 @@ +extend( + new ForumAssets([ + __DIR__.'/../js/dist/extension.js', + __DIR__.'/../less/extension.less' + ]), + + (new Locale('en'))->translations(__DIR__.'/../locale/en.yml'), + + new ForumTranslations([ + // Add the keys of translations you would like to be available + // for use by the JS client application. + ]), + ); + } + + /** + * Register the service provider. + * + * @return void + */ + public function register() + { + // + } +}