From f96cac605759c98c3bf5cd314009bc625caccd0f Mon Sep 17 00:00:00 2001
From: Toby Zerner <toby.zerner@gmail.com>
Date: Wed, 29 Jul 2015 21:00:09 +0930
Subject: [PATCH] Implement basic settings page

---
 js/admin/src/components/BasicsPage.js   | 137 ++++++++++++++++++++++++
 js/lib/Model.js                         |  14 ++-
 js/lib/components/Button.js             |  10 +-
 js/lib/models/Forum.js                  |   6 +-
 less/admin/BasicsPage.less              |  33 ++++++
 less/forum/Composer.less                |   5 -
 less/lib/Button.less                    |  16 +++
 src/Api/Actions/Forum/ShowAction.php    |  11 +-
 src/Api/Actions/Forum/UpdateAction.php  |  53 +++++++++
 src/Api/ApiServiceProvider.php          |   7 ++
 src/Api/Serializers/ForumSerializer.php |  21 +++-
 src/Locale/LocaleManager.php            |  12 +++
 src/Locale/LocaleServiceProvider.php    |   5 +-
 13 files changed, 313 insertions(+), 17 deletions(-)
 create mode 100644 js/admin/src/components/BasicsPage.js
 create mode 100644 less/admin/BasicsPage.less
 create mode 100644 src/Api/Actions/Forum/UpdateAction.php

