Initial refactor of client actions, data preloading, SEO

An initial stab at flarum/core#126. Still WIP. Preliminary
implementation of flarum/core#128 and flarum/core#13.
This commit is contained in:
Toby Zerner 2015-07-07 15:29:21 +09:30
parent fcc5aa17ea
commit 99876e9e36
27 changed files with 413 additions and 241 deletions

View File

@ -70,7 +70,7 @@ export default class AvatarEditor extends Component {
m.redraw();
m.request({
method: 'POST',
url: app.config['api_url']+'/users/'+user.id()+'/avatar',
url: app.forum.attribute('apiUrl')+'/users/'+user.id()+'/avatar',
data: data,
serialize: data => data,
background: true,
@ -91,7 +91,7 @@ export default class AvatarEditor extends Component {
m.redraw();
m.request({
method: 'DELETE',
url: app.config['api_url']+'/users/'+user.id()+'/avatar',
url: app.forum.attribute('apiUrl')+'/users/'+user.id()+'/avatar',
config: app.session.authorize.bind(app.session)
}).then(function(data) {
self.loading(false);

View File

@ -20,7 +20,7 @@ export default class ChangePasswordModal extends FormModal {
m.request({
method: 'POST',
url: app.config['api_url']+'/forgot',
url: app.forum.attribute('apiUrl')+'/forgot',
data: {email: app.session.user().email()},
background: true
}).then(response => {

View File

@ -64,9 +64,9 @@ export default class DiscussionList extends Component {
}
loadResults(offset) {
if (app.preload.response) {
var discussions = app.store.pushPayload(app.preload.response);
app.preload.response = null;
const discussions = app.preloadedDocument();
if (discussions) {
return m.deferred().resolve(discussions).promise;
} else {
var params = this.params();

View File

@ -43,17 +43,13 @@ export default class DiscussionPage extends mixin(Component, evented) {
var params = this.params();
params.include = params.include.join(',');
var discussion;
if (app.preload.response) {
const discussion = app.preloadedDocument();
if (discussion) {
// We must wrap this in a setTimeout because if we are mounting this
// component for the first time on page load, then any calls to m.redraw
// will be ineffective and thus any configs (scroll code) will be run
// before stuff is drawn to the page.
setTimeout(() => {
var discussion = app.store.pushPayload(app.preload.response);
app.preload.response = null;
this.setupDiscussion(discussion);
});
setTimeout(this.setupDiscussion.bind(this, discussion));
} else {
app.store.find('discussions', m.route.param('id'), params).then(this.setupDiscussion.bind(this));
}

View File

@ -44,7 +44,7 @@ export default class ForgotPasswordModal extends FormModal {
m.request({
method: 'POST',
url: app.config['api_url']+'/forgot',
url: app.forum.attribute('apiUrl')+'/forgot',
data: {email: this.email()},
background: true,
extract: xhr => {

View File

@ -61,7 +61,7 @@ export default class NotificationList extends Component {
badges && badges.length ? m('ul.badges', listItems(badges)) : '',
group.discussion.title()
)
: m('div.notification-group-header', app.config['forum_title']),
: m('div.notification-group-header', app.forum.attribute('title')),
m('ul.notification-group-list', group.notifications.map(notification => {
var NotificationComponent = app.notificationComponentRegistry[notification.contentType()];
return NotificationComponent ? m('li', NotificationComponent.component({notification})) : '';

View File

@ -45,7 +45,7 @@ export default class UserDropdown extends Component {
ActionButton.component({
icon: 'wrench',
label: 'Administration',
href: app.config['base_url']+'/admin'
href: app.forum.attribute('baseUrl')+'/admin'
})
);
}

View File

@ -17,8 +17,8 @@ export default class WelcomeHero extends Component {
m('div.container', [
m('button.close.btn.btn-icon.btn-link', {onclick: () => this.$().slideUp(this.hide.bind(this))}, m('i.fa.fa-times')),
m('div.container-narrow', [
m('h2', app.config['welcome_title']),
m('div.subtitle', m.trust(app.config['welcome_message']))
m('h2', app.forum.attribute('welcomeTitle')),
m('div.subtitle', m.trust(app.forum.attribute('welcomeMessage')))
])
])
])

View File

@ -1,9 +1,9 @@
export default function(app) {
if (app.preload.data) {
app.store.pushPayload({data: app.preload.data});
}
app.store.pushPayload({data: app.preload.data});
app.forum = app.store.getById('forums', 1);
if (app.preload.session) {
app.session.token(app.preload.session.token);
app.session.user(app.store.getById('users', app.preload.session.userId));
}
};
}

View File

@ -1,4 +1,5 @@
import Store from 'flarum/store';
import Forum from 'flarum/models/forum';
import User from 'flarum/models/user';
import Discussion from 'flarum/models/discussion';
import Post from 'flarum/models/post';
@ -9,10 +10,13 @@ import Notification from 'flarum/models/notification';
export default function(app) {
app.store = new Store();
app.store.models['users'] = User;
app.store.models['discussions'] = Discussion;
app.store.models['posts'] = Post;
app.store.models['groups'] = Group;
app.store.models['activity'] = Activity;
app.store.models['notifications'] = Notification;
};
app.store.models = {
forums: Forum,
users: User,
discussions: Discussion,
posts: Post,
groups: Group,
activity: Activity,
notifications: Notification
};
}

View File

@ -10,6 +10,10 @@ export default class Model {
return this.data().id;
}
attribute(attribute) {
return this.data().attributes[attribute];
}
pushData(newData) {
var data = this.data();
@ -90,7 +94,7 @@ export default class Model {
return app.request({
method: this.exists ? 'PATCH' : 'POST',
url: app.config['api_url']+'/'+this.data().type+(this.exists ? '/'+this.data().id : ''),
url: app.forum.attribute('apiUrl')+'/'+this.data().type+(this.exists ? '/'+this.data().id : ''),
data: {data},
background: true,
config: app.session.authorize.bind(app.session)
@ -108,7 +112,7 @@ export default class Model {
return app.request({
method: 'DELETE',
url: app.config['api_url']+'/'+this.data().type+'/'+this.data().id,
url: app.forum.attribute('apiUrl')+'/'+this.data().type+'/'+this.data().id,
background: true,
config: app.session.authorize.bind(app.session)
}).then(() => this.exists = false);

5
js/lib/models/forum.js Normal file
View File

@ -0,0 +1,5 @@
import Model from 'flarum/model';
class Forum extends Model {}
export default Forum;

View File

@ -13,7 +13,7 @@ export default class Session extends mixin(class {}, evented) {
var self = this;
m.request({
method: 'POST',
url: app.config['base_url']+'/login',
url: app.forum.attribute('baseUrl')+'/login',
data: {identification, password},
background: true
}).then(function(response) {
@ -32,7 +32,7 @@ export default class Session extends mixin(class {}, evented) {
}
logout() {
window.location = app.config['base_url']+'/logout';
window.location = app.forum.attribute('baseUrl')+'/logout?token='+this.token();
}
authorize(xhr) {

View File

@ -38,7 +38,7 @@ export default class Store {
}
return app.request({
method: 'GET',
url: app.config['api_url']+'/'+endpoint,
url: app.forum.attribute('apiUrl')+'/'+endpoint,
data: params,
background: true,
config: app.session.authorize.bind(app.session)

View File

@ -15,8 +15,17 @@ class App {
this.initializers.toArray().forEach((initializer) => initializer(this));
}
preloadedDocument() {
if (app.preload.document) {
const results = app.store.pushPayload(app.preload.document);
app.preload.document = null;
return results;
}
}
setTitle(title) {
document.title = (title ? title+' - ' : '')+this.config['forum_title'];
document.title = (title ? title+' - ' : '')+this.forum.attribute('title');
}
request(options) {

View File

@ -33,6 +33,6 @@ class Client
$response = $action->handle(new Request($input, $actor));
return json_decode($response->getBody());
return new Response($response);
}
}

16
src/Api/Response.php Normal file
View File

@ -0,0 +1,16 @@
<?php namespace Flarum\Api;
use Psr\Http\Message\ResponseInterface;
class Response
{
public function __construct(ResponseInterface $response)
{
$this->response = $response;
}
public function getBody()
{
return json_decode($this->response->getBody());
}
}

View File

@ -1,5 +1,7 @@
<?php namespace Flarum\Api\Serializers;
use Flarum\Core;
class ForumSerializer extends Serializer
{
/**
@ -21,7 +23,12 @@ class ForumSerializer extends Serializer
protected function getDefaultAttributes($forum)
{
return [
'title' => $forum->title
'title' => $forum->title,
'baseUrl' => Core::config('base_url'),
'apiUrl' => Core::config('api_url'),
'welcomeTitle' => Core::config('welcome_title'),
'welcomeMessage' => Core::config('welcome_message'),
'themePrimaryColor' => Core::config('theme_primary_color')
];
}
}

View File

@ -0,0 +1,134 @@
<?php namespace Flarum\Forum\Actions;
use Flarum\Api\Client;
use Flarum\Assets\AssetManager;
use Flarum\Assets\JsCompiler;
use Flarum\Assets\LessCompiler;
use Flarum\Core;
use Flarum\Core\Users\User;
use Flarum\Forum\Events\RenderView;
use Flarum\Locale\JsCompiler as LocaleJsCompiler;
use Flarum\Locale\LocaleManager;
use Flarum\Support\ClientView;
use Flarum\Support\HtmlAction;
use Illuminate\Database\ConnectionInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
abstract class ClientAction extends HtmlAction
{
/**
* @var Client
*/
protected $apiClient;
protected $locales;
/**
* @param Client $apiClient
* @param LocaleManager $locales
*/
public function __construct(Client $apiClient, LocaleManager $locales)
{
$this->apiClient = $apiClient;
$this->locales = $locales;
}
/**
* @param Request $request
* @param array $routeParams
* @return \Flarum\Support\ClientView
*/
public function render(Request $request, array $routeParams = [])
{
$actor = app('flarum.actor');
$assets = $this->getAssets();
$locale = $this->getLocaleCompiler($actor);
$layout = 'flarum.forum::forum';
$view = new ClientView(
$request,
$actor,
$this->apiClient,
$layout,
$assets,
$locale
);
return $view;
}
protected function getAssets()
{
$public = $this->getAssetDirectory();
$assets = new AssetManager(
new JsCompiler($public, 'forum.js'),
new LessCompiler($public, 'forum.css')
);
$root = __DIR__.'/../../..';
$assets->addFile($root.'/js/forum/dist/app.js');
$assets->addFile($root.'/less/forum/app.less');
foreach ($this->getLessVariables() as $name => $value) {
$assets->addLess("@$name: $value;");
}
$assets->addLess(Core::config('custom_less'));
return $assets;
}
protected function getLessVariables()
{
return [
'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'
];
}
protected function getLocaleCompiler(User $actor)
{
$locale = $actor->locale ?: Core::config('locale', 'en');
// $translations = $this->locales->getTranslations($locale);
$jsFiles = $this->locales->getJsFiles($locale);
$compiler = new LocaleJsCompiler($this->getAssetDirectory(), 'locale-'.$locale.'.js');
// $compiler->setTranslations(static::filterTranslations($translations));
array_walk($jsFiles, [$compiler, 'addFile']);
return $compiler;
}
protected function getAssetDirectory()
{
return public_path().'/assets';
}
/**
* @param $translations
* @return array
*/
// protected static function filterTranslations($translations)
// {
// $filtered = [];
//
// foreach (static::$translations as $key) {
// $parts = explode('.', $key);
// $level = &$filtered;
//
// foreach ($parts as $part) {
// $level = &$level[$part];
// }
//
// $level = array_get($translations, $key);
// }
//
// return $filtered;
// }
}

View File

@ -2,19 +2,25 @@
use Psr\Http\Message\ServerRequestInterface as Request;
class DiscussionAction extends IndexAction
class DiscussionAction extends ClientAction
{
protected function getDetails(Request $request, array $routeParams)
public function render(Request $request, array $routeParams = [])
{
$response = $this->apiClient->send(app('flarum.actor'), 'Flarum\Api\Actions\Discussions\ShowAction', [
$view = parent::render($request, $routeParams);
$actor = app('flarum.actor');
$action = 'Flarum\Api\Actions\Discussions\ShowAction';
$params = [
'id' => $routeParams['id'],
'page.near' => $routeParams['near']
]);
// TODO: return an object instead of an array?
return [
'title' => $response->data->attributes->title,
'response' => $response
];
$document = $this->apiClient->send($actor, $action, $params)->getBody();
$view->setTitle($document->data->attributes->title);
$view->setDocument($document);
$view->setContent(app('view')->make('flarum.forum::discussion', compact('document')));
return $view;
}
}

View File

@ -11,33 +11,8 @@ use Flarum\Support\HtmlAction;
use Illuminate\Database\ConnectionInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
class IndexAction extends HtmlAction
class IndexAction extends ClientAction
{
/**
* @var Client
*/
protected $apiClient;
/**
* @var ConnectionInterface
*/
protected $database;
/**
* @var array
*/
public static $translations = [];
/**
* @param Client $apiClient
* @param ConnectionInterface $database
*/
public function __construct(Client $apiClient, ConnectionInterface $database)
{
$this->apiClient = $apiClient;
$this->database = $database;
}
/**
* @param Request $request
* @param array $routeParams
@ -45,135 +20,22 @@ class IndexAction extends HtmlAction
*/
public function render(Request $request, array $routeParams = [])
{
$config = $this->database->table('config')
->whereIn('key', ['base_url', 'api_url', 'forum_title', 'welcome_title', 'welcome_message', 'theme_primary_color'])
->lists('value', 'key');
$session = [];
$view = parent::render($request, $routeParams);
$actor = app('flarum.actor');
$response = $this->apiClient->send($actor, 'Flarum\Api\Actions\Forum\ShowAction');
$data = [$response->data];
if (isset($response->included)) {
$data = array_merge($data, $response->included);
}
if ($actor->exists) {
$session = [
'userId' => $actor->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($actor, 'Flarum\Api\Actions\Users\ShowAction', ['id' => $actor->id]);
$data[] = $response->data;
if (isset($response->included)) {
$data = array_merge($data, $response->included);
}
}
$details = $this->getDetails($request, $routeParams);
$data = array_merge($data, array_get($details, 'data', []));
$response = array_get($details, 'response');
$title = array_get($details, 'title');
$view = view('flarum.forum::index')
->with('title', ($title ? $title.' - ' : '').Core::config('forum_title'))
->with('config', $config)
->with('layout', 'flarum.forum::forum')
->with('data', $data)
->with('response', $response)
->with('session', $session);
$root = __DIR__.'/../../..';
$public = public_path().'/assets';
$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 = $actor->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', [$assets->getCssFile()])
->with('scripts', [$assets->getJsFile(), $localeCompiler->getFile()]);
}
/**
* @param Request $request
* @param array $routeParams
* @return array
*/
protected function getDetails(Request $request, array $routeParams)
{
$queryParams = $request->getQueryParams();
// Only preload data if we're viewing the default index with no filters,
// otherwise we have to do all kinds of crazy stuff
if (!count($queryParams) && $request->getUri()->getPath() === '/') {
$response = $this->apiClient->send(app('flarum.actor'), 'Flarum\Api\Actions\Discussions\IndexAction');
if (! count($queryParams) && $request->getUri()->getPath() === '/') {
$actor = app('flarum.actor');
$action = 'Flarum\Api\Actions\Discussions\IndexAction';
return [
'response' => $response
];
$document = $this->apiClient->send($actor, $action)->getBody();
$view->setDocument($document);
$view->setContent(app('view')->make('flarum.forum::index', compact('document')));
}
return [];
}
/**
* @param $translations
* @return array
*/
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;
return $view;
}
}

View File

@ -58,19 +58,13 @@ class ForumServiceProvider extends ServiceProvider
);
$routes->get(
'/d/{id:\d+}/{slug}',
'/d/{id:\d+}/{slug}[/{near}]',
'flarum.forum.discussion',
$this->action('Flarum\Forum\Actions\DiscussionAction')
);
$routes->get(
'/d/{id:\d+}/{slug}/{near}',
'flarum.forum.discussion.near',
$this->action('Flarum\Forum\Actions\DiscussionAction')
);
$routes->get(
'/u/{username}',
'/u/{username}[/{filter}]',
'flarum.forum.user',
$this->action('Flarum\Forum\Actions\IndexAction')
);

130
src/Support/ClientView.php Normal file
View File

@ -0,0 +1,130 @@
<?php namespace Flarum\Support;
use Flarum\Api\Client;
use Flarum\Assets\AssetManager;
use Flarum\Core\Users\User;
use Psr\Http\Message\ServerRequestInterface as Request;
use Flarum\Locale\JsCompiler;
class ClientView
{
protected $actor;
protected $apiClient;
protected $title;
protected $document;
protected $content;
protected $request;
protected $layout;
public function __construct(
Request $request,
User $actor,
Client $apiClient,
$layout,
AssetManager $assets,
JsCompiler $locale
) {
$this->request = $request;
$this->actor = $actor;
$this->apiClient = $apiClient;
$this->layout = $layout;
$this->assets = $assets;
$this->locale = $locale;
}
public function setActor(User $actor)
{
$this->actor = $actor;
}
public function setTitle($title)
{
$this->title = $title;
}
public function setDocument($document)
{
$this->document = $document;
}
public function setContent($content)
{
$this->content = $content;
}
public function render()
{
$view = app('view')->file(__DIR__.'/../../views/app.blade.php');
$forum = $this->getForumDocument();
$data = $this->getDataFromDocument($forum);
if ($this->actor->exists) {
$user = $this->getUserDocument();
$data = array_merge($data, $this->getDataFromDocument($user));
}
$view->data = $data;
$view->session = $this->getSession();
$view->title = ($this->title ? $this->title . ' - ' : '') . $forum->data->attributes->title;
$view->document = $this->document;
$view->forum = $forum->data;
$view->layout = $this->layout;
$view->content = $this->content;
$view->styles = [$this->assets->getCssFile()];
$view->scripts = [$this->assets->getJsFile(), $this->locale->getFile()];
return $view->render();
}
public function __toString()
{
return $this->render();
}
protected function getForumDocument()
{
return $this->apiClient->send($this->actor, 'Flarum\Api\Actions\Forum\ShowAction')->getBody();
}
protected function getUserDocument()
{
// 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?
$document = $this->apiClient->send(
$this->actor,
'Flarum\Api\Actions\Users\ShowAction',
['id' => $this->actor->id]
)->getBody();
return $document;
}
protected function getDataFromDocument($document)
{
$data[] = $document->data;
if (isset($document->included)) {
$data = array_merge($data, $document->included);
}
return $data;
}
protected function getSession()
{
return [
'userId' => $this->actor->id,
'token' => $this->request->getCookieParams()['flarum_remember'],
];
}
}

41
views/app.blade.php Normal file
View File

@ -0,0 +1,41 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{ $title }}</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1">
<meta name="theme-color" content="{{ $forum->attributes->themePrimaryColor }}">
@foreach ($styles as $file)
<link rel="stylesheet" href="{{ str_replace(public_path(), '', $file) }}">
@endforeach
</head>
<body>
@include($layout)
<div id="modal"></div>
<div id="alerts"></div>
@foreach ($scripts as $file)
<script src="{{ str_replace(public_path(), '', $file) }}"></script>
@endforeach
<script>
var app = require('flarum/app')['default'];
app.preload = {
data: {!! json_encode($data) !!},
document: {!! json_encode($document) !!},
session: {!! json_encode($session) !!}
};
app.boot();
</script>
@if ($content)
<noscript>
{!! $content !!}
</noscript>
@endif
</body>
</html>

View File

@ -0,0 +1 @@
discussion SEO content

View File

@ -4,7 +4,7 @@
<header class="global-header" id="header">
<div id="back-button"></div>
<div class="container">
<h1 class="header-title"><a href="{{ $config['base_url'] }}" id="home-link">{{ $config['forum_title'] }}</a></h1>
<h1 class="header-title"><a href="{{ $forum->attributes->baseUrl }}" id="home-link">{{ $forum->attributes->title }}</a></h1>
<div id="header-primary" class="header-primary"></div>
<div id="header-secondary" class="header-secondary"></div>
</div>

View File

@ -1,38 +1 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{ $title }}</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1">
<!-- Theme color for Android phones -->
<meta name="theme-color" content="{{ $config['theme_primary_color'] }}">
@foreach ($styles as $file)
<link rel="stylesheet" href="{{ str_replace(public_path(), '', $file) }}">
@endforeach
</head>
<body>
@include($layout)
<div id="modal"></div>
<div id="alerts"></div>
@foreach ($scripts as $file)
<script src="{{ str_replace(public_path(), '', $file) }}"></script>
@endforeach
<script>
var app = require('flarum/app')['default'];
app.config = {!! json_encode($config) !!};
app.preload = {
data: {!! json_encode($data) !!},
response: {!! json_encode($response) !!},
session: {!! json_encode($session) !!}
};
app.boot();
</script>
</body>
</html>
index SEO content