1
0
mirror of https://github.com/flarum/core.git synced 2025-08-13 11:54:32 +02:00

Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Skvortsov
641330ce52 Don't scroll on m.route.set() to different post on same page.
This removes some messy logic, and the potential for glitches. This system worked well with Mithril 0.2 where we could listen in before (and prevent) page unload, but since that's not possible in Mithril 2, the implementation of the replacement was done in `onbeforeupdate`, which might be called while the page route is being updated, glitching out the page. Instead, extensions should check if they are already on the discussion page for the post they are linking to, and if so, use `app.current.get('stream').goToNumber(TARGET)`.

Please note that this does NOT affect going directly to posts from external links (or page reload), OR from other pages via m.route.set.
2020-10-08 11:34:56 -04:00
160 changed files with 1299 additions and 4597 deletions

View File

@@ -1,112 +1,5 @@
# Changelog
## [0.1.0-beta.14.1](https://github.com/flarum/core/compare/v0.1.0-beta.14...v0.1.0-beta.14.1)
### Fixed
- SuperTextarea component is not exported.
- Symfony dependencies do not match those depended on by Laravel (#2407)
- Scripts from textformatter aren't executed (#2415)
- Sub path installations have no page title.
- Losing focus of Composer area when coming from fullscreen.
## [0.1.0-beta.14](https://github.com/flarum/core/compare/v0.1.0-beta.13...v0.1.0-beta.14)
### Added
- Check dependencies before enabling / disabling extensions (https://github.com/flarum/core/pull/2188)
- Set up temporary infrastructure for TypeScript in core (https://github.com/flarum/core/pull/2206)
- Better UI for request error modals (https://github.com/flarum/core/pull/1929)
- Display name extender, tests, frontend UI (https://github.com/flarum/core/pull/2174)
- Scroll to post or show alert when editing a post from another page (https://github.com/flarum/core/pull/2108)
- Feature to test email config by sending an email to the current user (https://github.com/flarum/core/pull/2023)
- Allow searching users by group ID using the group gambit (https://github.com/flarum/core/pull/2192)
- Use `liveHumanTimes` helper to update times without reload/rerender (https://github.com/flarum/core/pull/2208)
- View extender, tests (https://github.com/flarum/core/pull/2134)
- User extender to replace `PrepareUserGroups` (https://github.com/flarum/core/pull/2110)
- Increase extensibility of skeleton PHP (https://github.com/flarum/core/pull/2308, https://github.com/flarum/core/pull/2318)
- Pass a translator instance to `getEmailSubject` in `MailableInterface` (https://github.com/flarum/core/pull/2244)
- Force LF line endings on windows (https://github.com/flarum/core/pull/2321)
- Add a `Link` component for internal and external links (https://github.com/flarum/core/pull/2315)
- `ConfirmDocumentUnload` component
- Error handler middleware can now be manipulated by the middleware extender
### Changed
- Update to Mithril 2 (https://github.com/flarum/core/pull/2255)
- Stop storing component instances (https://github.com/flarum/core/issues/1821, https://github.com/flarum/core/issues/2144)
- Update to Laravel 6.x (https://github.com/flarum/core/issues/2055)
- `Flarum\Foundation\Application` no longer implements `Illuminate\Contracts\Foundation\Application` (#2142)
- `Flarum\Foundation\Application` no longer inherits `Illuminate\Container\Container` (#2142)
- `paths` have been split off from `Flarum\Foundation\Application` into `Flarum\Foundation\Paths`, which can be injected where needed (#2142)
- `Flarum\User\Gate` no longer implements `Illuminate\Contracts\Auth\Access\Gate` (https://github.com/flarum/core/pull/2181)
- Improve Group Gambit performance (https://github.com/flarum/core/pull/2192)
- Switch to `dayjs` from `momentjs` (https://github.com/flarum/core/pull/2219)
- Don't create a `bio` column in `users` for new installations (https://github.com/flarum/core/pull/2215)
- Start converting core JS to TypeScript (https://github.com/flarum/core/pull/2207)
- Make Carbon an explicit dependency (https://github.com/flarum/core/commit/3b39c212e0fef7522e7d541a9214ff3817138d5d)
- Use Symfony's translator interface instead of Laravel's (https://github.com/flarum/core/pull/2243)
- Use newer versions of fontawesome (https://github.com/flarum/core/pull/2274)
- Use URL generator instead of `app()->url()` where possible (https://github.com/flarum/core/pull/2302)
- Move config from `config.php` into an injectable helper class (https://github.com/flarum/core/pull/2271)
- Use reserved TLD for bogus and test urls (https://github.com/flarum/core/commit/6860b24b70bd04544dde90e537ce021a5fc5a689)
- Replace `m.stream` with `flarum/utils/Stream` (https://github.com/flarum/core/pull/2316)
- Replace `affixedSidebar` util with `AffixedSidebar` component
- Replace `m.withAttr` with `flarum/utils/withAttr`
- Scroll Listener is now passive, performance improvement (https://github.com/flarum/core/pull/2387)
### Fixed
- `generate:migration` command for extensions (https://github.com/flarum/core/commit/443949f7b9d7558dbc1e0994cb898cbac59bec87)
- Container config for `UninstalledSite` (https://github.com/flarum/core/commit/ecdce44d555dd36a365fd472b2916e677ef173cf)
- Tooltip glitch on page chang (https://github.com/flarum/core/issues/2118)
- Using multiple extenders in tests (https://github.com/flarum/core/commit/c4f4f218bf4b175a30880b807f9ccb1a37a25330)
- Header glitch when opening modals (https://github.com/flarum/core/pull/2131)
- Ensure `SameSite` is explicitly set for cookies (https://github.com/flarum/core/pull/2159)
- Ensure `Flarum\User\Event\AvatarChanged` event is properly dispatched (https://github.com/flarum/core/pull/2197)
- Show correct error message on wrong password when changing email (https://github.com/flarum/core/pull/2171)
- Discussion unreadCount could be higher than commentCount if posts deleted (https://github.com/flarum/core/pull/2195)
- Don't show page title on the default route (https://github.com/flarum/core/pull/2047)
- Add page title to `All Discussions` page when it isn't the default route (https://github.com/flarum/core/pull/2047)
- Accept `'0'` as `false` for `flarum/components/Checkbox` (https://github.com/flarum/core/pull/2210)
- Fix PostStreamScrubber background (https://github.com/flarum/core/pull/2222)
- Test port on BaseUrl tests (https://github.com/flarum/core/pull/2226)
- `UrlGenerator` can now generate urls with optional parameters (https://github.com/flarum/core/pull/2246)
- Allow `less` to be compiled independently of Flarum (https://github.com/flarum/core/pull/2252)
- Use correct number abbreviation (https://github.com/flarum/core/pull/2261)
- Ensure avatar html uses alt tags for accessibility (https://github.com/flarum/core/pull/2269)
- Escape regex when searching (https://github.com/flarum/core/pull/2273)
- Remove unneeded semicolons inserted during JS compilation (https://github.com/flarum/core/pull/2280)
- Don't require a username/password for SMTP (https://github.com/flarum/core/pull/2287)
- Allow uppercase entries for SMTP encryption validation (https://github.com/flarum/core/pull/2289)
- Ensure that the right number of posts is returned from list posts API (https://github.com/flarum/core/pull/2291)
- Fix a variety of PostStream bugs (https://github.com/flarum/core/pull/2160, https://github.com/flarum/core/pull/2160)
- Sliding discussion glitch on mobile (https://github.com/flarum/core/pull/2324)
- Sliding discussion button in wrong place (https://github.com/flarum/core/pull/2330, https://github.com/flarum/core/pull/2383)
- Sliding discussion glitch on mobile (https://github.com/flarum/core/pull/2381)
- Fix PostStream for posts with top margins, and scrubber position when scrolling below posts (https://github.com/flarum/core/pull/2369)
### Removed
- `Flarum\Event\AbstractConfigureRoutes` event class
- `Flarum\Event\ConfigureApiRoutes` event class
- `Flarum\Event\ConfigureForumRoutes` event class
- `Flarum\Console\Event\Configuring` event class
- `Flarum\Event\ConfigureModelDates` event class
- `Flarum\Event\ConfigureLocales` event class
- `Flarum\Event\ConfigureModelDefaultAttributes` event class
- `Flarum\Event\GetModelRelationship` event class
- `Flarum\User\Event\BioChanged` event class
- `Flarum\Database\MigrationServiceProvider` moved into `Flarum\Database\DatabaseServiceProvider`
- Unused `admin/components/Widget` component (`admin/component/DashboardWidget` should be used instead)
- Mandrill mail driver (https://github.com/flarum/core/commit/bca833d3f1c34d45d95bf905902368a2753b8908)
### Deprecated
- `Flarum\User\Event\GetDisplayName` event class
- Global path helpers, `Flarum\Foundation\Application` path methods (https://github.com/flarum/core/pull/2155)
- `Flarum\User\AssertPermissionTrait` (https://github.com/flarum/core/pull/2044)
## [0.1.0-beta.13](https://github.com/flarum/core/compare/v0.1.0-beta.12...v0.1.0-beta.13)
### Added

View File

@@ -10,7 +10,7 @@
"email": "franz@develophp.org"
},
{
"name": "Daniël Klabbers",
"name": "Daniel Klabbers",
"email": "daniel@klabbers.email",
"homepage": "https://luceos.com"
},
@@ -27,10 +27,6 @@
{
"name": "Matthew Kilgore",
"email": "matthew@kilgore.dev"
},
{
"name": "Alexander (Sasha) Skvortsov",
"email": "askvortsov@flarum.org"
}
],
"support": {
@@ -76,11 +72,11 @@
"psr/http-server-handler": "^1.0",
"psr/http-server-middleware": "^1.0",
"s9e/text-formatter": "^2.3.6",
"symfony/config": "^4.3.4",
"symfony/console": "^4.3.4",
"symfony/event-dispatcher": "^4.3.4",
"symfony/translation": "^4.3.4",
"symfony/yaml": "^4.3.4",
"symfony/config": "^3.3",
"symfony/console": "^4.2",
"symfony/event-dispatcher": "^4.3.2",
"symfony/translation": "^3.3",
"symfony/yaml": "^3.3",
"tobscure/json-api": "^0.3.0",
"wikimedia/less.php": "^3.0"
},

14
js/dist/admin.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

16
js/dist/forum.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

27
js/package-lock.json generated
View File

@@ -3556,9 +3556,9 @@
"integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8="
},
"jquery": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.1.tgz",
"integrity": "sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg=="
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.4.1.tgz",
"integrity": "sha512-36+AdBzCL+y6qjw5Tx7HgzeGCzC81MDDgaUP8ld2zhx58HdqXGoBd+tHdrBMiyjGQs0Hxs/MLZTu/eHNJJuWPw=="
},
"jquery.hotkeys": {
"version": "0.1.0",
@@ -4546,6 +4546,11 @@
"integrity": "sha512-mUdIBBvdn0PLOeP3TEkMH7HHeUP3GjsXCwKarjv/kGmUFOYg1VqEemKhoQpWMu6X2I8kHeuVdGibLGkVK+/5Qw==",
"dev": true
},
"serialize-javascript": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-2.1.2.tgz",
"integrity": "sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ=="
},
"set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
@@ -4895,29 +4900,21 @@
}
},
"terser-webpack-plugin": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz",
"integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==",
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.3.tgz",
"integrity": "sha512-QMxecFz/gHQwteWwSo5nTc6UaICqN1bMedC5sMtUc7y3Ha3Q8y6ZO0iCR8pq4RJC8Hjf0FEPEHZqcMB/+DFCrA==",
"requires": {
"cacache": "^12.0.2",
"find-cache-dir": "^2.1.0",
"is-wsl": "^1.1.0",
"schema-utils": "^1.0.0",
"serialize-javascript": "^4.0.0",
"serialize-javascript": "^2.1.2",
"source-map": "^0.6.1",
"terser": "^4.1.2",
"webpack-sources": "^1.4.0",
"worker-farm": "^1.7.0"
},
"dependencies": {
"serialize-javascript": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
"integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
"requires": {
"randombytes": "^2.1.0"
}
},
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",

View File

@@ -10,7 +10,7 @@
"dayjs": "^1.8.28",
"expose-loader": "^0.7.5",
"flarum-webpack-config": "0.1.0-beta.10",
"jquery": "^3.5.1",
"jquery": "^3.4.1",
"jquery.hotkeys": "^0.1.0",
"lodash-es": "^4.17.14",
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",

View File

@@ -1,29 +1,13 @@
import HeaderPrimary from './components/HeaderPrimary';
import HeaderSecondary from './components/HeaderSecondary';
import routes from './routes';
import ExtensionPage from './components/ExtensionPage';
import Application from '../common/Application';
import Navigation from '../common/components/Navigation';
import AdminNav from './components/AdminNav';
import ExtensionData from './utils/ExtensionData';
export default class AdminApplication extends Application {
// Deprecated as of beta 15
extensionSettings = {};
extensionData = new ExtensionData();
extensionCategories = {
discussion: 70,
moderation: 60,
feature: 50,
formatting: 40,
theme: 30,
authentication: 20,
language: 10,
other: 0,
};
history = {
canGoBack: () => true,
getPrevious: () => {},
@@ -43,29 +27,24 @@ export default class AdminApplication extends Application {
* @inheritdoc
*/
mount() {
// Mithril does not render the home route on https://example.com/admin, so
// we need to go to https://example.com/admin#/ explicitly.
if (!document.location.hash) document.location.hash = '#/';
m.route.prefix = '#';
super.mount();
m.mount(document.getElementById('app-navigation'), {
view: () =>
Navigation.component({
className: 'App-backControl',
drawer: true,
}),
});
m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('admin-navigation'), AdminNav);
// Mithril does not render the home route on https://example.com/admin, so
// we need to go to https://example.com/admin#/ explicitly.
if (!document.location.hash) document.location.hash = '#/';
m.route.prefix = '#';
super.mount();
// If an extension has just been enabled, then we will run its settings
// callback.
const enabled = localStorage.getItem('enabledExtension');
if (enabled && this.extensionSettings[enabled] && typeof this.extensionSettings[enabled] === 'function') {
if (enabled && this.extensionSettings[enabled]) {
this.extensionSettings[enabled]();
localStorage.removeItem('enabledExtension');
}

View File

@@ -1,21 +1,17 @@
import compat from '../common/compat';
import saveSettings from './utils/saveSettings';
import ExtensionData from './utils/ExtensionData';
import isExtensionEnabled from './utils/isExtensionEnabled';
import getCategorizedExtensions from './utils/getCategorizedExtensions';
import SettingDropdown from './components/SettingDropdown';
import EditCustomFooterModal from './components/EditCustomFooterModal';
import SessionDropdown from './components/SessionDropdown';
import HeaderPrimary from './components/HeaderPrimary';
import AppearancePage from './components/AppearancePage';
import StatusWidget from './components/StatusWidget';
import ExtensionsWidget from './components/ExtensionsWidget';
import HeaderSecondary from './components/HeaderSecondary';
import SettingsModal from './components/SettingsModal';
import DashboardWidget from './components/DashboardWidget';
import ExtensionPage from './components/ExtensionPage';
import ExtensionLinkButton from './components/ExtensionLinkButton';
import AddExtensionModal from './components/AddExtensionModal';
import ExtensionsPage from './components/ExtensionsPage';
import AdminLinkButton from './components/AdminLinkButton';
import PermissionGrid from './components/PermissionGrid';
import MailPage from './components/MailPage';
@@ -27,7 +23,6 @@ import EditCustomHeaderModal from './components/EditCustomHeaderModal';
import PermissionsPage from './components/PermissionsPage';
import PermissionDropdown from './components/PermissionDropdown';
import AdminNav from './components/AdminNav';
import AdminHeader from './components/AdminHeader';
import EditCustomCssModal from './components/EditCustomCssModal';
import EditGroupModal from './components/EditGroupModal';
import routes from './routes';
@@ -35,21 +30,17 @@ import AdminApplication from './AdminApplication';
export default Object.assign(compat, {
'utils/saveSettings': saveSettings,
'utils/ExtensionData': ExtensionData,
'utils/isExtensionEnabled': isExtensionEnabled,
'utils/getCategorizedExtensions': getCategorizedExtensions,
'components/SettingDropdown': SettingDropdown,
'components/EditCustomFooterModal': EditCustomFooterModal,
'components/SessionDropdown': SessionDropdown,
'components/HeaderPrimary': HeaderPrimary,
'components/AppearancePage': AppearancePage,
'components/StatusWidget': StatusWidget,
'components/ExtensionsWidget': ExtensionsWidget,
'components/HeaderSecondary': HeaderSecondary,
'components/SettingsModal': SettingsModal,
'components/DashboardWidget': DashboardWidget,
'components/ExtensionPage': ExtensionPage,
'components/ExtensionLinkButton': ExtensionLinkButton,
'components/AddExtensionModal': AddExtensionModal,
'components/ExtensionsPage': ExtensionsPage,
'components/AdminLinkButton': AdminLinkButton,
'components/PermissionGrid': PermissionGrid,
'components/MailPage': MailPage,
@@ -61,7 +52,6 @@ export default Object.assign(compat, {
'components/PermissionsPage': PermissionsPage,
'components/PermissionDropdown': PermissionDropdown,
'components/AdminNav': AdminNav,
'components/AdminHeader': AdminHeader,
'components/EditCustomCssModal': EditCustomCssModal,
'components/EditGroupModal': EditGroupModal,
routes: routes,

View File

@@ -1,19 +0,0 @@
import Component from '../../common/Component';
import classList from '../../common/utils/classList';
import icon from '../../common/helpers/icon';
export default class AdminHeader extends Component {
view(vnode) {
return [
<div className={classList(['AdminHeader', this.attrs.className])}>
<div className="container">
<h2>
{icon(this.attrs.icon)}
{vnode.children}
</h2>
<div className="AdminHeader-description">{this.attrs.description}</div>
</div>
</div>,
];
}
}

View File

@@ -1,28 +1,28 @@
import ExtensionLinkButton from './ExtensionLinkButton';
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import Component from '../../common/Component';
import LinkButton from '../../common/components/LinkButton';
import AdminLinkButton from './AdminLinkButton';
import SelectDropdown from '../../common/components/SelectDropdown';
import getCategorizedExtensions from '../utils/getCategorizedExtensions';
import ItemList from '../../common/utils/ItemList';
import Stream from '../../common/utils/Stream';
export default class AdminNav extends Component {
oninit(vnode) {
super.oninit(vnode);
this.query = Stream('');
}
view() {
return (
<SelectDropdown className="AdminNav App-titleControl AdminNav-Main" buttonClassName="Button">
{this.items().toArray().concat(this.extensionItems().toArray())}
<SelectDropdown className="AdminNav App-titleControl" buttonClassName="Button">
{this.items().toArray()}
</SelectDropdown>
);
}
/**
* Build an item list of main links to show in the admin navigation.
* Build an item list of links to show in the admin navigation.
*
* @return {ItemList}
*/
@@ -31,90 +31,76 @@ export default class AdminNav extends Component {
items.add(
'dashboard',
<LinkButton href={app.route('dashboard')} icon="far fa-chart-bar" title={app.translator.trans('core.admin.nav.dashboard_title')}>
{app.translator.trans('core.admin.nav.dashboard_button')}
</LinkButton>
AdminLinkButton.component(
{
href: app.route('dashboard'),
icon: 'far fa-chart-bar',
description: app.translator.trans('core.admin.nav.dashboard_text'),
},
app.translator.trans('core.admin.nav.dashboard_button')
)
);
items.add(
'basics',
<LinkButton href={app.route('basics')} icon="fas fa-pencil-alt" title={app.translator.trans('core.admin.nav.basics_title')}>
{app.translator.trans('core.admin.nav.basics_button')}
</LinkButton>
AdminLinkButton.component(
{
href: app.route('basics'),
icon: 'fas fa-pencil-alt',
description: app.translator.trans('core.admin.nav.basics_text'),
},
app.translator.trans('core.admin.nav.basics_button')
)
);
items.add(
'mail',
<LinkButton href={app.route('mail')} icon="fas fa-envelope" title={app.translator.trans('core.admin.nav.email_title')}>
{app.translator.trans('core.admin.nav.email_button')}
</LinkButton>
AdminLinkButton.component(
{
href: app.route('mail'),
icon: 'fas fa-envelope',
description: app.translator.trans('core.admin.nav.email_text'),
},
app.translator.trans('core.admin.nav.email_button')
)
);
items.add(
'permissions',
<LinkButton href={app.route('permissions')} icon="fas fa-key" title={app.translator.trans('core.admin.nav.permissions_title')}>
{app.translator.trans('core.admin.nav.permissions_button')}
</LinkButton>
AdminLinkButton.component(
{
href: app.route('permissions'),
icon: 'fas fa-key',
description: app.translator.trans('core.admin.nav.permissions_text'),
},
app.translator.trans('core.admin.nav.permissions_button')
)
);
items.add(
'appearance',
<LinkButton href={app.route('appearance')} icon="fas fa-paint-brush" title={app.translator.trans('core.admin.nav.appearance_title')}>
{app.translator.trans('core.admin.nav.appearance_button')}
</LinkButton>
AdminLinkButton.component(
{
href: app.route('appearance'),
icon: 'fas fa-paint-brush',
description: app.translator.trans('core.admin.nav.appearance_text'),
},
app.translator.trans('core.admin.nav.appearance_button')
)
);
items.add(
'search',
<div className="Search-input">
<input
className="FormControl SearchBar"
bidi={this.query}
type="search"
placeholder={app.translator.trans('core.admin.nav.search_placeholder')}
/>
</div>
'extensions',
AdminLinkButton.component(
{
href: app.route('extensions'),
icon: 'fas fa-puzzle-piece',
description: app.translator.trans('core.admin.nav.extensions_text'),
},
app.translator.trans('core.admin.nav.extensions_button')
)
);
return items;
}
extensionItems() {
const items = new ItemList();
const categorizedExtensions = getCategorizedExtensions();
const categories = app.extensionCategories;
Object.keys(categorizedExtensions).map((category) => {
if (!this.query()) {
items.add(
category,
<h4 className="ExtensionListTitle">{app.translator.trans(`core.admin.nav.categories.${category}`)}</h4>,
categories[category]
);
}
categorizedExtensions[category].map((extension) => {
const query = this.query().toUpperCase();
const title = extension.extra['flarum-extension'].title;
if (!query || title.toUpperCase().includes(query) || extension.description.toUpperCase().includes(query)) {
items.add(
extension.id,
<ExtensionLinkButton
href={app.route('extension', { id: extension.id })}
extensionId={extension.id}
className="ExtensionNavButton"
title={extension.description}
>
{title}
</ExtensionLinkButton>,
categories[category]
);
}
});
});
return items;
}
}

View File

@@ -7,7 +7,6 @@ import EditCustomHeaderModal from './EditCustomHeaderModal';
import EditCustomFooterModal from './EditCustomFooterModal';
import UploadImageButton from './UploadImageButton';
import saveSettings from '../utils/saveSettings';
import AdminHeader from './AdminHeader';
export default class AppearancePage extends Page {
oninit(vnode) {
@@ -22,13 +21,6 @@ export default class AppearancePage extends Page {
view() {
return (
<div className="AppearancePage">
<AdminHeader
icon="fas fa-paint-brush"
description={app.translator.trans('core.admin.appearance.description')}
className="AppearancePage-header"
>
{app.translator.trans('core.admin.appearance.title')}
</AdminHeader>
<div className="container">
<form onsubmit={this.onsubmit.bind(this)}>
<fieldset className="AppearancePage-colors">

View File

@@ -7,7 +7,6 @@ import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch';
import Stream from '../../common/utils/Stream';
import withAttr from '../../common/utils/withAttr';
import AdminHeader from './AdminHeader';
export default class BasicsPage extends Page {
oninit(vnode) {
@@ -50,9 +49,6 @@ export default class BasicsPage extends Page {
view() {
return (
<div className="BasicsPage">
<AdminHeader icon="fas fa-pencil-alt" description={app.translator.trans('core.admin.basics.description')} className="BasicsPage-header">
{app.translator.trans('core.admin.basics.title')}
</AdminHeader>
<div className="container">
<form onsubmit={this.onsubmit.bind(this)}>
{FieldSet.component(

View File

@@ -1,29 +1,16 @@
import Page from '../../common/components/Page';
import StatusWidget from './StatusWidget';
import ExtensionsWidget from './ExtensionsWidget';
import AdminHeader from './AdminHeader';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
export default class DashboardPage extends Page {
view() {
return (
<div className="DashboardPage">
<AdminHeader icon="fas fa-chart-bar" description={app.translator.trans('core.admin.dashboard.description')} className="DashboardPage-header">
{app.translator.trans('core.admin.dashboard.title')}
</AdminHeader>
<div className="container">{this.availableWidgets().toArray()}</div>
<div className="container">{this.availableWidgets()}</div>
</div>
);
}
availableWidgets() {
const items = new ItemList();
items.add('status', <StatusWidget />, 30);
items.add('extensions', <ExtensionsWidget />, 10);
return items;
return [<StatusWidget />];
}
}

View File

@@ -1,29 +0,0 @@
import isExtensionEnabled from '../utils/isExtensionEnabled';
import LinkButton from '../../common/components/LinkButton';
import icon from '../../common/helpers/icon';
import ItemList from '../../common/utils/ItemList';
export default class ExtensionLinkButton extends LinkButton {
getButtonContent(children) {
const content = super.getButtonContent(children);
const extension = app.data.extensions[this.attrs.extensionId];
const statuses = this.statusItems(extension.id).toArray();
content.unshift(
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
{extension.icon ? icon(extension.icon.name) : ''}
</span>
);
content.push(statuses);
return content;
}
statusItems(name) {
const items = new ItemList();
items.add('enabled', <span class={'ExtensionListItem-Dot ' + (isExtensionEnabled(name) ? 'enabled' : 'disabled')} />);
return items;
}
}

View File

@@ -1,376 +0,0 @@
import Button from '../../common/components/Button';
import Link from '../../common/components/Link';
import LinkButton from '../../common/components/LinkButton';
import Page from '../../common/components/Page';
import Select from '../../common/components/Select';
import Switch from '../../common/components/Switch';
import icon from '../../common/helpers/icon';
import punctuateSeries from '../../common/helpers/punctuateSeries';
import listItems from '../../common/helpers/listItems';
import ItemList from '../../common/utils/ItemList';
import Stream from '../../common/utils/Stream';
import LoadingModal from './LoadingModal';
import ExtensionPermissionGrid from './ExtensionPermissionGrid';
import saveSettings from '../utils/saveSettings';
import ExtensionData from '../utils/ExtensionData';
import isExtensionEnabled from '../utils/isExtensionEnabled';
export default class ExtensionPage extends Page {
oninit(vnode) {
super.oninit(vnode);
this.loading = false;
this.extension = app.data.extensions[this.attrs.id];
this.changingState = false;
this.settings = {};
this.infoFields = {
discuss: 'fas fa-comment-alt',
documentation: 'fas fa-book',
support: 'fas fa-life-ring',
website: 'fas fa-link',
donate: 'fas fa-donate',
};
// Backwards compatibility layer will be removed in
// Beta 16
if (app.extensionSettings[this.extension.id]) {
app.extensionData[this.extension.id] = app.extensionSettings[this.extension.id];
}
}
className() {
return this.extension.id + '-Page';
}
view() {
return (
<div className={'ExtensionPage ' + this.className()}>
{this.header()}
{!this.isEnabled() ? (
<div className="container">
<h2 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.enable_to_see')}</h2>
</div>
) : (
<div className="ExtensionPage-body">{this.sections().toArray()}</div>
)}
</div>
);
}
header() {
return [
<div className="ExtensionPage-header">
<div className="container">
<div className="ExtensionTitle">
<span className="ExtensionIcon" style={this.extension.icon}>
{this.extension.icon ? icon(this.extension.icon.name) : ''}
</span>
<div className="ExtensionName">
<h2>{this.extension.extra['flarum-extension'].title}</h2>
</div>
<div className="ExtensionPage-headerTopItems">
<ul>{listItems(this.topItems().toArray())}</ul>
</div>
</div>
<div className="helpText">{this.extension.description}</div>
<div className="ExtensionPage-headerItems">
<Switch state={this.isEnabled()} onchange={this.toggle.bind(this, this.extension.id)}>
{this.isEnabled(this.extension.id)
? app.translator.trans('core.admin.extension.enabled')
: app.translator.trans('core.admin.extension.disabled')}
</Switch>
<aside className="ExtensionInfo">
<ul>{listItems(this.infoItems().toArray())}</ul>
</aside>
</div>
</div>
</div>,
];
}
sections() {
const items = new ItemList();
items.add('content', this.content());
items.add('permissions', [
<div className="ExtensionPage-permissions">
<div className="ExtensionPage-permissions-header">
<div className="container">
<h2 className="ExtensionTitle">{app.translator.trans('core.admin.extension.permissions_title')}</h2>
</div>
</div>
<div className="container">
{app.extensionData.extensionHasPermissions(this.extension.id) ? (
ExtensionPermissionGrid.component({ extensionId: this.extension.id })
) : (
<h2 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_permissions')}</h2>
)}
</div>
</div>,
]);
return items;
}
content() {
const settings = app.extensionData.getSettings(this.extension.id);
return (
<div className="ExtensionPage-settings">
<div className="container">
{typeof app.extensionData[this.extension.id] === 'function' ? (
<Button onclick={app.extensionData[this.extension.id].bind(this)} className="Button Button--primary">
{app.translator.trans('core.admin.extension.open_modal')}
</Button>
) : settings ? (
<div className="Form">
{settings.map(this.buildSettingComponent.bind(this))}
<div className="Form-group">{this.submitButton()}</div>
</div>
) : (
<h2 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_settings')}</h2>
)}
</div>
</div>
);
}
topItems() {
const items = new ItemList();
items.add('version', <span className="ExtensionVersion">{this.extension.version}</span>);
if (!this.isEnabled()) {
const uninstall = () => {
if (confirm(app.translator.trans('core.admin.extension.confirm_uninstall'))) {
app
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + this.extension.id,
method: 'DELETE',
})
.then(() => window.location.reload());
app.modal.show(LoadingModal);
}
};
items.add(
'uninstall',
<Button icon="fas fa-trash-alt" className="Button Button--primary" onclick={uninstall.bind(this)}>
{app.translator.trans('core.admin.extension.uninstall_button')}
</Button>
);
}
return items;
}
infoItems() {
const items = new ItemList();
if (this.extension.authors) {
let authors = [];
Object.keys(this.extension.authors).map((author, i) => {
const link = this.extension.authors[author].homepage
? this.extension.authors[author].homepage
: 'mailto:' + this.extension.authors[author].email;
authors.push(
<Link href={link} external={true} target="_blank">
{this.extension.authors[author].name}
</Link>
);
});
items.add('authors', [icon('fas fa-user'), <span>{punctuateSeries(authors)}</span>]);
}
const infoData = {};
if (this.extension.source || this.extension.support) {
infoData.source = {
icon: 'fas fa-code',
href: this.extension.source ? this.extension.source.url : this.extension.support.source,
};
}
Object.keys(this.infoFields).map((field) => {
const info = this.extension.extra['flarum-extension'].info;
if (info && info[field]) {
infoData[field] = {
icon: this.infoFields[field],
href: info[field],
};
}
});
Object.entries(infoData).map(([field, value]) => {
items.add(
field,
<LinkButton href={value.href} icon={value.icon} external={true} target="_blank">
{app.translator.trans(`core.admin.extension.info_links.${field}`)}
</LinkButton>
);
});
return items;
}
submitButton() {
return (
<Button onclick={this.saveSettings.bind(this)} className="Button Button--primary" loading={this.loading} disabled={!this.isChanged()}>
{app.translator.trans('core.admin.settings.submit_button')}
</Button>
);
}
/**
* getSetting takes a settings object and turns it into a component.
* Depending on the type of input, you can set the type to 'bool', 'select', or
* any standard <input> type.
*
* @example
*
* {
* setting: 'acme.checkbox',
* label: app.translator.trans('acme.admin.setting_label'),
* type: 'bool'
* }
*
* @example
*
* {
* setting: 'acme.select',
* label: app.translator.trans('acme.admin.setting_label'),
* type: 'select',
* options: {
* 'option1': 'Option 1 label',
* 'option2': 'Option 2 label',
* },
* default: 'option1',
* }
*
* @param setting
* @returns {JSX.Element}
*/
buildSettingComponent(entry) {
const setting = entry.setting;
const value = this.setting([setting])();
if (['bool', 'checkbox', 'switch', 'boolean'].includes(entry.type)) {
return (
<div className="Form-group">
<Switch state={!!value && value !== '0'} onchange={this.settings[setting]}>
{entry.label}
</Switch>
</div>
);
} else if (['select', 'dropdown', 'selectdropdown'].includes(entry.type)) {
return (
<div className="Form-group">
<label>{entry.label}</label>
<Select value={value || entry.default} options={entry.options} buttonClassName="Button" onchange={this.settings[setting]} />
</div>
);
} else {
return (
<div className="Form-group">
<label>{entry.label}</label>
<input type={entry.type} className="FormControl" bidi={this.setting(setting)} />
</div>
);
}
}
toggle() {
const enabled = this.isEnabled();
this.changingState = true;
app
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + this.extension.id,
method: 'PATCH',
body: { enabled: !enabled },
errorHandler: this.onerror.bind(this),
})
.then(() => {
if (!enabled) localStorage.setItem('enabledExtension', this.extension.id);
window.location.reload();
});
app.modal.show(LoadingModal);
}
dirty() {
const dirty = {};
Object.keys(this.settings).forEach((key) => {
const value = this.settings[key]();
if (value !== app.data.settings[key]) {
dirty[key] = value;
}
});
return dirty;
}
isChanged() {
return Object.keys(this.dirty()).length;
}
saveSettings(e) {
e.preventDefault();
app.alerts.clear();
this.loading = true;
saveSettings(this.dirty()).then(this.onsaved.bind(this));
}
onsaved() {
this.loading = false;
app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.extension.saved_message'));
}
setting(key, fallback = '') {
this.settings[key] = this.settings[key] || Stream(app.data.settings[key] || fallback);
return this.settings[key];
}
isEnabled() {
let isEnabled = isExtensionEnabled(this.extension.id);
return this.changingState ? !isEnabled : isEnabled;
}
onerror(e) {
// We need to give the modal animation time to start; if we close the modal too early,
// it breaks the bootstrap modal library.
// TODO: This workaround should be removed when we move away from bootstrap JS for modals.
setTimeout(() => {
app.modal.close();
}, 300); // Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
if (e.status !== 409) {
throw e;
}
const error = e.response.errors[0];
app.alerts.show(
{ type: 'error' },
app.translator.trans(`core.lib.error.${error.code}_message`, {
extension: error.extension,
extensions: error.extensions.join(', '),
})
);
}
}

View File

@@ -1,39 +0,0 @@
import PermissionGrid from './PermissionGrid';
import ItemList from '../../common/utils/ItemList';
export default class ExtensionPermissionGrid extends PermissionGrid {
oninit(vnode) {
super.oninit(vnode);
this.extensionId = this.attrs.extensionId;
}
permissionItems() {
const permissionCategories = super.permissionItems();
permissionCategories.items = Object.entries(permissionCategories.items)
.filter(([category, info]) => info.content.children.length > 0)
.reduce((obj, [category, info]) => {
obj[category] = info;
return obj;
}, {});
return permissionCategories;
}
viewItems() {
return app.extensionData.getExtensionPermissions(this.extensionId, 'view') || new ItemList();
}
startItems() {
return app.extensionData.getExtensionPermissions(this.extensionId, 'start') || new ItemList();
}
replyItems() {
return app.extensionData.getExtensionPermissions(this.extensionId, 'reply') || new ItemList();
}
moderateItems() {
return app.extensionData.getExtensionPermissions(this.extensionId, 'moderate') || new ItemList();
}
}

View File

@@ -0,0 +1,154 @@
import Page from '../../common/components/Page';
import Button from '../../common/components/Button';
import Dropdown from '../../common/components/Dropdown';
import AddExtensionModal from './AddExtensionModal';
import LoadingModal from './LoadingModal';
import ItemList from '../../common/utils/ItemList';
import icon from '../../common/helpers/icon';
export default class ExtensionsPage extends Page {
view() {
return (
<div className="ExtensionsPage">
<div className="ExtensionsPage-header">
<div className="container">
{Button.component(
{
icon: 'fas fa-plus',
className: 'Button Button--primary',
onclick: () => app.modal.show(AddExtensionModal),
},
app.translator.trans('core.admin.extensions.add_button')
)}
</div>
</div>
<div className="ExtensionsPage-list">
<div className="container">
<ul className="ExtensionList">
{Object.keys(app.data.extensions).map((id) => {
const extension = app.data.extensions[id];
const controls = this.controlItems(extension.id).toArray();
return (
<li className={'ExtensionListItem ' + (!this.isEnabled(extension.id) ? 'disabled' : '')}>
<div className="ExtensionListItem-content">
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
{extension.icon ? icon(extension.icon.name) : ''}
</span>
{controls.length ? (
<Dropdown
className="ExtensionListItem-controls"
buttonClassName="Button Button--icon Button--flat"
menuClassName="Dropdown-menu--right"
icon="fas fa-ellipsis-h"
>
{controls}
</Dropdown>
) : (
''
)}
<div className="ExtensionListItem-main">
<label className="ExtensionListItem-title">
<input type="checkbox" checked={this.isEnabled(extension.id)} onclick={this.toggle.bind(this, extension.id)} />{' '}
{extension.extra['flarum-extension'].title}
</label>
<div className="ExtensionListItem-version">{extension.version}</div>
<div className="ExtensionListItem-description">{extension.description}</div>
</div>
</div>
</li>
);
})}
</ul>
</div>
</div>
</div>
);
}
controlItems(name) {
const items = new ItemList();
const enabled = this.isEnabled(name);
if (app.extensionSettings[name]) {
items.add(
'settings',
Button.component(
{
icon: 'fas fa-cog',
onclick: app.extensionSettings[name],
},
app.translator.trans('core.admin.extensions.settings_button')
)
);
}
if (!enabled) {
items.add(
'uninstall',
Button.component(
{
icon: 'far fa-trash-alt',
onclick: () => {
app
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + name,
method: 'DELETE',
})
.then(() => window.location.reload());
app.modal.show(LoadingModal);
},
},
app.translator.trans('core.admin.extensions.uninstall_button')
)
);
}
return items;
}
isEnabled(name) {
const enabled = JSON.parse(app.data.settings.extensions_enabled);
return enabled.indexOf(name) !== -1;
}
toggle(id) {
const enabled = this.isEnabled(id);
app
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + id,
method: 'PATCH',
body: { enabled: !enabled },
errorHandler: this.onerror.bind(this),
})
.then(() => {
if (!enabled) localStorage.setItem('enabledExtension', id);
window.location.reload();
});
app.modal.show(LoadingModal);
}
onerror(e) {
// We need to give the modal animation time to start; if we close the modal too early,
// it breaks the bootstrap modal library.
// TODO: This workaround should be removed when we move away from bootstrap JS for modals.
setTimeout(() => {
app.modal.close();
const error = JSON.parse(e.responseText).errors[0];
app.alerts.show(
{ type: 'error' },
app.translator.trans(`core.lib.error.${error.code}_message`, {
extension: error.extension,
extensions: error.extensions.join(', '),
})
);
}, 250);
}
}

View File

@@ -1,48 +0,0 @@
import DashboardWidget from './DashboardWidget';
import isExtensionEnabled from '../utils/isExtensionEnabled';
import getCategorizedExtensions from '../utils/getCategorizedExtensions';
import Link from '../../common/components/Link';
import icon from '../../common/helpers/icon';
export default class ExtensionsWidget extends DashboardWidget {
className() {
return 'ExtensionsWidget';
}
content() {
const categorizedExtensions = getCategorizedExtensions();
const categories = app.extensionCategories;
return (
<div className="ExtensionsWidget-list">
<div className="container">
{Object.keys(categories).map((category) => {
if (categorizedExtensions[category]) {
return (
<div className="ExtensionList-Category">
<h4 className="ExtensionList-Label">{app.translator.trans(`core.admin.nav.categories.${category}`)}</h4>
<ul className="ExtensionList">
{categorizedExtensions[category].map((extension) => {
return (
<li className={'ExtensionListItem ' + (!isExtensionEnabled(extension.id) ? 'disabled' : '')}>
<Link href={app.route('extension', { id: extension.id })}>
<div className="ExtensionListItem-content">
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
{extension.icon ? icon(extension.icon.name) : ''}
</span>
<span className="ExtensionListItem-title">{extension.extra['flarum-extension'].title}</span>
</div>
</Link>
</li>
);
})}
</ul>
</div>
);
}
})}
</div>
</div>
);
}
}

View File

@@ -1,5 +1,4 @@
import Component from '../../common/Component';
import LinkButton from '../../common/components/LinkButton';
import SessionDropdown from './SessionDropdown';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
@@ -20,13 +19,6 @@ export default class HeaderSecondary extends Component {
items() {
const items = new ItemList();
items.add(
'help',
<LinkButton href="https://docs.flarum.org/troubleshoot.html" icon="fas fa-question-circle" external={true} target="_blank">
{app.translator.trans('core.admin.header.get_help')}
</LinkButton>
);
items.add('session', SessionDropdown.component());
return items;

View File

@@ -6,8 +6,6 @@ import Select from '../../common/components/Select';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import saveSettings from '../utils/saveSettings';
import Stream from '../../common/utils/Stream';
import icon from '../../common/helpers/icon';
import AdminHeader from './AdminHeader';
export default class MailPage extends Page {
oninit(vnode) {
@@ -67,11 +65,11 @@ export default class MailPage extends Page {
return (
<div className="MailPage">
<AdminHeader icon="fas fa-envelope" description={app.translator.trans('core.admin.email.description')} className="MailPage-header">
{app.translator.trans('core.admin.email.title')}
</AdminHeader>
<div className="container">
<form onsubmit={this.onsubmit.bind(this)}>
<h2>{app.translator.trans('core.admin.email.heading')}</h2>
<div className="helpText">{app.translator.trans('core.admin.email.text')}</div>
{FieldSet.component(
{
label: app.translator.trans('core.admin.email.addresses_heading'),

View File

@@ -6,6 +6,12 @@ import ItemList from '../../common/utils/ItemList';
import icon from '../../common/helpers/icon';
export default class PermissionGrid extends Component {
oninit(vnode) {
super.oninit(vnode);
this.permissions = this.permissionItems().toArray();
}
view() {
const scopes = this.scopeItems().toArray();
@@ -29,27 +35,25 @@ export default class PermissionGrid extends Component {
<th>{this.scopeControlItems().toArray()}</th>
</tr>
</thead>
{this.permissionItems()
.toArray()
.map((section) => (
<tbody>
<tr className="PermissionGrid-section">
<th>{section.label}</th>
{permissionCells(section)}
{this.permissions.map((section) => (
<tbody>
<tr className="PermissionGrid-section">
<th>{section.label}</th>
{permissionCells(section)}
<td />
</tr>
{section.children.map((child) => (
<tr className="PermissionGrid-child">
<th>
{icon(child.icon)}
{child.label}
</th>
{permissionCells(child)}
<td />
</tr>
{section.children.map((child) => (
<tr className="PermissionGrid-child">
<th>
{icon(child.icon)}
{child.label}
</th>
{permissionCells(child)}
<td />
</tr>
))}
</tbody>
))}
))}
</tbody>
))}
</table>
);
}
@@ -154,8 +158,6 @@ export default class PermissionGrid extends Component {
permission: 'user.viewLastSeenAt',
});
items.merge(app.extensionData.getAllExtensionPermissions('view'));
return items;
}
@@ -196,8 +198,6 @@ export default class PermissionGrid extends Component {
90
);
items.merge(app.extensionData.getAllExtensionPermissions('start'));
return items;
}
@@ -238,8 +238,6 @@ export default class PermissionGrid extends Component {
90
);
items.merge(app.extensionData.getAllExtensionPermissions('reply'));
return items;
}
@@ -336,8 +334,6 @@ export default class PermissionGrid extends Component {
60
);
items.merge(app.extensionData.getAllExtensionPermissions('moderate'));
return items;
}

View File

@@ -4,15 +4,11 @@ import EditGroupModal from './EditGroupModal';
import Group from '../../common/models/Group';
import icon from '../../common/helpers/icon';
import PermissionGrid from './PermissionGrid';
import AdminHeader from './AdminHeader';
export default class PermissionsPage extends Page {
view() {
return (
<div className="PermissionsPage">
<AdminHeader icon="fas fa-key" description={app.translator.trans('core.admin.permissions.description')} className="PermissionsPage-header">
{app.translator.trans('core.admin.permissions.title')}
</AdminHeader>
<div className="PermissionsPage-groups">
<div className="container">
{app.store

View File

@@ -1,19 +0,0 @@
import DefaultResolver from '../../common/resolvers/DefaultResolver';
/**
* A custom route resolver for ExtensionPage that generates handles routes
* to default extension pages or a page provided by an extension.
*/
export default class ExtensionPageResolver extends DefaultResolver {
static extension: string | null = null;
onmatch(args, requestedPath, route) {
const extensionPage = app.extensionData.getPage(args.id);
if (extensionPage) {
return extensionPage;
}
return super.onmatch(args, requestedPath, route);
}
}

View File

@@ -2,9 +2,8 @@ import DashboardPage from './components/DashboardPage';
import BasicsPage from './components/BasicsPage';
import PermissionsPage from './components/PermissionsPage';
import AppearancePage from './components/AppearancePage';
import ExtensionsPage from './components/ExtensionsPage';
import MailPage from './components/MailPage';
import ExtensionPage from './components/ExtensionPage';
import ExtensionPageResolver from './resolvers/ExtensionPageResolver';
/**
* The `routes` initializer defines the forum app's routes.
@@ -17,7 +16,7 @@ export default function (app) {
basics: { path: '/basics', component: BasicsPage },
permissions: { path: '/permissions', component: PermissionsPage },
appearance: { path: '/appearance', component: AppearancePage },
extensions: { path: '/extensions', component: ExtensionsPage },
mail: { path: '/mail', component: MailPage },
extension: { path: '/extension/:id', component: ExtensionPage, resolverClass: ExtensionPageResolver },
};
}

View File

@@ -1,167 +0,0 @@
import ItemList from '../../common/utils/ItemList';
export default class ExtensionData {
constructor() {
this.data = {};
this.currentExtension = null;
}
/**
* This function simply takes the extension id
*
* @example
* app.extensionData.load('flarum-tags')
*
* flarum/flags -> flarum-flags | acme/extension -> acme-extension
*
* @param extension
*/
for(extension) {
this.currentExtension = extension;
this.data[extension] = this.data[extension] || {};
return this;
}
/**
* This function registers your settings with Flarum
*
* @example
*
* .registerSetting({
* setting: 'flarum-flags.guidelines_url',
* type: 'text', // This will be inputted into the input tag for the setting (text/number/etc)
* label: app.translator.trans('flarum-flags.admin.settings.guidelines_url_label')
* }, 15) // priority is optional (ItemList)
*
*
* @param content
* @param priority
* @returns {ExtensionData}
*/
registerSetting(content, priority = 0) {
this.data[this.currentExtension].settings = this.data[this.currentExtension].settings || new ItemList();
this.data[this.currentExtension].settings.add(content.setting, content, priority);
return this;
}
/**
* This function registers your permission with Flarum
*
* @example
*
* .registerPermission('permissions', {
* icon: 'fas fa-flag',
* label: app.translator.trans('flarum-flags.admin.permissions.view_flags_label'),
* permission: 'discussion.viewFlags'
* }, 'moderate', 65)
*
* @param content
* @param permissionType
* @param priority
* @returns {ExtensionData}
*/
registerPermission(content, permissionType = null, priority = 0) {
this.data[this.currentExtension].permissions = this.data[this.currentExtension].permissions || {};
if (!this.data[this.currentExtension].permissions[permissionType]) {
this.data[this.currentExtension].permissions[permissionType] = new ItemList();
}
this.data[this.currentExtension].permissions[permissionType].add(content.permission, content, priority);
return this;
}
/**
* Replace the default extension page with a custom component.
* This component would typically extend ExtensionPage
*
* @param component
* @returns {ExtensionData}
*/
registerPage(component) {
this.data[this.currentExtension].page = component;
return this;
}
/**
* Get an extension's registered settings
*
* @param extensionId
* @returns {boolean|*}
*/
getSettings(extensionId) {
if (this.data[extensionId] && this.data[extensionId].settings) {
return this.data[extensionId].settings.toArray();
}
return false;
}
/**
*
* Get an ItemList of all extensions' registered permissions
*
* @param extension
* @param type
* @returns {ItemList}
*/
getAllExtensionPermissions(type) {
const items = new ItemList();
Object.keys(this.data).map((extension) => {
if (this.extensionHasPermissions(extension) && this.data[extension].permissions[type]) {
items.merge(this.data[extension].permissions[type]);
}
});
return items;
}
/**
* Get a singular extension's registered permissions
*
* @param extension
* @param type
* @returns {boolean|*}
*/
getExtensionPermissions(extension, type) {
if (this.extensionHasPermissions(extension) && this.data[extension].permissions[type]) {
return this.data[extension].permissions[type];
}
return new ItemList();
}
/**
* Checks whether a given extension has registered permissions.
*
* @param extension
* @returns {boolean}
*/
extensionHasPermissions(extension) {
if (this.data[extension] && this.data[extension].permissions) {
return true;
}
return false;
}
/**
* Returns an extension's custom page component if it exists.
*
* @param extension
* @returns {boolean|*}
*/
getPage(extension) {
if (this.data[extension]) {
return this.data[extension].page;
}
return false;
}
}

View File

@@ -1,25 +0,0 @@
export default function getCategorizedExtensions() {
let extensions = {};
Object.keys(app.data.extensions).map((id) => {
const extension = app.data.extensions[id];
let category = extension.extra['flarum-extension'].category;
// Wrap languages packs into new system
if (extension.extra['flarum-locale']) {
category = 'language';
}
if (category in app.extensionCategories) {
extensions[category] = extensions[category] || [];
extensions[category].push(extension);
} else {
extensions.other = extensions.other || [];
extensions.other.push(extension);
}
});
return extensions;
}

View File

@@ -1,5 +0,0 @@
export default function isExtensionEnabled(name) {
const enabled = JSON.parse(app.data.settings.extensions_enabled);
return enabled.includes(name);
}

View File

@@ -198,19 +198,13 @@ export default class Application {
m.route(document.getElementById('content'), basePath + '/', mapRoutes(this.routes, basePath));
// Add a class to the body which indicates that the page has been scrolled
// down. When this happens, we'll add classes to the header and app body
// which will set the navbar's position to fixed. We don't want to always
// have it fixed, as that could overlap with custom headers.
const scrollListener = new ScrollListener((top) => {
// down.
new ScrollListener((top) => {
const $app = $('#app');
const offset = $app.offset().top;
$app.toggleClass('affix', top >= offset).toggleClass('scrolled', top > offset);
$('.App-header').toggleClass('navbar-fixed-top', top >= offset);
});
scrollListener.start();
scrollListener.update();
}).start();
$(() => {
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
@@ -270,7 +264,7 @@ export default class Application {
updateTitle() {
const count = this.titleCount ? `(${this.titleCount}) ` : '';
const pageTitleWithSeparator = this.title && m.route.get() !== this.forum.attribute('basePath') + '/' ? this.title + ' - ' : '';
const pageTitleWithSeparator = this.title && m.route.get() !== '/' ? this.title + ' - ' : '';
const title = this.forum.attribute('title');
document.title = count + pageTitleWithSeparator + title;
}

View File

@@ -1,5 +1,8 @@
import * as Mithril from 'mithril';
let deprecatedPropsWarned = false;
let deprecatedInitPropsWarned = false;
export interface ComponentAttrs extends Mithril.Attributes {}
/**
@@ -128,5 +131,38 @@ export default abstract class Component<T extends ComponentAttrs = ComponentAttr
*
* This can be used to assign default values for missing, optional attrs.
*/
protected static initAttrs<T>(attrs: T): void {}
protected static initAttrs<T>(attrs: T): void {
// Deprecated, part of Mithril 2 BC layer
if ('initProps' in this && !deprecatedInitPropsWarned) {
deprecatedInitPropsWarned = true;
console.warn('initProps is deprecated, please use initAttrs instead.');
(this as any).initProps(attrs);
}
}
// BEGIN DEPRECATED MITHRIL 2 BC LAYER
/**
* The attributes passed into the component.
*
* @see https://mithril.js.org/components.html#passing-data-to-components
*
* @deprecated, use attrs instead.
*/
get props() {
if (!deprecatedPropsWarned) {
deprecatedPropsWarned = true;
console.warn('this.props is deprecated, please use this.attrs instead.');
}
return this.attrs;
}
set props(props) {
if (!deprecatedPropsWarned) {
deprecatedPropsWarned = true;
console.warn('this.props is deprecated, please use this.attrs instead.');
}
this.attrs = props;
}
// END DEPRECATED MITHRIL 2 BC LAYER
}

View File

@@ -19,7 +19,6 @@ import extract from './utils/extract';
import ScrollListener from './utils/ScrollListener';
import stringToColor from './utils/stringToColor';
import subclassOf from './utils/subclassOf';
import SuperTextarea from './utils/SuperTextarea';
import patchMithril from './utils/patchMithril';
import classList from './utils/classList';
import extractText from './utils/extractText';
@@ -68,7 +67,6 @@ import username from './helpers/username';
import userOnline from './helpers/userOnline';
import listItems from './helpers/listItems';
import Fragment from './Fragment';
import DefaultResolver from './resolvers/DefaultResolver';
export default {
extend: extend,
@@ -91,7 +89,6 @@ export default {
'utils/stringToColor': stringToColor,
'utils/Stream': Stream,
'utils/subclassOf': subclassOf,
'utils/SuperTextarea': SuperTextarea,
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
'utils/patchMithril': patchMithril,
'utils/classList': classList,
@@ -141,5 +138,4 @@ export default {
'helpers/username': username,
'helpers/userOnline': userOnline,
'helpers/listItems': listItems,
'resolvers/DefaultResolver': DefaultResolver,
};

View File

@@ -35,11 +35,6 @@ export default class Button extends Component {
attrs['aria-label'] = attrs.title;
}
// If given a translation object, extract the text.
if (typeof attrs.title === 'object') {
attrs.title = extractText(attrs.title);
}
// If nothing else is provided, we use the textual button content as tooltip
if (!attrs.title && vnode.children) {
attrs.title = extractText(vnode.children);

View File

@@ -12,14 +12,12 @@ import Link from './Link';
* active.
* - `href` The URL to link to. If the current URL `m.route()` matches this,
* the `active` prop will automatically be set to true.
* - `force` Whether the page should be fully rerendered. Defaults to `true`.
*/
export default class LinkButton extends Button {
static initAttrs(attrs) {
super.initAttrs(attrs);
attrs.active = this.isActive(attrs);
if (attrs.force === undefined) attrs.force = true;
}
view(vnode) {

View File

@@ -35,8 +35,7 @@ export default class Modal extends Component {
this.attrs.animateHide();
// Here, we ensure that the animation has time to complete.
// See https://mithril.js.org/lifecycle-methods.html#onbeforeremove
// Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
return new Promise((resolve) => setTimeout(resolve, 300));
return new Promise((resolve) => setTimeout(resolve, 1000));
}
}

View File

@@ -10,11 +10,7 @@ export default class Page extends Component {
oninit(vnode) {
super.oninit(vnode);
app.previous = app.current;
app.current = new PageState(this.constructor, { routeName: this.attrs.routeName });
app.drawer.hide();
app.modal.close();
this.onNewRoute();
/**
* A class name to apply to the body while the route is active.
@@ -22,20 +18,20 @@ export default class Page extends Component {
* @type {String}
*/
this.bodyClass = '';
}
/**
* Whether we should scroll to the top of the page when its rendered.
*
* @type {Boolean}
*/
this.scrollTopOnCreate = true;
/**
* A collections of actions to run when the route changes.
* This is extracted here, and not hardcoded in oninit, as oninit is not called
* when a different route is handled by the same component, but we still need to
* adjust the current route name.
*/
onNewRoute() {
app.previous = app.current;
app.current = new PageState(this.constructor, { routeName: this.attrs.routeName });
/**
* Whether the browser should restore scroll state on refreshes.
*
* @type {Boolean}
*/
this.useBrowserScrollRestoration = true;
app.drawer.hide();
app.modal.close();
}
oncreate(vnode) {
@@ -44,14 +40,6 @@ export default class Page extends Component {
if (this.bodyClass) {
$('#app').addClass(this.bodyClass);
}
if (this.scrollTopOnCreate) {
$(window).scrollTop(0);
}
if ('scrollRestoration' in history) {
history.scrollRestoration = this.useBrowserScrollRestoration ? 'auto' : 'manual';
}
}
onremove() {

View File

@@ -12,9 +12,6 @@ import icon from '../helpers/icon';
function isActive(vnode) {
const tag = vnode.tag;
// Allow non-selectable dividers/headers to be added.
if (typeof tag === 'string' && tag !== 'a' && tag !== 'button') return false;
if ('initAttrs' in tag) {
tag.initAttrs(vnode.attrs);
}

View File

@@ -1,11 +1,11 @@
import dayjs from 'dayjs';
import * as Mithril from 'mithril';
/**
* The `fullTime` helper displays a formatted time string wrapped in a <time>
* tag.
*
* @param {Date} time
* @return {Object}
*/
export default function fullTime(time: Date): Mithril.Vnode {
export default function fullTime(time) {
const d = dayjs(time);
const datetime = d.format();

View File

@@ -1,13 +1,14 @@
import dayjs from 'dayjs';
import * as Mithril from 'mithril';
import humanTimeUtil from '../utils/humanTime';
/**
* The `humanTime` helper displays a time in a human-friendly time-ago format
* (e.g. '12 days ago'), wrapped in a <time> tag with other information about
* the time.
*
* @param {Date} time
* @return {Object}
*/
export default function humanTime(time: Date): Mithril.Vnode {
export default function humanTime(time) {
const d = dayjs(time);
const datetime = d.format();

View File

@@ -1,6 +1,6 @@
import 'expose-loader?$!expose-loader?jQuery!jquery';
import 'expose-loader?m!mithril';
import 'expose-loader?dayjs!dayjs';
import 'expose-loader?moment!expose-loader?dayjs!dayjs';
import 'expose-loader?m.bidi!m.attrs.bidi';
import 'bootstrap/js/affix';
import 'bootstrap/js/dropdown';

View File

@@ -1,41 +0,0 @@
import Mithril from 'mithril';
/**
* Generates a route resolver for a given component.
* In addition to regular route resolver functionality:
* - It provide the current route name as an attr
* - It sets a key on the component so a rerender will be triggered on route change.
*/
export default class DefaultResolver {
component: Mithril.Component;
routeName: string;
constructor(component, routeName) {
this.component = component;
this.routeName = routeName;
}
/**
* When a route change results in a changed key, a full page
* rerender occurs. This method can be overriden in subclasses
* to prevent rerenders on some route changes.
*/
makeKey() {
return this.routeName + JSON.stringify(m.route.param());
}
makeAttrs(vnode) {
return {
...vnode.attrs,
routeName: this.routeName,
};
}
onmatch(args, requestedPath, route) {
return this.component;
}
render(vnode) {
return [{ ...vnode, attrs: this.makeAttrs(vnode), key: this.makeKey() }];
}
}

View File

@@ -58,7 +58,7 @@ export default class ScrollListener {
*/
start() {
if (!this.active) {
window.addEventListener('scroll', (this.active = this.loop.bind(this)), { passive: true });
window.addEventListener('scroll', (this.active = this.loop.bind(this)));
}
}

View File

@@ -1,6 +1,3 @@
import dayjs from 'dayjs';
import 'dayjs/plugin/relativeTime';
/**
* The `humanTime` utility converts a date to a localized, human-readable time-
* ago string.

View File

@@ -1,9 +1,6 @@
import DefaultResolver from '../resolvers/DefaultResolver';
/**
* The `mapRoutes` utility converts a map of named application routes into a
* format that can be understood by Mithril, and wraps them in route resolvers
* to provide each route with the current route name.
* format that can be understood by Mithril.
*
* @see https://mithril.js.org/route.html#signature
* @param {Object} routes
@@ -13,17 +10,14 @@ import DefaultResolver from '../resolvers/DefaultResolver';
export default function mapRoutes(routes, basePath = '') {
const map = {};
for (const routeName in routes) {
const route = routes[routeName];
for (const key in routes) {
const route = routes[key];
if ('resolver' in route) {
map[basePath + route.path] = route.resolver;
} else if ('component' in route) {
const resolverClass = 'resolverClass' in route ? route.resolverClass : DefaultResolver;
map[basePath + route.path] = new resolverClass(route.component, routeName);
} else {
throw new Error(`Either a resolver or a component must be provided for the route [${routeName}]`);
}
map[basePath + route.path] = {
render() {
return m(route.component, { routeName: key });
},
};
}
return map;

View File

@@ -1,3 +1,9 @@
import withAttr from './withAttr';
import Stream from './Stream';
let deprecatedMPropWarned = false;
let deprecatedMWithAttrWarned = false;
export default function patchMithril(global) {
const defaultMithril = global.m;
@@ -16,5 +22,23 @@ export default function patchMithril(global) {
Object.keys(defaultMithril).forEach((key) => (modifiedMithril[key] = defaultMithril[key]));
// BEGIN DEPRECATED MITHRIL 2 BC LAYER
modifiedMithril.prop = function (...args) {
if (!deprecatedMPropWarned) {
deprecatedMPropWarned = true;
console.warn('m.prop() is deprecated, please use the Stream util (flarum/utils/Streams) instead.');
}
return Stream.bind(this)(...args);
};
modifiedMithril.withAttr = function (...args) {
if (!deprecatedMWithAttrWarned) {
deprecatedMWithAttrWarned = true;
console.warn("m.withAttr() is deprecated, please use flarum's withAttr util (flarum/utils/withAttr) instead.");
}
return withAttr.bind(this)(...args);
};
// END DEPRECATED MITHRIL 2 BC LAYER
global.m = modifiedMithril;
}

View File

@@ -90,6 +90,11 @@ export default class ForumApplication extends Application {
* @type {DiscussionListState}
*/
this.discussions = new DiscussionListState({}, this);
/**
* @deprecated beta 14, remove in beta 15.
*/
this.cache.discussionList = this.discussions;
}
/**
@@ -110,19 +115,17 @@ export default class ForumApplication extends Application {
this.routes[defaultAction].path = '/';
this.history.push(defaultAction, this.translator.trans('core.forum.header.back_to_index_tooltip'), '/');
this.pane = new Pane(document.getElementById('app'));
m.route.prefix = '';
super.mount(this.forum.attribute('basePath'));
// We mount navigation and header components after the page, so components
// like the back button can access the updated state when rendering.
m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('composer'), { view: () => Composer.component({ state: this.composer }) });
this.pane = new Pane(document.getElementById('app'));
m.route.prefix = '';
super.mount(this.forum.attribute('basePath'));
alertEmailConfirmation(this);
// Route the home link back home when clicked. We do not want it to register

View File

@@ -71,7 +71,6 @@ import Search from './components/Search';
import DiscussionListItem from './components/DiscussionListItem';
import LoadingPost from './components/LoadingPost';
import PostsUserPage from './components/PostsUserPage';
import DiscussionPageResolver from './resolvers/DiscussionPageResolver';
import routes from './routes';
import ForumApplication from './ForumApplication';
@@ -147,7 +146,6 @@ export default Object.assign(compat, {
'components/DiscussionListItem': DiscussionListItem,
'components/LoadingPost': LoadingPost,
'components/PostsUserPage': PostsUserPage,
'resolvers/DiscussionPageResolver': DiscussionPageResolver,
routes: routes,
ForumApplication: ForumApplication,
});

View File

@@ -118,10 +118,7 @@ export default class ChangeEmailModal extends Modal {
meta: { password: this.password() },
}
)
.then(() => {
this.success = true;
this.alertAttrs = null;
})
.then(() => (this.success = true))
.catch(() => {})
.then(this.loaded.bind(this));
}

View File

@@ -56,7 +56,9 @@ export default class CommentPost extends Post {
]);
}
refreshContent() {
onupdate(vnode) {
super.onupdate();
const contentHtml = this.isEditing() ? '' : this.attrs.post.contentHtml();
// If the post content has changed since the last render, we'll run through
@@ -64,28 +66,13 @@ export default class CommentPost extends Post {
// necessary because TextFormatter outputs them for e.g. syntax highlighting.
if (this.contentHtml !== contentHtml) {
this.$('.Post-body script').each(function () {
const script = document.createElement('script');
script.textContent = this.textContent;
Array.from(this.attributes).forEach((attr) => script.setAttribute(attr.name, attr.value));
this.parentNode.replaceChild(script, this);
eval.call(window, $(this).text());
});
}
this.contentHtml = contentHtml;
}
oncreate(vnode) {
super.oncreate(vnode);
this.refreshContent();
}
onupdate(vnode) {
super.onupdate(vnode);
this.refreshContent();
}
isEditing() {
return app.composer.bodyMatches(EditPostComposer, { post: this.attrs.post });
}

View File

@@ -199,7 +199,7 @@ export default class Composer extends Component {
*/
animatePositionChange() {
// When exiting full-screen mode: focus content
if (this.prevPosition === ComposerState.Position.FULLSCREEN && this.state.position === ComposerState.Position.NORMAL) {
if (this.prevPosition === ComposerState.Position.FULLSCREEN) {
this.focus();
return;
}

View File

@@ -44,6 +44,12 @@ export default class ComposerBody extends Component {
}
this.composer.fields.content(this.attrs.originalContent || '');
/**
* @deprecated BC layer, remove in Beta 15.
*/
this.content = this.composer.fields.content;
this.editor = this.composer;
}
view() {

View File

@@ -100,7 +100,7 @@ export default class DiscussionComposer extends ComposerBody {
.save(data)
.then((discussion) => {
this.composer.hide();
app.discussions.refresh({ deferClear: true });
app.discussions.refresh();
m.route.set(app.route.discussion(discussion));
}, this.loaded.bind(this));
}

View File

@@ -91,12 +91,12 @@ export default class DiscussionListItem extends Component {
)
: ''}
<span
<a
className={'Slidable-underneath Slidable-underneath--left Slidable-underneath--elastic' + (isUnread ? '' : ' disabled')}
onclick={this.markAsRead.bind(this)}
>
{icon('fas fa-check')}
</span>
</a>
<div className={'DiscussionListItem-content Slidable-content' + (isUnread ? ' unread' : '') + (isRead ? ' read' : '')}>
<Link

View File

@@ -18,8 +18,6 @@ export default class DiscussionPage extends Page {
oninit(vnode) {
super.oninit(vnode);
this.useBrowserScrollRestoration = false;
/**
* The discussion that is being viewed.
*
@@ -49,6 +47,8 @@ export default class DiscussionPage extends Page {
app.history.push('discussion');
this.bodyClass = 'App--discussion';
this.prevRoute = m.route.get();
}
onremove() {
@@ -84,6 +84,7 @@ export default class DiscussionPage extends Page {
{PostStream.component({
discussion,
stream: this.stream,
targetPost: this.stream.targetPost,
onPositionChange: this.positionChanged.bind(this),
})}
</div>
@@ -95,6 +96,18 @@ export default class DiscussionPage extends Page {
);
}
onbeforeupdate(vnode) {
super.onbeforeupdate(vnode);
const idParam = m.route.param('id');
if (m.route.get() !== this.prevRoute && this.discussion && (!idParam || idParam.split('-')[0] !== this.discussion.id())) {
this.prevRoute = m.route.get();
this.onNewRoute();
this.oninit(vnode);
}
}
/**
* Load the discussion from the API or use the preloaded one.
*/
@@ -157,19 +170,23 @@ export default class DiscussionPage extends Page {
record.relationships.discussion.data.id === discussionId
)
.map((record) => app.store.getById('posts', record.id))
.sort((a, b) => a.createdAt() - b.createdAt())
.sort((a, b) => a.id() - b.id())
.slice(0, 20);
}
const startNumber = m.route.param('near') || (includedPosts[0] && includedPosts[0].number());
// Set up the post stream for this discussion, along with the first page of
// posts we want to display. Tell the stream to scroll down and highlight
// the specific post that was routed to.
this.stream = new PostStreamState(discussion, includedPosts);
this.stream.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true).then(() => {
this.stream.goToNumber(startNumber, true).then(() => {
this.discussion = discussion;
app.current.set('discussion', discussion);
app.current.set('stream', this.stream);
this.positionChanged(startNumber);
});
}
@@ -219,7 +236,10 @@ export default class DiscussionPage extends Page {
// replace it into the window's history and our own history stack.
const url = app.route.discussion(discussion, (this.near = startNumber));
this.prevRoute = url;
m.route.set(url, null, { replace: true });
window.history.replaceState(null, document.title, url);
app.history.push('discussion', discussion.title());
// If the user hasn't read past here before, then we'll update their read

View File

@@ -42,7 +42,26 @@ export default class IndexPage extends Page {
app.history.push('index', app.translator.trans('core.forum.header.back_to_index_tooltip'));
this.bodyClass = 'App--index';
this.scrollTopOnCreate = false;
this.currentPath = m.route.get();
}
onbeforeupdate(vnode) {
super.onbeforeupdate(vnode);
const curPath = m.route.get();
if (this.currentPath !== curPath) {
this.onNewRoute();
app.discussions.clear();
app.discussions.refreshParams(app.search.params());
this.currentPath = curPath;
this.setTitle();
}
}
view() {
@@ -86,22 +105,18 @@ export default class IndexPage extends Page {
$('#app').css('min-height', $(window).height() + heroHeight);
// Let browser handle scrolling on page reload.
if (app.previous.type == null) return;
// When on mobile, only retain scroll if we're coming from a discussion page.
// Otherwise, we've just changed the filter, so we should go to the top of the page.
if (app.screen() == 'desktop' || app.screen() == 'desktop-hd' || this.lastDiscussion) {
$(window).scrollTop(scrollTop - oldHeroHeight + heroHeight);
} else {
$(window).scrollTop(0);
}
// Scroll to the remembered position. We do this after a short delay so that
// it happens after the browser has done its own "back button" scrolling,
// which isn't right. https://github.com/flarum/core/issues/835
const scroll = () => $(window).scrollTop(scrollTop - oldHeroHeight + heroHeight);
scroll();
setTimeout(scroll, 1);
// If we've just returned from a discussion page, then the constructor will
// have set the `lastDiscussion` property. If this is the case, we want to
// scroll down to that discussion so that it's in view.
if (this.lastDiscussion) {
const $discussion = this.$(`li[data-id="${this.lastDiscussion.id()}"] .DiscussionListItem`);
const $discussion = this.$(`.DiscussionListItem[data-id="${this.lastDiscussion.id()}"]`);
if ($discussion.length) {
const indexTop = $('#header').outerHeight();
@@ -116,16 +131,14 @@ export default class IndexPage extends Page {
}
}
onbeforeremove() {
// Save the scroll position so we can restore it when we return to the
// discussion list.
app.cache.scrollTop = $(window).scrollTop();
}
onremove() {
super.onremove();
$('#app').css('min-height', '');
// Save the scroll position so we can restore it when we return to the
// discussion list.
app.cache.scrollTop = $(window).scrollTop();
}
/**

View File

@@ -24,7 +24,7 @@ export default class Notification extends Component {
<Link
className={'Notification Notification--' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')}
href={href}
external={href.includes('://')}
external={href.indexOf('://') === -1}
onclick={this.markAsRead.bind(this)}
>
{!notification.isRead() &&

View File

@@ -32,13 +32,6 @@ export default class PostStream extends Component {
const posts = this.stream.posts();
const postIds = this.discussion.postIds();
const postFadeIn = (vnode) => {
$(vnode.dom).addClass('fadeIn');
// 500 is the duration of the fadeIn CSS animation + 100ms,
// so the animation has time to complete
setTimeout(() => $(vnode.dom).removeClass('fadeIn'), 500);
};
const items = posts.map((post, i) => {
let content;
const attrs = { 'data-index': this.stream.visibleStart + i };
@@ -49,7 +42,6 @@ export default class PostStream extends Component {
content = PostComponent ? PostComponent.component({ post }) : '';
attrs.key = 'post' + post.id();
attrs.oncreate = postFadeIn;
attrs['data-time'] = time.toISOString();
attrs['data-number'] = post.number();
attrs['data-id'] = post.id();
@@ -97,7 +89,7 @@ export default class PostStream extends Component {
// is not already doing so, then show a 'write a reply' placeholder.
if (viewingEnd && (!app.session.user || this.discussion.canReply())) {
items.push(
<div className="PostStream-item" key="reply" data-index={this.stream.count()} oncreate={postFadeIn}>
<div className="PostStream-item" key="reply">
{ReplyPlaceholder.component({ discussion: this.discussion })}
</div>
);
@@ -129,15 +121,16 @@ export default class PostStream extends Component {
* Start scrolling, if appropriate, to a newly-targeted post.
*/
triggerScroll() {
if (!this.stream.needsScroll) return;
if (!this.attrs.targetPost || !this.stream.needsScroll) return;
const target = this.stream.targetPost;
const newTarget = this.attrs.targetPost;
this.stream.needsScroll = false;
if ('number' in target) {
this.scrollToNumber(target.number, this.stream.animateScroll);
} else if ('index' in target) {
this.scrollToIndex(target.index, this.stream.animateScroll, target.reply);
if ('number' in newTarget) {
this.scrollToNumber(newTarget.number, this.stream.animateScroll);
} else if ('index' in newTarget) {
const backwards = newTarget.index === this.stream.count() - 1;
this.scrollToIndex(newTarget.index, this.stream.animateScroll, backwards);
}
}
@@ -188,9 +181,9 @@ export default class PostStream extends Component {
// seen if the browser were scrolled right up to the top of the page,
// and the viewport had a height of 0.
const $items = this.$('.PostStream-item[data-index]');
let index = $items.first().data('index') || 0;
let visible = 0;
let period = '';
let indexFromViewPort = null;
// Now loop through each of the items in the discussion. An 'item' is
// either a single post or a 'gap' of one or more posts that haven't
@@ -216,10 +209,8 @@ export default class PostStream extends Component {
const visibleBottom = Math.min(height, viewportTop + viewportHeight - top);
const visiblePost = visibleBottom - visibleTop;
// We take the index of the first item that passed the previous checks.
// It is the item that is first visible in the viewport.
if (indexFromViewPort === null) {
indexFromViewPort = parseFloat($this.data('index')) + visibleTop / height;
if (top <= viewportTop) {
index = parseFloat($this.data('index')) + visibleTop / height;
}
if (visiblePost > 0) {
@@ -232,10 +223,7 @@ export default class PostStream extends Component {
if (time) period = time;
});
// If indexFromViewPort is null, it means no posts are visible in the
// viewport. This can happen, when drafting a long reply post. In that case
// set the index to the last post.
this.stream.index = indexFromViewPort !== null ? indexFromViewPort + 1 : this.stream.count();
this.stream.index = index + 1;
this.stream.visible = visible;
if (period) this.stream.description = dayjs(period).format('MMMM YYYY');
}
@@ -287,9 +275,7 @@ export default class PostStream extends Component {
* @return {Integer}
*/
getMarginTop() {
const headerId = app.screen() === 'phone' ? '#app-navigation' : '#header';
return this.$() && $(headerId).outerHeight() + parseInt(this.$().css('margin-top'), 10);
return this.$() && $('#header').outerHeight() + parseInt(this.$().css('margin-top'), 10);
}
/**
@@ -310,17 +296,18 @@ export default class PostStream extends Component {
*
* @param {Integer} index
* @param {Boolean} animate
* @param {Boolean} reply Whether or not to scroll to the reply placeholder.
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
* at the given index, instead of the top of it.
* @return {jQuery.Deferred}
*/
scrollToIndex(index, animate, reply) {
const $item = reply ? $('.PostStream-item:last-child') : this.$(`.PostStream-item[data-index=${index}]`);
scrollToIndex(index, animate, bottom) {
const $item = this.$(`.PostStream-item[data-index=${index}]`);
this.scrollToItem($item, animate, true, reply);
if (reply) {
this.flashItem($item);
}
return this.scrollToItem($item, animate, true, bottom).then(() => {
if (index == this.stream.count() - 1) {
this.flashItem(this.$('.PostStream-item:last-child'));
}
});
}
/**
@@ -330,10 +317,11 @@ export default class PostStream extends Component {
* @param {Boolean} animate
* @param {Boolean} force Whether or not to force scrolling to the item, even
* if it is already in the viewport.
* @param {Boolean} reply Whether or not to scroll to the reply placeholder.
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
* at the given index, instead of the top of it.
* @return {jQuery.Deferred}
*/
scrollToItem($item, animate, force, reply) {
scrollToItem($item, animate, force, bottom) {
const $container = $('html, body').stop(true);
const index = $item.data('index');
@@ -344,10 +332,10 @@ export default class PostStream extends Component {
const scrollBottom = scrollTop + $(window).height();
// If the item is already in the viewport, we may not need to scroll.
// If we're scrolling to the reply placeholder, we'll make sure its
// If we're scrolling to the bottom of an item, then we'll make sure the
// bottom will line up with the top of the composer.
if (force || itemTop < scrollTop || itemBottom > scrollBottom) {
const top = reply ? itemBottom - $(window).height() + app.composer.computedHeight() : $item.is(':first-child') ? 0 : itemTop;
const top = bottom ? itemBottom - $(window).height() + app.composer.computedHeight() : $item.is(':first-child') ? 0 : itemTop;
if (!animate) {
$container.scrollTop(top);
@@ -361,7 +349,7 @@ export default class PostStream extends Component {
// We manually set the index because we want to display the index of the
// exact post we've scrolled to, not just that of the first post within viewport.
this.updateScrubber();
if (index !== undefined) this.stream.index = index + 1;
this.stream.index = index;
};
// If we don't update this before the scroll, the scrubber will start
@@ -372,22 +360,18 @@ export default class PostStream extends Component {
return Promise.all([$container.promise(), this.stream.loadPromise]).then(() => {
m.redraw.sync();
// Rendering post contents will probably throw off our position.
// To counter this, we'll scroll either:
// - To the reply placeholder (aligned with composer top)
// - To the top of the page if we're on the first post
// - To the top of a post (if that post exists)
// If the post does not currently exist, it's probably
// outside of the range we loaded in, so we won't adjust anything,
// as it will soon be rendered by the "load more" system.
let itemOffset;
if (reply) {
const $placeholder = $('.PostStream-item:last-child');
$(window).scrollTop($placeholder.offset().top + $placeholder.height() - $(window).height() + app.composer.computedHeight());
} else if (index === 0) {
// After post data has been loaded in, we will attempt to scroll back
// to the top of the requested post (or to the top of the page if the
// first post was requested). In some cases, we may have scrolled to
// the end of the available post range, in which case, the next range
// of posts will be loaded in. However, in those cases, the post we
// requested won't exist, so scrolling to it would cause an error.
// Accordingly, we start by checking that it's offset is defined.
const offset = $(`.PostStream-item[data-index=${index}]`).offset();
if (index === 0) {
$(window).scrollTop(0);
} else if ((itemOffset = $(`.PostStream-item[data-index=${index}]`).offset())) {
$(window).scrollTop(itemOffset.top - this.getMarginTop());
} else if (offset) {
$(window).scrollTop($(`.PostStream-item[data-index=${index}]`).offset().top - this.getMarginTop());
}
// We want to adjust this again after posts have been loaded in
@@ -405,11 +389,10 @@ export default class PostStream extends Component {
* @param {jQuery} $item
*/
flashItem($item) {
// This might execute before the fadeIn class has been removed in PostStreamItem's
// oncreate, so we remove it just to be safe and avoid a double animation.
$item.removeClass('fadeIn');
$item.addClass('flash').on('animationend webkitAnimationEnd', (e) => {
$item.removeClass('flash');
if (e.animationName === 'fadeIn') {
$item.removeClass('flash');
}
});
}
}

View File

@@ -27,6 +27,19 @@ export default class UserPage extends Page {
this.user = null;
this.bodyClass = 'App--user';
this.prevUsername = m.route.param('username');
}
onbeforeupdate() {
const currUsername = m.route.param('username');
if (currUsername !== this.prevUsername) {
this.onNewRoute();
this.prevUsername = currUsername;
this.loadUser(currUsername);
}
}
view() {
@@ -135,7 +148,7 @@ export default class UserPage extends Page {
items.add(
'posts',
<LinkButton href={app.route('user.posts', { username: user.username() })} icon="far fa-comment">
<LinkButton href={app.route('user.posts', { username: user.username() })} force icon="far fa-comment">
{app.translator.trans('core.forum.user.posts_link')} <span className="Button-badge">{user.commentCount()}</span>
</LinkButton>,
100
@@ -143,7 +156,7 @@ export default class UserPage extends Page {
items.add(
'discussions',
<LinkButton href={app.route('user.discussions', { username: user.username() })} icon="fas fa-bars">
<LinkButton href={app.route('user.discussions', { username: user.username() })} force icon="fas fa-bars">
{app.translator.trans('core.forum.user.discussions_link')} <span className="Button-badge">{user.discussionCount()}</span>
</LinkButton>,
90

View File

@@ -1,49 +0,0 @@
import DefaultResolver from '../../common/resolvers/DefaultResolver';
import DiscussionPage from '../components/DiscussionPage';
/**
* This isn't exported as it is a temporary measure.
* A more robust system will be implemented alongside UTF-8 support in beta 15.
*/
function getDiscussionIdFromSlug(slug: string | undefined) {
if (!slug) return;
return slug.split('-')[0];
}
/**
* A custom route resolver for DiscussionPage that generates the same key to all posts
* on the same discussion. It triggers a scroll when going from one post to another
* in the same discussion.
*/
export default class DiscussionPageResolver extends DefaultResolver {
static scrollToPostNumber: string | null = null;
makeKey() {
const params = { ...m.route.param() };
if ('near' in params) {
delete params.near;
}
params.id = getDiscussionIdFromSlug(params.id);
return this.routeName.replace('.near', '') + JSON.stringify(params);
}
onmatch(args, requestedPath, route) {
if (app.current.matches(DiscussionPage) && getDiscussionIdFromSlug(args.id) === getDiscussionIdFromSlug(m.route.param('id'))) {
// By default, the first post number of any discussion is 1
DiscussionPageResolver.scrollToPostNumber = args.near || '1';
}
return super.onmatch(args, requestedPath, route);
}
render(vnode) {
if (DiscussionPageResolver.scrollToPostNumber !== null) {
const number = DiscussionPageResolver.scrollToPostNumber;
// Scroll after a timeout to avoid clashes with the render.
setTimeout(() => app.current.get('stream').goToNumber(number));
DiscussionPageResolver.scrollToPostNumber = null;
}
return super.render(vnode);
}
}

View File

@@ -4,7 +4,6 @@ import PostsUserPage from './components/PostsUserPage';
import DiscussionsUserPage from './components/DiscussionsUserPage';
import SettingsPage from './components/SettingsPage';
import NotificationsPage from './components/NotificationsPage';
import DiscussionPageResolver from './resolvers/DiscussionPageResolver';
/**
* The `routes` initializer defines the forum app's routes.
@@ -15,8 +14,8 @@ export default function (app) {
app.routes = {
index: { path: '/all', component: IndexPage },
discussion: { path: '/d/:id', component: DiscussionPage, resolverClass: DiscussionPageResolver },
'discussion.near': { path: '/d/:id/:near', component: DiscussionPage, resolverClass: DiscussionPageResolver },
discussion: { path: '/d/:id', component: DiscussionPage },
'discussion.near': { path: '/d/:id/:near', component: DiscussionPage },
user: { path: '/u/:username', component: PostsUserPage },
'user.posts': { path: '/u/:username', component: PostsUserPage },

View File

@@ -34,6 +34,11 @@ class ComposerState {
this.editor = null;
this.clear();
/**
* @deprecated BC layer, remove in Beta 15.
*/
this.component = this;
}
/**
@@ -72,6 +77,12 @@ class ComposerState {
this.fields = {
content: Stream(''),
};
/**
* @deprecated BC layer, remove in Beta 15.
*/
this.content = this.fields.content;
this.value = this.fields.content;
}
/**

View File

@@ -96,9 +96,7 @@ class PostStreamState {
// If we want to go to the reply preview, then we will go to the end of the
// discussion and then scroll to the very bottom of the page.
if (number === 'reply') {
const resultPromise = this.goToLast();
this.targetPost.reply = true;
return resultPromise;
return this.goToLast();
}
this.paused = true;
@@ -172,7 +170,7 @@ class PostStreamState {
* @return {Promise}
*/
loadNearIndex(index) {
if (index >= this.visibleStart && index < this.visibleEnd) {
if (index >= this.visibleStart && index <= this.visibleEnd) {
return Promise.resolve();
}
@@ -282,13 +280,7 @@ class PostStreamState {
}
});
if (loadIds.length) {
return app.store.find('posts', loadIds).then((newPosts) => {
return loaded.concat(newPosts).sort((a, b) => a.createdAt() - b.createdAt());
});
}
return Promise.resolve(loaded);
return loadIds.length ? app.store.find('posts', loadIds) : Promise.resolve(loaded);
}
/**

View File

@@ -1,5 +1,5 @@
{
"include": ["src/**/*.ts", "src/**/*.tsx"],
"include": ["src/**/*.ts"],
"files": ["shims.d.ts"],
"compilerOptions": {
"allowUmdGlobalAccess": true,

View File

@@ -1,12 +1,10 @@
@import "common/common";
@import "admin/AdminHeader";
@import "admin/AdminNav";
@import "admin/DashboardPage";
@import "admin/BasicsPage";
@import "admin/PermissionsPage";
@import "admin/EditGroupModal";
@import "admin/ExtensionPage";
@import "admin/ExtensionWidget";
@import "admin/ExtensionsPage";
@import "admin/AppearancePage";
@import "admin/MailPage";

View File

@@ -1,19 +0,0 @@
.AdminHeader {
background: @control-bg;
margin-bottom: 20px;
padding: 20px 0;
h2 {
margin-top: 0;
margin-bottom: 10px;
color: @muted-color;
}
.AdminHeader-description {
margin: 0;
}
.icon {
margin-right: 15px;
}
}

View File

@@ -1,85 +1,17 @@
@admin-pane-width: 250px;
@admin-pane-width: 300px;
.App {
padding-bottom: 0;
}
.AdminLinkButton-description {
display: none;
}
.AdminContent {
padding: 20px 0;
}
.App-content .sideNavOffset {
margin-top: 0;
}
.Header-controls {
> li {
margin-left: 10px;
}
}
@media @phone {
.Dropdown-menu {
height: 70vh;
.item-search {
margin: 10px;
.SearchBar {
width: 100%
}
}
}
.ExtensionNavButton {
.Button-label {
margin-left: 30px;
}
.ExtensionIcon {
margin: 0 0 0 -4px !important;
}
}
}
@media @tablet {
.item-search{
display: none;
}
.ExtensionItem, .item-search {
display: none !important;
}
.ExtensionListTitle {
display: none !important;
}
}
@media @phone, @tablet {
.App-nav .AdminNav {
.Dropdown-menu {
> li {
.ExtensionListTitle {
color: @muted-color;
text-transform: uppercase;
margin: 25px 0 10px 15px;
}
.ExtensionIcon {
margin: -2px -29px;
width: 25px;
height: 25px;
font-size: 12.5px;
.icon {
margin: 0;
}
}
}
}
}
}
@media @desktop, @desktop-hd {
.App-nav {
position: absolute;
@@ -88,84 +20,60 @@
width: @admin-pane-width;
.box-shadow(0 6px 6px @shadow-color);
background: @body-bg;
border-top: 1px solid @control-bg;
z-index: @zindex-pane;
overflow-y: scroll;
padding-bottom: 40px;
overflow: auto;
.affix & {
position: fixed;
bottom: 0;
height: auto;
}
}
.App-content .sideNavOffset {
margin-left: @admin-pane-width;
}
.App-nav .AdminNav {
.Dropdown-menu {
.item-search {
margin-top: 10px;
margin-bottom: 20px;
.Dropdown-menu > li {
> a {
padding: 15px 15px 15px 45px;
display: block;
text-decoration: none;
white-space: normal;
}
> a, > a:hover, &.active > a {
color: @muted-color;
}
> a:hover {
background: @control-bg;
}
&.active > a {
background: @control-bg;
font-weight: normal;
> li {
> a {
padding: 10px 10px 10px 45px;
display: block;
text-decoration: none;
}
> a,
> a:hover,
&.active > a {
.Button-label, .Button-icon {
color: @text-color;
}
> a:hover {
background: @control-bg;
}
&.active > a {
background: @primary-color;
font-weight: normal;
.Button-label,
.Button-icon {
color: @body-bg;
font-weight: bold;
}
}
.Button-icon {
float: left;
font-size: 13px !important;
margin-left: -25px !important;
margin-top: 4px !important;
}
.Button-label {
padding-left: 5px;
font-size: 14px;
font-weight: normal;
}
.Search-input,
.SearchBar {
max-width: 215px;
margin: 0 auto;
}
.ExtensionListTitle {
color: @muted-color;
text-transform: uppercase;
margin: 25px 0 15px 15px;
}
.ExtensionIcon {
width: 25px;
height: 25px;
font-size: 15px;
margin-left: -29px;
vertical-align: middle;
font-weight: bold;
}
}
}
.Button-icon {
float: left;
margin-left: -30px;
font-size: 14px;
margin-top: 4px !important;
}
.Button-label {
display: block;
font-size: 15px;
font-weight: normal;
margin: 0 0 5px;
}
.AdminLinkButton-description {
display: block;
font-size: 12px;
}
}
.container {
width: 100%;
margin: 0;
@@ -177,33 +85,4 @@
padding: 0;
}
}
}
.ExtensionListItem-Dot {
height: 10px;
width: 10px;
border-radius: 50%;
display: inline-block;
right: 13px;
margin: 6px 5px;
position: absolute;
}
.ExtensionNavButton {
.Button-label {
display: inline-block;
max-width: calc(100% - 18px);
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
margin-left: 5px;
}
}
.ExtensionListItem-Dot.enabled {
background-color: #2ECC40;
}
.ExtensionListItem-Dot.disabled {
background-color: #FF4136;
}

View File

@@ -1,8 +1,8 @@
.AppearancePage {
@media @desktop-up {
.container {
max-width: 600px;
padding: 30px;
margin: 0;
}
}

View File

@@ -1,4 +1,5 @@
.BasicsPage {
padding: 20px 0;
@media @desktop-up {
.container {

View File

@@ -1,11 +1,18 @@
.DashboardPage {
background: @body-bg;
background: @control-bg;
color: @control-color;
min-height: 100vh;
@media @desktop-up {
.container {
padding: 30px;
margin: 0;
}
}
}
.Widget {
background: @control-bg;
.DashboardWidget {
background: @body-bg;
color: @text-color;
border-radius: @border-radius;
padding: 20px;

View File

@@ -1,153 +0,0 @@
.ExtensionPage {
min-height: 110vh;
.ExtensionPage-header {
.ExtensionTitle {
display: flex;
align-items: center;
flex-wrap: wrap;
margin: 20px 0 15px;
}
.helpText {
margin-bottom: 5px;
}
h2 {
display: inline-block;
margin: 0;
vertical-align: middle;
}
}
.ExtensionPage-header,
.ExtensionPage-permissions-header {
background: @control-bg;
h2 {
color: @muted-color;
span {
font-size: 13px;
color: @muted-color;
font-weight: normal;
}
}
.Button-icon {
display: unset;
}
ul {
display: flex;
align-items: center;
list-style-type: none;
padding: 0;
margin: 0;
> li {
display: inline;
color: @muted-color;
margin-left: 13px;
> a {
color: @muted-color;
}
> .icon {
margin-right: 5px;
}
}
}
.ExtensionPage-headerItems {
padding: 15px 0;
display: flex;
align-items: center;
flex-wrap: wrap;
.Checkbox {
margin: 5px 0 0 0;
display: inline-block;
}
}
.Checkbox.off {
.Checkbox-display {
background: @muted-more-color;
}
}
.ExtensionInfo {
margin-left: auto;
.item-authors {
a {
color: @muted-color;
}
}
}
.ExtensionName {
display: inline-block;
margin-left: 8px;
}
.ExtensionIcon {
width: 30px;
height: 30px;
font-size: 15px;
margin-left: 0;
vertical-align: middle;
}
.ExtensionPage-headerTopItems {
margin-left: auto;
}
@media (max-width: @screen-phone-max) {
.ExtensionPage-headerTopItems {
float: right;
position: relative;
}
.item-website, .item-source, .item-documentation {
display: none;
}
}
}
.ExtensionPage-settings {
margin-top: 20px;
padding: 10px 0;
input {
max-width: 400px;
}
}
.ExtensionPage-subHeader {
color: @muted-color;
font-weight: normal;
text-align: center;
}
.ExtensionPage-permissions {
@media @phone {
> .container {
overflow-x: scroll;
padding-bottom: 20px;
}
}
.ExtensionPage-permissions-header {
margin: 20px 0 20px;
padding: 5px 0;
}
}
}

View File

@@ -1,93 +0,0 @@
.ExtensionsWidget {
background-color: @body-bg;
padding: 0;
}
.ExtensionsWidget-list {
> .container {
padding: 0;
background-color: @body-bg;
.ExtensionList-Category {
background: @control-bg;
padding: 20px 0 20px 20px;
margin-bottom: 20px;
border-radius: @border-radius;
.ExtensionList-Label {
margin-top: 0;
color: @muted-color;
}
}
.ExtensionGroup {
margin-bottom: 20px;
h3 {
color: @muted-color;
text-transform: uppercase;
font-size: 12px;
margin: 0 0 10px;
}
}
.ExtensionList {
padding: 0;
list-style: none;
display: grid;
grid-gap: 10px;
grid-template-columns: repeat(auto-fit, 90px);
margin-bottom: 0;
> li {
text-align: left;
position: relative;
display: block;
}
}
}
.ExtensionListItem.disabled {
.ExtensionListItem-title {
opacity: 0.5;
color: @muted-color;
}
.ExtensionListItem-icon {
opacity: 0.5;
}
}
.ExtensionListItem {
transition: .15s ease-in-out;
&:hover {
transform: scale(1.05);
}
.ExtensionListItem-title {
display: block;
text-align: center;
margin-top: 5px;
color: @text-color;
}
a:hover {
text-decoration: none;
}
}
}
.ExtensionIcon {
width: 90px;
height: 90px;
background: @control-bg;
color: @control-color;
border-radius: 6px;
display: inline-flex;
font-size: 45px;
text-align: center;
align-items: center;
justify-content: center;
vertical-align: middle;
}

View File

@@ -0,0 +1,115 @@
@extension-list-column-width: 410px;
.ExtensionsPage-header {
padding: 20px 0;
background: @control-bg;
}
.ExtensionsPage-list {
padding: 30px 0;
}
.ExtensionGroup {
margin-bottom: 20px;
h3 {
color: @muted-color;
text-transform: uppercase;
font-size: 12px;
margin: 0 0 10px;
}
}
.ExtensionList {
columns: 3;
column-width: @extension-list-column-width;
margin: 0;
padding: 0;
list-style: none;
.clearfix();
> li {
-webkit-column-break-inside: avoid;
break-inside: avoid-column;
page-break-inside: avoid;
text-align: left;
position: relative;
border-radius: 4px;
transition: background .2s;
}
}
.ExtensionListItem.disabled {
.ExtensionListItem-title {
opacity: 0.5;
color: @muted-color;
}
.ExtensionListItem-icon {
opacity: 0.5;
}
}
.ExtensionListItem {
padding: 10px;
}
.ExtensionListItem:hover {
background: @control-bg;
}
.ExtensionListItem-content {
padding: 0 50px;
min-height: 40px;
}
.ExtensionListItem-main {
overflow: hidden;
text-overflow: ellipsis;
}
.ExtensionListItem-title {
display: inline-block;
font-size: 13px;
font-weight: bold;
white-space: nowrap;
cursor: pointer;
padding-right: 10px;
}
.ExtensionListItem-version {
color: @muted-more-color;
font-size: 11px;
font-weight: normal;
display: inline-flex;
}
.ExtensionListItem-controls {
float: right;
display: none;
margin-right: -50px;
margin-top: 1px;
.ExtensionListItem:hover &, &.open {
display: block;
}
}
.ExtensionListItem-description {
font-size: 11px;
font-weight: normal;
text-align: justify;
}
.ExtensionIcon {
width: 40px;
height: 40px;
background: @control-bg;
color: @control-color;
border-radius: 6px;
display: inline-block;
font-size: 20px;
line-height: 40px;
text-align: center;
margin-left: -50px;
position: absolute;
}
@media (max-width: @extension-list-column-width) {
.ExtensionListItem-description {
display: none;
}
.ExtensionListItem-version {
display: block;
margin-top: 8px;
}
}

View File

@@ -1,4 +1,5 @@
.MailPage {
padding: 20px 0;
@media @desktop-up {
.container {

View File

@@ -1,11 +1,6 @@
.PermissionsPage-groups {
background: @control-bg;
border-radius: @border-radius;
max-width: calc(~'100% - 60px');
display: block;
margin-left: 30px;
overflow-x: auto;
padding: 8px 0 8px;
padding: 20px 0;
}
.Group {
width: 90px;

View File

@@ -10,23 +10,29 @@
}
}
// Fix a solid white box to the top of the viewport. This toolbar's contents
// will differ depending on the device: on phones it will be content
// controls, whereas on desktops it will be the header. We will overlay
// these things on top of it later.
.App:before {
content: " ";
.header-background();
border-bottom: 0;
position: absolute;
.affix& {
position: fixed;
}
.scrolled& {
.box-shadow(0 2px 6px @shadow-color);
}
}
// PHONES: Somewhere on the page there will be a .App-backControl, a
// .App-primaryControl, and a .App-titleControl. We will position these on the
// left, right, and center of the header respectively.
@media @phone {
.App-navigation {
.header-background();
border-bottom: 0;
position: absolute;
.affix & {
position: fixed;
}
.scrolled & {
.box-shadow(0 2px 6px @shadow-color);
}
}
.App-primaryControl, .App-titleControl, .App-backControl {
position: absolute !important;
z-index: @zindex-header + 1;
@@ -228,17 +234,13 @@
display: none;
}
.App-header {
.header-background();
padding: 8px;
position: absolute;
.affix & {
position: fixed;
}
.scrolled & {
.box-shadow(0 2px 6px @shadow-color);
}
height: @header-height;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: @zindex-header;
& when (@config-colored-header = true) {
.light-contents(@header-color, @header-control-bg, @header-control-color);

View File

@@ -105,10 +105,6 @@
text-align: left;
}
}
.off.Checkbox--switch .Checkbox-display {
background: @muted-more-color;
}
}
.Modal-footer {
border: 0;

View File

@@ -6,7 +6,18 @@
margin-top: 10px;
}
}
@-webkit-keyframes fadeIn {
0% {opacity: 0}
100% {opacity: 1}
}
@keyframes fadeIn {
0% {opacity: 0}
100% {opacity: 1}
}
.PostStream-item {
.animation(fadeIn 0.4s ease-in-out);
&:not(:last-child) {
border-bottom: 1px solid @control-bg;
@@ -93,16 +104,3 @@
.animation(pulsate 0.2s ease-in-out);
.animation-iteration-count(1);
}
@-webkit-keyframes fadeIn {
0% {opacity: 0}
100% {opacity: 1}
}
@keyframes fadeIn {
0% {opacity: 0}
100% {opacity: 1}
}
.fadeIn {
.animation(fadeIn 0.4s ease-in-out);
.animation-iteration-count(1);
}

View File

@@ -44,7 +44,7 @@
.sliding& {
position: relative;
background: @control-bg;
background: #fff;
z-index: 2;
border-radius: 2px;
.box-shadow(0 2px 6px @shadow-color);

View File

@@ -54,10 +54,9 @@ class AdminServiceProvider extends AbstractServiceProvider
HttpMiddleware\StartSession::class,
HttpMiddleware\RememberFromCookie::class,
HttpMiddleware\AuthenticateWithSession::class,
HttpMiddleware\SetLocale::class,
'flarum.admin.route_resolver',
HttpMiddleware\CheckCsrfToken::class,
Middleware\RequireAdministrateAbility::class
HttpMiddleware\SetLocale::class,
Middleware\RequireAdministrateAbility::class,
];
});
@@ -69,10 +68,6 @@ class AdminServiceProvider extends AbstractServiceProvider
);
});
$this->app->bind('flarum.admin.route_resolver', function () {
return new HttpMiddleware\ResolveRoute($this->app->make('flarum.admin.routes'));
});
$this->app->singleton('flarum.admin.handler', function () {
$pipe = new MiddlewarePipe;
@@ -80,7 +75,7 @@ class AdminServiceProvider extends AbstractServiceProvider
$pipe->pipe($this->app->make($middleware));
}
$pipe->pipe(new HttpMiddleware\ExecuteRoute());
$pipe->pipe(new HttpMiddleware\DispatchRoute($this->app->make('flarum.admin.routes')));
return $pipe;
});

View File

@@ -42,20 +42,6 @@ class ApiServiceProvider extends AbstractServiceProvider
return $routes;
});
$this->app->singleton('flarum.api.throttlers', function () {
return [
'bypassThrottlingAttribute' => function ($request) {
if ($request->getAttribute('bypassThrottling')) {
return false;
}
}
];
});
$this->app->bind(Middleware\ThrottleApi::class, function ($app) {
return new Middleware\ThrottleApi($app->make('flarum.api.throttlers'));
});
$this->app->singleton('flarum.api.middleware', function () {
return [
'flarum.api.error_handler',
@@ -65,10 +51,8 @@ class ApiServiceProvider extends AbstractServiceProvider
HttpMiddleware\RememberFromCookie::class,
HttpMiddleware\AuthenticateWithSession::class,
HttpMiddleware\AuthenticateWithHeader::class,
HttpMiddleware\SetLocale::class,
'flarum.api.route_resolver',
HttpMiddleware\CheckCsrfToken::class,
Middleware\ThrottleApi::class
HttpMiddleware\SetLocale::class,
];
});
@@ -80,10 +64,6 @@ class ApiServiceProvider extends AbstractServiceProvider
);
});
$this->app->bind('flarum.api.route_resolver', function () {
return new HttpMiddleware\ResolveRoute($this->app->make('flarum.api.routes'));
});
$this->app->singleton('flarum.api.handler', function () {
$pipe = new MiddlewarePipe;
@@ -91,16 +71,10 @@ class ApiServiceProvider extends AbstractServiceProvider
$pipe->pipe($this->app->make($middleware));
}
$pipe->pipe(new HttpMiddleware\ExecuteRoute());
$pipe->pipe(new HttpMiddleware\DispatchRoute($this->app->make('flarum.api.routes')));
return $pipe;
});
$this->app->singleton('flarum.api.notification_serializers', function () {
return [
'discussionRenamed' => BasicDiscussionSerializer::class
];
});
}
/**
@@ -108,7 +82,7 @@ class ApiServiceProvider extends AbstractServiceProvider
*/
public function boot()
{
$this->setNotificationSerializers();
$this->registerNotificationSerializers();
AbstractSerializeController::setContainer($this->app);
AbstractSerializeController::setEventDispatcher($events = $this->app->make('events'));
@@ -120,12 +94,13 @@ class ApiServiceProvider extends AbstractServiceProvider
/**
* Register notification serializers.
*/
protected function setNotificationSerializers()
protected function registerNotificationSerializers()
{
$blueprints = [];
$serializers = $this->app->make('flarum.api.notification_serializers');
$serializers = [
'discussionRenamed' => BasicDiscussionSerializer::class
];
// Deprecated in beta 15, remove in beta 16
$this->app->make('events')->dispatch(
new ConfigureNotificationTypes($blueprints, $serializers)
);

View File

@@ -64,9 +64,6 @@ class CreateDiscussionController extends AbstractCreateController
$actor = $request->getAttribute('actor');
$ipAddress = Arr::get($request->getServerParams(), 'REMOTE_ADDR', '127.0.0.1');
/**
* @deprecated, remove in beta 15.
*/
if (! $request->getAttribute('bypassFloodgate')) {
$this->floodgate->assertNotFlooding($actor);
}

View File

@@ -65,9 +65,6 @@ class CreatePostController extends AbstractCreateController
$discussionId = Arr::get($data, 'relationships.discussion.data.id');
$ipAddress = Arr::get($request->getServerParams(), 'REMOTE_ADDR', '127.0.0.1');
/**
* @deprecated, remove in beta 15.
*/
if (! $request->getAttribute('bypassFloodgate')) {
$this->floodgate->assertNotFlooding($actor);
}

View File

@@ -9,36 +9,69 @@
namespace Flarum\Api\Controller;
use Intervention\Image\Image;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Intervention\Image\ImageManager;
use Psr\Http\Message\UploadedFileInterface;
use League\Flysystem\FilesystemInterface;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class UploadFaviconController extends UploadImageController
class UploadFaviconController extends ShowForumController
{
protected $filePathSettingKey = 'favicon_path';
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
protected $filenamePrefix = 'favicon';
/**
* @var FilesystemInterface
*/
protected $uploadDir;
/**
* @param SettingsRepositoryInterface $settings
* @param FilesystemInterface $uploadDir
*/
public function __construct(SettingsRepositoryInterface $settings, FilesystemInterface $uploadDir)
{
$this->settings = $settings;
$this->uploadDir = $uploadDir;
}
/**
* {@inheritdoc}
*/
protected function makeImage(UploadedFileInterface $file): Image
public function data(ServerRequestInterface $request, Document $document)
{
$this->fileExtension = pathinfo($file->getClientFilename(), PATHINFO_EXTENSION);
$request->getAttribute('actor')->assertAdmin();
if ($this->fileExtension === 'ico') {
$encodedImage = $file->getStream();
$file = Arr::get($request->getUploadedFiles(), 'favicon');
$extension = pathinfo($file->getClientFilename(), PATHINFO_EXTENSION);
if ($extension === 'ico') {
$image = $file->getStream();
} else {
$manager = new ImageManager();
$manager = new ImageManager;
$encodedImage = $manager->make($file->getStream())->resize(64, 64, function ($constraint) {
$image = $manager->make($file->getStream())->resize(64, 64, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
})->encode('png');
$this->fileExtension = 'png';
$extension = 'png';
}
return $encodedImage;
if (($path = $this->settings->get('favicon_path')) && $this->uploadDir->has($path)) {
$this->uploadDir->delete($path);
}
$uploadName = 'favicon-'.Str::lower(Str::random(8)).'.'.$extension;
$this->uploadDir->write($uploadName, $image);
$this->settings->set('favicon_path', $uploadName);
return parent::data($request, $document);
}
}

View File

@@ -1,87 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Controller;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Intervention\Image\Image;
use League\Flysystem\FilesystemInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UploadedFileInterface;
use Tobscure\JsonApi\Document;
abstract class UploadImageController extends ShowForumController
{
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
/**
* @var FilesystemInterface
*/
protected $uploadDir;
/**
* @var string
*/
protected $fileExtension = 'png';
/**
* @var string
*/
protected $filePathSettingKey = '';
/**
* @var string
*/
protected $filenamePrefix = '';
/**
* @param SettingsRepositoryInterface $settings
* @param FilesystemInterface $uploadDir
*/
public function __construct(SettingsRepositoryInterface $settings, FilesystemInterface $uploadDir)
{
$this->settings = $settings;
$this->uploadDir = $uploadDir;
}
/**
* {@inheritdoc}
*/
public function data(ServerRequestInterface $request, Document $document)
{
$request->getAttribute('actor')->assertAdmin();
$file = Arr::get($request->getUploadedFiles(), $this->filenamePrefix);
$encodedImage = $this->makeImage($file);
if (($path = $this->settings->get($this->filePathSettingKey)) && $this->uploadDir->has($path)) {
$this->uploadDir->delete($path);
}
$uploadName = $this->filenamePrefix.'-'.Str::lower(Str::random(8)).'.'.$this->fileExtension;
$this->uploadDir->write($uploadName, $encodedImage);
$this->settings->set($this->filePathSettingKey, $uploadName);
return parent::data($request, $document);
}
/**
* @param UploadedFileInterface $file
* @return Image
*/
abstract protected function makeImage(UploadedFileInterface $file): Image;
}

View File

@@ -9,27 +9,61 @@
namespace Flarum\Api\Controller;
use Intervention\Image\Image;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Intervention\Image\ImageManager;
use Psr\Http\Message\UploadedFileInterface;
use League\Flysystem\FilesystemInterface;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class UploadLogoController extends UploadImageController
class UploadLogoController extends ShowForumController
{
protected $filePathSettingKey = 'logo_path';
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
protected $filenamePrefix = 'logo';
/**
* @var FilesystemInterface
*/
protected $uploadDir;
/**
* @param SettingsRepositoryInterface $settings
* @param FilesystemInterface $uploadDir
*/
public function __construct(SettingsRepositoryInterface $settings, FilesystemInterface $uploadDir)
{
$this->settings = $settings;
$this->uploadDir = $uploadDir;
}
/**
* {@inheritdoc}
*/
protected function makeImage(UploadedFileInterface $file): Image
public function data(ServerRequestInterface $request, Document $document)
{
$manager = new ImageManager();
$request->getAttribute('actor')->assertAdmin();
$file = Arr::get($request->getUploadedFiles(), 'logo');
$manager = new ImageManager;
$encodedImage = $manager->make($file->getStream())->heighten(60, function ($constraint) {
$constraint->upsize();
})->encode('png');
return $encodedImage;
if (($path = $this->settings->get('logo_path')) && $this->uploadDir->has($path)) {
$this->uploadDir->delete($path);
}
$uploadName = 'logo-'.Str::lower(Str::random(8)).'.png';
$this->uploadDir->write($uploadName, $encodedImage);
$this->settings->set('logo_path', $uploadName);
return parent::data($request, $document);
}
}

View File

@@ -17,8 +17,6 @@ use Flarum\Api\Serializer\AbstractSerializer;
*
* This event is fired when a serializer is constructing an array of resource
* attributes for API output.
*
* @deprecated in beta 15, removed in beta 16
*/
class Serializing
{

View File

@@ -1,57 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Middleware;
use Flarum\Post\Exception\FloodingException;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface as Middleware;
use Psr\Http\Server\RequestHandlerInterface as Handler;
class ThrottleApi implements Middleware
{
protected $throttlers;
public function __construct(array $throttlers)
{
$this->throttlers = $throttlers;
}
public function process(Request $request, Handler $handler): Response
{
if ($this->throttle($request)) {
throw new FloodingException;
}
return $handler->handle($request);
}
/**
* @return bool
*/
public function throttle(Request $request): bool
{
$throttle = false;
foreach ($this->throttlers as $throttler) {
$result = $throttler($request);
// Explicitly returning false overrides all throttling.
// Explicitly returning true marks the request to be throttled.
// Anything else is ignored.
if ($result === false) {
return false;
} elseif ($result === true) {
$throttle = true;
}
}
return $throttle;
}
}

View File

@@ -16,7 +16,6 @@ use Flarum\Event\GetApiRelationship;
use Flarum\User\User;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Arr;
use InvalidArgumentException;
use LogicException;
use Psr\Http\Message\ServerRequestInterface as Request;
@@ -48,16 +47,6 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
*/
protected static $container;
/**
* @var callable[]
*/
protected static $mutators = [];
/**
* @var array
*/
protected static $customRelations = [];
/**
* @return Request
*/
@@ -94,18 +83,6 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
$attributes = $this->getDefaultAttributes($model);
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
if (isset(static::$mutators[$class])) {
foreach (static::$mutators[$class] as $callback) {
$attributes = array_merge(
$attributes,
$callback($this, $model, $attributes)
);
}
}
}
// Deprecated in beta 15, removed in beta 16
static::$dispatcher->dispatch(
new Serializing($this, $model, $attributes)
);
@@ -125,7 +102,7 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
* @param DateTime|null $date
* @return string|null
*/
public function formatDate(DateTime $date = null)
protected function formatDate(DateTime $date = null)
{
if ($date) {
return $date->format(DateTime::RFC3339);
@@ -153,20 +130,10 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
*/
protected function getCustomRelationship($model, $name)
{
// Deprecated in beta 15, removed in beta 16
$relationship = static::$dispatcher->until(
new GetApiRelationship($this, $name, $model)
);
foreach (array_merge([static::class], class_parents($this)) as $class) {
$callback = Arr::get(static::$customRelations, "$class.$name");
if (is_callable($callback)) {
$relationship = $callback($this, $model);
break;
}
}
if ($relationship && ! ($relationship instanceof Relationship)) {
throw new LogicException(
'GetApiRelationship handler must return an instance of '.Relationship::class
@@ -313,27 +280,4 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
{
static::$container = $container;
}
/**
* @param string $serializerClass
* @param callable $mutator
*/
public static function addMutator(string $serializerClass, callable $mutator)
{
if (! isset(static::$mutators[$serializerClass])) {
static::$mutators[$serializerClass] = [];
}
static::$mutators[$serializerClass][] = $mutator;
}
/**
* @param string $serializerClass
* @param string $relation
* @param callable $callback
*/
public static function setRelationship(string $serializerClass, string $relation, callable $callback)
{
static::$customRelations[$serializerClass][$relation] = $callback;
}
}

View File

@@ -13,9 +13,6 @@ use Flarum\Notification\Blueprint\BlueprintInterface;
use InvalidArgumentException;
use ReflectionClass;
/**
* @deprecated in beta 15, removed in beta 16
*/
class ConfigureNotificationTypes
{
/**

View File

@@ -9,9 +9,6 @@
namespace Flarum\Event;
/**
* @deprecated in beta 15, remove in beta 16. Use the Post extender instead.
*/
class ConfigurePostTypes
{
private $models;

View File

@@ -21,8 +21,6 @@ use Flarum\Api\Serializer\AbstractSerializer;
* @see AbstractSerializer::hasOne()
* @see AbstractSerializer::hasMany()
* @see https://github.com/tobscure/json-api
*
* @deprecated in beta 15, removed in beta 16
*/
class GetApiRelationship
{

View File

@@ -0,0 +1,39 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Event;
use Flarum\User\User;
/**
* @deprecated beta 14, remove in beta 15. Use the User extender instead.
* The `PrepareUserGroups` event.
*/
class PrepareUserGroups
{
/**
* @var User
*/
public $user;
/**
* @var array
*/
public $groupIds;
/**
* @param User $user
* @param array $groupIds
*/
public function __construct(User $user, array &$groupIds)
{
$this->user = $user;
$this->groupIds = &$groupIds;
}
}

View File

@@ -1,162 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Extend;
use Flarum\Api\Serializer\AbstractSerializer;
use Flarum\Extension\Extension;
use Flarum\Foundation\ContainerUtil;
use Illuminate\Contracts\Container\Container;
class ApiSerializer implements ExtenderInterface
{
private $serializerClass;
private $attributes = [];
private $mutators = [];
private $relationships = [];
/**
* @param string $serializerClass The ::class attribute of the serializer you are modifying.
* This serializer should extend from \Flarum\Api\Serializer\AbstractSerializer.
*/
public function __construct(string $serializerClass)
{
$this->serializerClass = $serializerClass;
}
/**
* @param string $name: The name of the attribute.
* @param callable|string $callback
*
* The callback can be a closure or an invokable class, and should accept:
* - $serializer: An instance of this serializer.
* - $model: An instance of the model being serialized.
* - $attributes: An array of existing attributes.
*
* The callable should return:
* - The value of the attribute.
*
* @return self
*/
public function attribute(string $name, $callback)
{
$this->attributes[$name] = $callback;
return $this;
}
/**
* Add to or modify the attributes array of this serializer.
*
* @param callable|string $callback
*
* The callback can be a closure or an invokable class, and should accept:
* - $serializer: An instance of this serializer.
* - $model: An instance of the model being serialized.
* - $attributes: An array of existing attributes.
*
* The callable should return:
* - An array of additional attributes to merge with the existing array.
* Or a modified $attributes array.
*
* @return self
*/
public function mutate($callback)
{
$this->mutators[] = $callback;
return $this;
}
/**
* Establish a simple hasOne relationship from this serializer to another serializer.
* This represents a one-to-one relationship.
*
* @param string $name: The name of the relation. Has to be unique from other relation names.
* The relation has to exist in the model handled by this serializer.
* @param string $serializerClass: The ::class attribute the serializer that handles this relation.
* This serializer should extend from \Flarum\Api\Serializer\AbstractSerializer.
* @return self
*/
public function hasOne(string $name, string $serializerClass)
{
return $this->relationship($name, function (AbstractSerializer $serializer, $model) use ($serializerClass, $name) {
return $serializer->hasOne($model, $serializerClass, $name);
});
}
/**
* Establish a simple hasMany relationship from this serializer to another serializer.
* This represents a one-to-many relationship.
*
* @param string $name: The name of the relation. Has to be unique from other relation names.
* The relation has to exist in the model handled by this serializer.
* @param string $serializerClass: The ::class attribute the serializer that handles this relation.
* This serializer should extend from \Flarum\Api\Serializer\AbstractSerializer.
* @return self
*/
public function hasMany(string $name, string $serializerClass)
{
return $this->relationship($name, function (AbstractSerializer $serializer, $model) use ($serializerClass, $name) {
return $serializer->hasMany($model, $serializerClass, $name);
});
}
/**
* Add a relationship from this serializer to another serializer.
*
* @param string $name: The name of the relation. Has to be unique from other relation names.
* The relation has to exist in the model handled by this serializer.
* @param callable|string $callback
*
* The callable can be a closure or an invokable class, and should accept:
* - $serializer: An instance of this serializer.
* - $model: An instance of the model being serialized.
*
* The callable should return:
* - $relationship: An instance of \Tobscure\JsonApi\Relationship.
*
* @return self
*/
public function relationship(string $name, $callback)
{
$this->relationships[$this->serializerClass][$name] = $callback;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
if (! empty($this->attributes)) {
$this->mutators[] = function ($serializer, $model, $attributes) use ($container) {
foreach ($this->attributes as $attributeName => $callback) {
$callback = ContainerUtil::wrapCallback($callback, $container);
$attributes[$attributeName] = $callback($serializer, $model, $attributes);
}
return $attributes;
};
}
foreach ($this->mutators as $mutator) {
$mutator = ContainerUtil::wrapCallback($mutator, $container);
AbstractSerializer::addMutator($this->serializerClass, $mutator);
}
foreach ($this->relationships as $serializerClass => $relationships) {
foreach ($relationships as $relation => $callback) {
$callback = ContainerUtil::wrapCallback($callback, $container);
AbstractSerializer::setRelationship($serializerClass, $relation, $callback);
}
}
}
}

View File

@@ -14,28 +14,11 @@ use Illuminate\Contracts\Container\Container;
class Csrf implements ExtenderInterface
{
protected $csrfExemptRoutes = [];
protected $csrfExemptPaths = [];
/**
* Exempt a named route from CSRF checks.
*
* @param string $routeName
*/
public function exemptRoute(string $routeName)
{
$this->csrfExemptRoutes[] = $routeName;
return $this;
}
/**
* Exempt a path from csrf checks. Wildcards are supported.
*
* @deprecated beta 15, remove beta 16. Exempt routes should be used instead.
*/
public function exemptPath(string $path)
{
$this->csrfExemptRoutes[] = $path;
$this->csrfExemptPaths[] = $path;
return $this;
}
@@ -43,7 +26,7 @@ class Csrf implements ExtenderInterface
public function extend(Container $container, Extension $extension = null)
{
$container->extend('flarum.http.csrfExemptPaths', function ($existingExemptPaths) {
return array_merge($existingExemptPaths, $this->csrfExemptRoutes);
return array_merge($existingExemptPaths, $this->csrfExemptPaths);
});
}
}

View File

@@ -25,7 +25,7 @@ class Event implements ExtenderInterface
* - the class attribute of a class with a public `handle` method, which accepts an instance of the event as a parameter
*
* @param string $event
* @param callable|string $listener
* @param callable $listener
*/
public function listen(string $event, $listener)
{

View File

@@ -10,93 +10,38 @@
namespace Flarum\Extend;
use Flarum\Extension\Extension;
use Flarum\Formatter\Event\Configuring;
use Flarum\Formatter\Formatter as ActualFormatter;
use Flarum\Foundation\ContainerUtil;
use Illuminate\Contracts\Container\Container;
use Illuminate\Events\Dispatcher;
class Formatter implements ExtenderInterface, LifecycleInterface
{
private $configurationCallbacks = [];
private $parsingCallbacks = [];
private $renderingCallbacks = [];
private $callback;
/**
* Configure the formatter. This can be used to add support for custom markdown/bbcode/etc tags,
* or otherwise change the formatter. Please see documentation for the s9e text formatter library for more
* information on how to use this.
*
* @param callable|string $callback
*
* The callback can be a closure or invokable class, and should accept:
* - \s9e\TextFormatter\Configurator $configurator
*/
public function configure($callback)
{
$this->configurationCallbacks[] = $callback;
return $this;
}
/**
* Prepare the system for parsing. This can be used to modify the text that will be parsed, or to modify the parser.
* Please note that the text to be parsed must be returned, regardless of whether it's changed.
*
* @param callable|string $callback
*
* The callback can be a closure or invokable class, and should accept:
* - \s9e\TextFormatter\Parser $parser
* - mixed $context
* - string $text: The text to be parsed.
*
* The callback should return:
* - string $text: The text to be parsed.
*/
public function parse($callback)
{
$this->parsingCallbacks[] = $callback;
return $this;
}
/**
* Prepare the system for rendering. This can be used to modify the xml that will be rendered, or to modify the renderer.
* Please note that the xml to be rendered must be returned, regardless of whether it's changed.
*
* @param callable|string $callback
*
* The callback can be a closure or invokable class, and should accept:
* - \s9e\TextFormatter\Rendered $renderer
* - mixed $context
* - string $xml: The xml to be rendered.
* - ServerRequestInterface $request
*
* The callback should return:
* - string $xml: The xml to be rendered.
*/
public function render($callback)
{
$this->renderingCallbacks[] = $callback;
$this->callback = $callback;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
$container->extend('flarum.formatter', function ($formatter, $container) {
foreach ($this->configurationCallbacks as $callback) {
$formatter->addConfigurationCallback(ContainerUtil::wrapCallback($callback, $container));
}
$events = $container->make(Dispatcher::class);
foreach ($this->parsingCallbacks as $callback) {
$formatter->addParsingCallback(ContainerUtil::wrapCallback($callback, $container));
}
$events->listen(
Configuring::class,
function (Configuring $event) use ($container) {
if (is_string($this->callback)) {
$callback = $container->make($this->callback);
} else {
$callback = $this->callback;
}
foreach ($this->renderingCallbacks as $callback) {
$formatter->addRenderingCallback(ContainerUtil::wrapCallback($callback, $container));
$callback($event->configurator);
}
return $formatter;
});
);
}
public function onEnable(Container $container, Extension $extension)

View File

@@ -12,7 +12,6 @@ namespace Flarum\Extend;
use Flarum\Extension\Event\Disabled;
use Flarum\Extension\Event\Enabled;
use Flarum\Extension\Extension;
use Flarum\Foundation\ContainerUtil;
use Flarum\Foundation\Event\ClearingCache;
use Flarum\Frontend\Assets;
use Flarum\Frontend\Compiler\Source\SourceCollector;
@@ -172,7 +171,11 @@ class Frontend implements ExtenderInterface
"flarum.frontend.$this->frontend",
function (ActualFrontend $frontend, Container $container) {
foreach ($this->content as $content) {
$frontend->content(ContainerUtil::wrapCallback($content, $container));
if (is_string($content)) {
$content = $container->make($content);
}
$frontend->content($content);
}
}
);

View File

@@ -11,14 +11,12 @@ namespace Flarum\Extend;
use Flarum\Database\AbstractModel;
use Flarum\Extension\Extension;
use Flarum\Foundation\ContainerUtil;
use Illuminate\Contracts\Container\Container;
use Illuminate\Support\Arr;
class Model implements ExtenderInterface
{
private $modelClass;
private $customRelations = [];
/**
* @param string $modelClass The ::class attribute of the model you are modifying.
@@ -50,9 +48,7 @@ class Model implements ExtenderInterface
}
/**
* Add a default value for a given attribute, which can be an explicit value, a closure,
* or an instance of an invokable class. Unlike with some other extenders,
* it CANNOT be the `::class` attribute of an invokable class.
* Add a default value for a given attribute, which can be an explicit value, or a closure.
*
* @param string $attribute
* @param mixed $value
@@ -161,7 +157,7 @@ class Model implements ExtenderInterface
* @param string $name: The name of the relation. This doesn't have to be anything in particular,
* but has to be unique from other relation names for this model, and should
* work as the name of a method.
* @param callable|string $callback
* @param callable $callable
*
* The callable can be a closure or invokable class, and should accept:
* - $instance: An instance of this model.
@@ -172,17 +168,15 @@ class Model implements ExtenderInterface
*
* @return self
*/
public function relationship(string $name, $callback)
public function relationship(string $name, callable $callable)
{
$this->customRelations[$name] = $callback;
Arr::set(AbstractModel::$customRelations, "$this->modelClass.$name", $callable);
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
foreach ($this->customRelations as $name => $callback) {
Arr::set(AbstractModel::$customRelations, "$this->modelClass.$name", ContainerUtil::wrapCallback($callback, $container));
}
// Nothing needed here.
}
}

View File

@@ -1,78 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Extend;
use Flarum\Extension\Extension;
use Illuminate\Contracts\Container\Container;
class Notification implements ExtenderInterface
{
private $blueprints = [];
private $serializers = [];
private $drivers = [];
private $typesEnabledByDefault = [];
/**
* @param string $blueprint The ::class attribute of the blueprint class.
* This blueprint should implement \Flarum\Notification\Blueprint\BlueprintInterface.
* @param string $serializer The ::class attribute of the serializer class.
* This serializer should extend from \Flarum\Api\Serializer\AbstractSerializer.
* @param string[] $driversEnabledByDefault The names of the drivers enabled by default for this notification type.
* (example: alert, email).
* @return self
*/
public function type(string $blueprint, string $serializer, array $driversEnabledByDefault = [])
{
$this->blueprints[$blueprint] = $driversEnabledByDefault;
$this->serializers[$blueprint::getType()] = $serializer;
return $this;
}
/**
* @param string $driverName The name of the notification driver.
* @param string $driver The ::class attribute of the driver class.
* This driver should implement \Flarum\Notification\Driver\NotificationDriverInterface.
* @param string[] $typesEnabledByDefault The names of blueprint classes of types enabled by default for this driver.
* @return self
*/
public function driver(string $driverName, string $driver, array $typesEnabledByDefault = [])
{
$this->drivers[$driverName] = $driver;
$this->typesEnabledByDefault[$driverName] = $typesEnabledByDefault;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
$container->extend('flarum.notification.blueprints', function ($existingBlueprints) {
$existingBlueprints = array_merge($existingBlueprints, $this->blueprints);
foreach ($this->typesEnabledByDefault as $driverName => $typesEnabledByDefault) {
foreach ($typesEnabledByDefault as $blueprintClass) {
if (isset($existingBlueprints[$blueprintClass]) && (! in_array($driverName, $existingBlueprints[$blueprintClass]))) {
$existingBlueprints[$blueprintClass][] = $driverName;
}
}
}
return $existingBlueprints;
});
$container->extend('flarum.api.notification_serializers', function ($existingSerializers) {
return array_merge($existingSerializers, $this->serializers);
});
$container->extend('flarum.notification.drivers', function ($existingDrivers) {
return array_merge($existingDrivers, $this->drivers);
});
}
}

View File

@@ -1,39 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Extend;
use Flarum\Extension\Extension;
use Flarum\Post\Post as PostModel;
use Illuminate\Contracts\Container\Container;
class Post implements ExtenderInterface
{
private $postTypes = [];
/**
* Register a new post type. This is generally done for custom 'event posts',
* such as those that appear when a discussion is renamed.
*
* @param string $postType: The ::class attribute of the custom Post type that is being added.
*/
public function type(string $postType)
{
$this->postTypes[] = $postType;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
foreach ($this->postTypes as $postType) {
PostModel::setModel($postType::$type, $postType);
}
}
}

Some files were not shown because too many files have changed in this diff Show More