diff --git a/js/admin/src/components/BasicsPage.js b/js/admin/src/components/BasicsPage.js
new file mode 100644
index 000000000..a3d8f2f69
--- /dev/null
+++ b/js/admin/src/components/BasicsPage.js
@@ -0,0 +1,137 @@
+import Component from 'flarum/Component';
+import FieldSet from 'flarum/components/FieldSet';
+import Select from 'flarum/components/Select';
+import Button from 'flarum/components/Button';
+
+export default class BasicsPage extends Component {
+  constructor(...args) {
+    super(...args);
+
+    this.loading = false;
+
+    this.fields = [
+      'forum_title',
+      'forum_description',
+      'default_locale',
+      'default_route',
+      'welcome_title',
+      'welcome_message'
+    ];
+    this.values = {};
+
+    const config = app.forum.attribute('config');
+    this.fields.forEach(key => this.values[key] = m.prop(config[key]));
+
+    this.localeOptions = {};
+    const locales = app.forum.attribute('availableLocales');
+    for (const i in locales) {
+      this.localeOptions[i] = `${locales[i]} (${i})`;
+    }
+  }
+
+  view() {
+    return (
+      <div className="BasicsPage">
+        <div className="container">
+          <form onsubmit={this.onsubmit.bind(this)}>
+            {FieldSet.component({
+              label: 'Forum Title',
+              children: [
+                <input className="FormControl" value={this.values.forum_title()} oninput={m.withAttr('value', this.values.forum_title)}/>
+              ]
+            })}
+
+            {FieldSet.component({
+              label: 'Forum Description',
+              children: [
+                <div className="helpText">
+                  Enter a short sentence or two that describes your community. This will appear in the meta tag and show up in search engines.
+                </div>,
+                <textarea className="FormControl" value={this.values.forum_description()} oninput={m.withAttr('value', this.values.forum_description)}/>
+              ]
+            })}
+
+            {Object.keys(this.localeOptions).length > 1
+              ? FieldSet.component({
+                label: 'Default Language',
+                children: [
+                  Select.component({
+                    options: this.localeOptions,
+                    onchange: this.values.default_locale
+                  })
+                ]
+              })
+              : ''}
+
+            {FieldSet.component({
+              label: 'Home Page',
+              className: 'BasicsPage-homePage',
+              children: [
+                <div className="helpText">
+                  Choose the page which users will first see when they visit your forum. If entering a custom value, use the path relative to the forum root.
+                </div>,
+                <label className="checkbox">
+                  <input type="radio" name="homePage" value="/all" checked={this.values.default_route() === '/all'} onclick={m.withAttr('value', this.values.default_route)}/>
+                  All Discussions
+                </label>,
+                <label className="checkbox">
+                  <input type="radio" name="homePage" value="custom" checked={this.values.default_route() !== '/all'} onclick={() => {
+                    this.values.default_route('');
+                    m.redraw(true);
+                    this.$('.BasicsPage-homePage input').select();
+                  }}/>
+                  Custom <input className="FormControl" value={this.values.default_route()} onchange={m.withAttr('value', this.values.default_route)} style={this.values.default_route() !== '/all' ? 'margin-top: 5px' : 'display:none'}/>
+                </label>
+              ]
+            })}
+
+            {FieldSet.component({
+              label: 'Welcome Banner',
+              className: 'BasicsPage-welcomeBanner',
+              children: [
+                <div className="helpText">
+                  Configure the text that displays in the banner on the All Discussions page. Use this to welcome guests to your forum.
+                </div>,
+                <div className="BasicsPage-welcomeBanner-input">
+                  <input className="FormControl" value={this.values.welcome_title()} oninput={m.withAttr('value', this.values.welcome_title)}/>
+                  <textarea className="FormControl" value={this.values.welcome_message()} oninput={m.withAttr('value', this.values.welcome_message)}/>
+                </div>
+              ]
+            })}
+
+            {Button.component({
+              type: 'submit',
+              className: 'Button Button--primary',
+              children: 'Save Changes',
+              loading: this.loading,
+              disabled: !this.changed()
+            })}
+          </form>
+        </div>
+      </div>
+    );
+  }
+
+  changed() {
+    const config = app.forum.attribute('config');
+
+    return this.fields.some(key => this.values[key]() !== config[key]);
+  }
+
+  onsubmit(e) {
+    e.preventDefault();
+
+    if (this.loading) return;
+
+    this.loading = true;
+
+    const config = {};
+
+    this.fields.forEach(key => config[key] = this.values[key]());
+
+    app.forum.save({config}).finally(() => {
+      this.loading = false;
+      m.redraw();
+    });
+  }
+}
diff --git a/js/lib/Model.js b/js/lib/Model.js
index 55362861b..df16a5e63 100644
--- a/js/lib/Model.js
+++ b/js/lib/Model.js
@@ -155,7 +155,7 @@ export default class Model {
 
     return app.request({
       method: this.exists ? 'PATCH' : 'POST',
-      url: app.forum.attribute('apiUrl') + '/' + this.data.type + (this.exists ? '/' + this.data.id : ''),
+      url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
       data: {data}
     }).then(
       // If everything went well, we'll make sure the store knows that this
@@ -187,13 +187,23 @@ export default class Model {
 
     return app.request({
       method: 'DELETE',
-      url: app.forum.attribute('apiUrl') + '/' + this.data.type + '/' + this.data.id,
+      url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
       data
     }).then(
       () => this.exists = false
     );
   }
 
+  /**
+   * Construct a path to the API endpoint for this resource.
+   *
+   * @return {String}
+   * @protected
+   */
+  apiEndpoint() {
+    return '/' + this.data.type + (this.exists ? '/' + this.data.id : '');
+  }
+
   /**
    * Generate a function which returns the value of the given attribute.
    *
diff --git a/js/lib/components/Button.js b/js/lib/components/Button.js
index f99bda0ce..5991cfe27 100644
--- a/js/lib/components/Button.js
+++ b/js/lib/components/Button.js
@@ -1,6 +1,7 @@
 import Component from 'flarum/Component';
 import icon from 'flarum/helpers/icon';
 import extract from 'flarum/utils/extract';
+import LoadingIndicator from 'flarum/components/LoadingIndicator';
 
 /**
  * The `Button` component defines an element which, when clicked, performs an
@@ -11,6 +12,7 @@ import extract from 'flarum/utils/extract';
  * - `disabled` Whether or not the button is disabled. If truthy, the button
  *   will be given a 'disabled' class name, and any `onclick` handler will be
  *   removed.
+ * - `loading` Whether or not the button should be in a disabled loading state.
  *
  * All other props will be assigned as attributes on the button element.
  *
@@ -28,8 +30,9 @@ export default class Button extends Component {
     const iconName = extract(attrs, 'icon');
     if (iconName) attrs.className += ' hasIcon';
 
-    if (attrs.disabled) {
-      attrs.className += ' disabled';
+    const loading = extract(attrs, 'loading');
+    if (attrs.disabled || loading) {
+      attrs.className += ' disabled' + (loading ? ' loading' : '');
       delete attrs.onclick;
     }
 
@@ -47,7 +50,8 @@ export default class Button extends Component {
 
     return [
       iconName ? icon(iconName, {className: 'Button-icon'}) : '', ' ',
-      this.props.children ? <span className="Button-label">{this.props.children}</span> : ''
+      this.props.children ? <span className="Button-label">{this.props.children}</span> : '',
+      this.props.loading ? LoadingIndicator.component({size: 'tiny', className: 'LoadingIndicator--inline'}) : ''
     ];
   }
 }
diff --git a/js/lib/models/Forum.js b/js/lib/models/Forum.js
index b19a5d4a7..2898b170b 100644
--- a/js/lib/models/Forum.js
+++ b/js/lib/models/Forum.js
@@ -1,3 +1,7 @@
 import Model from 'flarum/Model';
 
-export default class Forum extends Model {}
+export default class Forum extends Model {
+  apiEndpoint() {
+    return '/forum';
+  }
+}
diff --git a/less/admin/BasicsPage.less b/less/admin/BasicsPage.less
new file mode 100644
index 000000000..52d193961
--- /dev/null
+++ b/less/admin/BasicsPage.less
@@ -0,0 +1,33 @@
+.BasicsPage {
+  padding: 20px 0;
+
+  @media @desktop-up {
+    .container {
+      max-width: 600px;
+      margin: 0;
+    }
+  }
+
+  fieldset {
+    margin-bottom: 30px;
+
+    > ul {
+      list-style: none;
+      margin: 0;
+      padding: 0;
+    }
+  }
+}
+
+.BasicsPage-welcomeBanner-input {
+  :first-child {
+    margin-bottom: 1px;
+    border-bottom-right-radius: 0;
+    border-bottom-left-radius: 0;
+    font-weight: bold;
+  }
+  :last-child {
+    border-top-right-radius: 0;
+    border-top-left-radius: 0;
+  }
+}
diff --git a/less/forum/Composer.less b/less/forum/Composer.less
index d55375823..b9c58035a 100644
--- a/less/forum/Composer.less
+++ b/less/forum/Composer.less
@@ -331,10 +331,5 @@
       border-top: 0;
       padding: 20px 0;
     }
-
-    & .Button--primary {
-      padding-left: 20px;
-      padding-right: 20px;
-    }
   }
 }
diff --git a/less/lib/Button.less b/less/lib/Button.less
index 80d1906d9..3660c16bf 100755
--- a/less/lib/Button.less
+++ b/less/lib/Button.less
@@ -86,6 +86,20 @@
       pointer-events: none; // Future-proof disabling of clicks on `<a>` elements
     }
   }
+
+  .Button-label {
+    .transition(margin-right 0.1s);
+  }
+  .LoadingIndicator {
+    color: inherit;
+    margin: 0 -10px 0 -15px;
+    float: right;
+  }
+  &.loading {
+    .Button-label {
+      margin-right: 20px;
+    }
+  }
 }
 
 .Button--color(@color; @background) {
@@ -155,6 +169,8 @@
 .Button--primary {
   .Button--color(@body-bg, @primary-color);
   font-weight: bold;
+  padding-left: 20px;
+  padding-right: 20px;
 
   .Button-icon {
     display: none;
diff --git a/src/Api/Actions/Forum/ShowAction.php b/src/Api/Actions/Forum/ShowAction.php
index 2125cae5d..b8aebaf34 100644
--- a/src/Api/Actions/Forum/ShowAction.php
+++ b/src/Api/Actions/Forum/ShowAction.php
@@ -2,6 +2,7 @@
 
 use Flarum\Api\Actions\SerializeResourceAction;
 use Flarum\Api\JsonApiRequest;
+use Flarum\Core\Groups\Group;
 use Tobscure\JsonApi\Document;
 
 class ShowAction extends SerializeResourceAction
@@ -14,7 +15,9 @@ class ShowAction extends SerializeResourceAction
     /**
      * @inheritdoc
      */
-    public $include = [];
+    public $include = [
+        'groups' => true
+    ];
 
     /**
      * @inheritdoc
@@ -51,6 +54,10 @@ class ShowAction extends SerializeResourceAction
      */
     protected function data(JsonApiRequest $request, Document $document)
     {
-        return app('flarum.forum');
+        $forum = app('flarum.forum');
+
+        $forum->groups = Group::all();
+
+        return $forum;
     }
 }
diff --git a/src/Api/Actions/Forum/UpdateAction.php b/src/Api/Actions/Forum/UpdateAction.php
new file mode 100644
index 000000000..0ad33cf04
--- /dev/null
+++ b/src/Api/Actions/Forum/UpdateAction.php
@@ -0,0 +1,53 @@
+<?php namespace Flarum\Api\Actions\Forum;
+
+use Flarum\Core\Exceptions\PermissionDeniedException;
+use Flarum\Core\Settings\SettingsRepository;
+use Flarum\Api\Actions\SerializeResourceAction;
+use Flarum\Api\JsonApiRequest;
+use Tobscure\JsonApi\Document;
+
+class UpdateAction extends SerializeResourceAction
+{
+    /**
+     * @inheritdoc
+     */
+    public $serializer = 'Flarum\Api\Serializers\ForumSerializer';
+
+    /**
+     * @var SettingsRepository
+     */
+    protected $settings;
+
+    /**
+     * @param SettingsRepository $settings
+     */
+    public function __construct(SettingsRepository $settings)
+    {
+        $this->settings = $settings;
+    }
+
+    /**
+     * Get the forum, ready to be serialized and assigned to the JsonApi
+     * response.
+     *
+     * @param JsonApiRequest $request
+     * @param Document $document
+     * @return \Flarum\Core\Forum
+     */
+    protected function data(JsonApiRequest $request, Document $document)
+    {
+        if (! $request->actor->isAdmin()) {
+            throw new PermissionDeniedException;
+        }
+
+        $config = $request->get('data.attributes.config');
+
+        if (is_array($config)) {
+            foreach ($config as $k => $v) {
+                $this->settings->set($k, $v);
+            }
+        }
+
+        return app('flarum.forum');
+    }
+}
diff --git a/src/Api/ApiServiceProvider.php b/src/Api/ApiServiceProvider.php
index c3c9860d8..e08b42b8d 100644
--- a/src/Api/ApiServiceProvider.php
+++ b/src/Api/ApiServiceProvider.php
@@ -94,6 +94,13 @@ class ApiServiceProvider extends ServiceProvider
             $this->action('Flarum\Api\Actions\Forum\ShowAction')
         );
 
+        // Save forum information
+        $routes->patch(
+            '/forum',
+            'flarum.api.forum.update',
+            $this->action('Flarum\Api\Actions\Forum\UpdateAction')
+        );
+
         // Retrieve authentication token
         $routes->post(
             '/token',
diff --git a/src/Api/Serializers/ForumSerializer.php b/src/Api/Serializers/ForumSerializer.php
index b8b623436..d4915bf33 100644
--- a/src/Api/Serializers/ForumSerializer.php
+++ b/src/Api/Serializers/ForumSerializer.php
@@ -1,6 +1,7 @@
 <?php namespace Flarum\Api\Serializers;
 
 use Flarum\Core;
+use Flarum\Core\Groups\Permission;
 
 class ForumSerializer extends Serializer
 {
@@ -22,13 +23,29 @@ class ForumSerializer extends Serializer
      */
     protected function getDefaultAttributes($forum)
     {
-        return [
-            'title' => $forum->title,
+        $attributes = [
+            'title' => Core::config('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')
         ];
+
+        if ($this->actor->isAdmin()) {
+            $attributes['config'] = app('Flarum\Core\Settings\SettingsRepository')->all();
+            $attributes['availableLocales'] = app('flarum.localeManager')->getLocales();
+            $attributes['permissions'] = Permission::map();
+        }
+
+        return $attributes;
+    }
+
+    /**
+     * @return callable
+     */
+    protected function groups()
+    {
+        return $this->hasMany('Flarum\Api\Serializers\GroupSerializer');
     }
 }
diff --git a/src/Locale/LocaleManager.php b/src/Locale/LocaleManager.php
index bc9e600e8..c7debf173 100644
--- a/src/Locale/LocaleManager.php
+++ b/src/Locale/LocaleManager.php
@@ -2,12 +2,24 @@
 
 class LocaleManager
 {
+    protected $locales = [];
+
     protected $translations = [];
 
     protected $js = [];
 
     protected $config = [];
 
+    public function addLocale($locale, $name)
+    {
+        $this->locales[$locale] = $name;
+    }
+
+    public function getLocales()
+    {
+        return $this->locales;
+    }
+
     public function addTranslations($locale, $translations)
     {
         if (! isset($this->translations[$locale])) {
diff --git a/src/Locale/LocaleServiceProvider.php b/src/Locale/LocaleServiceProvider.php
index 510e50413..1cc846b2f 100644
--- a/src/Locale/LocaleServiceProvider.php
+++ b/src/Locale/LocaleServiceProvider.php
@@ -14,15 +14,16 @@ class LocaleServiceProvider extends ServiceProvider
     {
         $manager = $this->app->make('flarum.localeManager');
 
-        $this->registerLocale($manager, 'en');
+        $this->registerLocale($manager, 'en', 'English');
 
         event(new RegisterLocales($manager));
     }
 
-    public function registerLocale(LocaleManager $manager, $locale)
+    public function registerLocale(LocaleManager $manager, $locale, $title)
     {
         $path = __DIR__.'/../../locale/'.$locale;
 
+        $manager->addLocale($locale, $title);
         $manager->addTranslations($locale, $path.'.yml');
         $manager->addConfig($locale, $path.'.php');
         $manager->addJsFile($locale, $path.'.js');