mirror of
https://github.com/flarum/core.git
synced 2025-08-23 00:23:25 +02:00
Compare commits
71 Commits
v0.1.0-bet
...
ds/discuss
Author | SHA1 | Date | |
---|---|---|---|
|
f6d88bf724 | ||
|
925628c208 | ||
|
aae83c4fbc | ||
|
d4b2d89da0 | ||
|
9b27b0d9d7 | ||
|
94381dca62 | ||
|
a2d5dd3397 | ||
|
f8edc2d827 | ||
|
62235a16ca | ||
|
36c55e8f69 | ||
|
859f014539 | ||
|
06e1d21331 | ||
|
fd5de6929e | ||
|
84b1666b24 | ||
|
0c61fcc61c | ||
|
8e25bcb68f | ||
|
fad783547c | ||
|
210a6b3e25 | ||
|
73409184b9 | ||
|
afe038699e | ||
|
649851d356 | ||
|
d1dfa758e4 | ||
|
8901073d12 | ||
|
e0437d237a | ||
|
07a43f52b4 | ||
|
9e9118fa0d | ||
|
4679448300 | ||
|
ef4bf8128e | ||
|
67a2aac635 | ||
|
51a97fb12e | ||
|
056d420c7b | ||
|
cfa533ebd6 | ||
|
eed407812f | ||
|
641619e820 | ||
|
984f751c71 | ||
|
8830e9dd09 | ||
|
fe41bc1fdc | ||
|
5a763050a6 | ||
|
8c813bc340 | ||
|
f67dee0a9e | ||
|
f968420216 | ||
|
d5e124b4a2 | ||
|
09e2736cbc | ||
|
ddb3d3edb0 | ||
|
28d56f5fc8 | ||
|
9b4012bbb5 | ||
|
1a5e4d454e | ||
|
387b4fd315 | ||
|
66482c2815 | ||
|
277a5c3fac | ||
|
286d8dec5b | ||
|
e1c61a0e85 | ||
|
102e76b084 | ||
|
d09d4bc507 | ||
|
c3989cc952 | ||
|
9cb9097b24 | ||
|
571a835be0 | ||
|
0c95774333 | ||
|
67741c7a6f | ||
|
f5cfec15e3 | ||
|
47d2eee9ce | ||
|
c10cc92deb | ||
|
529d2edcaf | ||
|
f0e77a5789 | ||
|
87c258b2f8 | ||
|
cee87848fe | ||
|
5842dd1200 | ||
|
b311512502 | ||
|
0b2a5fa5b8 | ||
|
52e45aacad | ||
|
21c2a4b2a4 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ Thumbs.db
|
|||||||
/tests/integration/tmp
|
/tests/integration/tmp
|
||||||
.vagrant
|
.vagrant
|
||||||
.idea/*
|
.idea/*
|
||||||
|
.vscode
|
||||||
|
62
CHANGELOG.md
62
CHANGELOG.md
@@ -1,12 +1,70 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.1.0-beta.15](https://github.com/flarum/core/compare/v0.1.0-beta.14.1...v0.1.0-beta.15)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Slug drivers support (https://github.com/flarum/core/pull/2456).
|
||||||
|
- Notification type extender (https://github.com/flarum/core/pull/2424).
|
||||||
|
- Validation extender (https://github.com/flarum/core/pull/2102).
|
||||||
|
- Post extender (https://github.com/flarum/core/pull/2101).
|
||||||
|
- Notification channel extender (https://github.com/flarum/core/pull/2432).
|
||||||
|
- Service provider extender (https://github.com/flarum/core/pull/2437).
|
||||||
|
- API serializer extender (https://github.com/flarum/core/pull/2438).
|
||||||
|
- User preferences extender (https://github.com/flarum/core/pull/2463).
|
||||||
|
- Settings extender (https://github.com/flarum/core/pull/2452).
|
||||||
|
- ApiController extender (https://github.com/flarum/core/pull/2451).
|
||||||
|
- Model visibility extender (https://github.com/flarum/core/pull/2460).
|
||||||
|
- Policy extender (https://github.com/flarum/core/pull/2461).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Time helpers converted to Typescript (https://github.com/flarum/core/pull/2391).
|
||||||
|
- Improved the formatter extender (https://github.com/flarum/core/pull/2098).
|
||||||
|
- Improve wording on installer when facing file permission issues (https://github.com/flarum/core/pull/2435).
|
||||||
|
- Background color of checkbox toggles improved for better usability (https://github.com/flarum/core/pull/2443).
|
||||||
|
- Route resolving refactored (https://github.com/flarum/core/pull/2425).
|
||||||
|
- Administration panel UX refactored (https://github.com/flarum/core/pull/2409).
|
||||||
|
- Floodgate moved to middleware and extender added (https://github.com/flarum/core/pull/2170).
|
||||||
|
- DRY up image uploading logic (https://github.com/flarum/core/pull/2477).
|
||||||
|
- Process isolation on testing (https://github.com/flarum/core/commit/984f751c718c89501cc09857bc271efa2c7eea8c).
|
||||||
|
- Forum and admin javascript exports namespaced (https://github.com/flarum/core/pull/2488).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Web updater does not take into account subfolder installations (https://github.com/flarum/core/pull/2426).
|
||||||
|
- Callables handling in extenders failed (https://github.com/flarum/core/pull/2423).
|
||||||
|
- Scrolling on mobile from PostSteam changes didn't work correctly (https://github.com/flarum/core/pull/2385).
|
||||||
|
- Side pane covers part of the discussion page due to `app.discussions` being empty (https://github.com/flarum/core/commit/102e76b084bf47fdfb4c73f95e1fbb322537f7aa).
|
||||||
|
- Change email modal keeps showing the previous error message even on success (https://github.com/flarum/core/pull/2467).
|
||||||
|
- Comment count not updated when discussions are deleted (https://github.com/flarum/core/pull/2472).
|
||||||
|
- `goToIndex` in PostStream does not trigger an xhr to retrieve new data (https://github.com/flarum/core/commit/09e2736cbcc267594b660beabbd001d9030f9880).
|
||||||
|
- On refresh the post number is reduced by one (https://github.com/flarum/core/pull/2476).
|
||||||
|
- Queue worker would instantiate a new Queue factory, not the bound one (https://github.com/flarum/core/pull/2481).
|
||||||
|
- Header accidentally has a border bottom (https://github.com/flarum/core/pull/2489).
|
||||||
|
- Namespace mentioned in docblock is incorrect (https://github.com/flarum/core/pull/2494).
|
||||||
|
- Scrolling inside longer discussions (especially Firefox) skips posts (https://github.com/flarum/core/commit/210a6b3e253d7917bd1eacd3ed8d2f95073ae99d).
|
||||||
|
- Uploading avatars that are jpg/jpeg fails with a validation error (https://github.com/flarum/core/pull/2497).
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- MomentJS alias (https://github.com/flarum/core/pull/2428).
|
||||||
|
- Deprecated user events `GetDisplayName` and `PrepareUserGroups` (https://github.com/flarum/core/pull/2428).
|
||||||
|
- AssertPermissionTrait (https://github.com/flarum/core/pull/2428).
|
||||||
|
- Path related helpers and methods in Application (https://github.com/flarum/core/pull/2428).
|
||||||
|
- Backward compatibility layers from the frontend rewrite (https://github.com/flarum/core/pull/2428).
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- `CheckingForFlooding` (https://github.com/flarum/core/commit/8e25bcb68f86cc992c46dfa70368419fe9f936ac).
|
||||||
|
|
||||||
## [0.1.0-beta.14.1](https://github.com/flarum/core/compare/v0.1.0-beta.14...v0.1.0-beta.14.1)
|
## [0.1.0-beta.14.1](https://github.com/flarum/core/compare/v0.1.0-beta.14...v0.1.0-beta.14.1)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- SuperTextarea component is not exported.
|
- SuperTextarea component is not exported.
|
||||||
- Symfony dependencies do not match those depended on by Laravel (#2407)
|
- Symfony dependencies do not match those depended on by Laravel (https://github.com/flarum/core/pull/2407).
|
||||||
- Scripts from textformatter aren't executed (#2415)
|
- Scripts from textformatter aren't executed (https://github.com/flarum/core/pull/2415)
|
||||||
- Sub path installations have no page title.
|
- Sub path installations have no page title.
|
||||||
- Losing focus of Composer area when coming from fullscreen.
|
- Losing focus of Composer area when coming from fullscreen.
|
||||||
|
|
||||||
|
@@ -79,6 +79,7 @@
|
|||||||
"symfony/config": "^4.3.4",
|
"symfony/config": "^4.3.4",
|
||||||
"symfony/console": "^4.3.4",
|
"symfony/console": "^4.3.4",
|
||||||
"symfony/event-dispatcher": "^4.3.4",
|
"symfony/event-dispatcher": "^4.3.4",
|
||||||
|
"symfony/mime": "^5.2.0",
|
||||||
"symfony/translation": "^4.3.4",
|
"symfony/translation": "^4.3.4",
|
||||||
"symfony/yaml": "^4.3.4",
|
"symfony/yaml": "^4.3.4",
|
||||||
"tobscure/json-api": "^0.3.0",
|
"tobscure/json-api": "^0.3.0",
|
||||||
|
6
js/dist/admin.js
vendored
6
js/dist/admin.js
vendored
File diff suppressed because one or more lines are too long
2
js/dist/admin.js.map
vendored
2
js/dist/admin.js.map
vendored
File diff suppressed because one or more lines are too long
6
js/dist/forum.js
vendored
6
js/dist/forum.js
vendored
File diff suppressed because one or more lines are too long
2
js/dist/forum.js.map
vendored
2
js/dist/forum.js.map
vendored
File diff suppressed because one or more lines are too long
6
js/package-lock.json
generated
6
js/package-lock.json
generated
@@ -3382,9 +3382,9 @@
|
|||||||
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
|
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
|
||||||
},
|
},
|
||||||
"ini": {
|
"ini": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||||
"integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw=="
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
|
||||||
},
|
},
|
||||||
"interpret": {
|
"interpret": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
|
@@ -1,13 +1,29 @@
|
|||||||
import HeaderPrimary from './components/HeaderPrimary';
|
import HeaderPrimary from './components/HeaderPrimary';
|
||||||
import HeaderSecondary from './components/HeaderSecondary';
|
import HeaderSecondary from './components/HeaderSecondary';
|
||||||
import routes from './routes';
|
import routes from './routes';
|
||||||
|
import ExtensionPage from './components/ExtensionPage';
|
||||||
import Application from '../common/Application';
|
import Application from '../common/Application';
|
||||||
import Navigation from '../common/components/Navigation';
|
import Navigation from '../common/components/Navigation';
|
||||||
import AdminNav from './components/AdminNav';
|
import AdminNav from './components/AdminNav';
|
||||||
|
import ExtensionData from './utils/ExtensionData';
|
||||||
|
|
||||||
export default class AdminApplication extends Application {
|
export default class AdminApplication extends Application {
|
||||||
|
// Deprecated as of beta 15
|
||||||
extensionSettings = {};
|
extensionSettings = {};
|
||||||
|
|
||||||
|
extensionData = new ExtensionData();
|
||||||
|
|
||||||
|
extensionCategories = {
|
||||||
|
discussion: 70,
|
||||||
|
moderation: 60,
|
||||||
|
feature: 50,
|
||||||
|
formatting: 40,
|
||||||
|
theme: 30,
|
||||||
|
authentication: 20,
|
||||||
|
language: 10,
|
||||||
|
other: 0,
|
||||||
|
};
|
||||||
|
|
||||||
history = {
|
history = {
|
||||||
canGoBack: () => true,
|
canGoBack: () => true,
|
||||||
getPrevious: () => {},
|
getPrevious: () => {},
|
||||||
@@ -34,7 +50,13 @@ export default class AdminApplication extends Application {
|
|||||||
m.route.prefix = '#';
|
m.route.prefix = '#';
|
||||||
super.mount();
|
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-navigation'), Navigation);
|
||||||
m.mount(document.getElementById('header-primary'), HeaderPrimary);
|
m.mount(document.getElementById('header-primary'), HeaderPrimary);
|
||||||
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
|
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
|
||||||
@@ -43,7 +65,7 @@ export default class AdminApplication extends Application {
|
|||||||
// If an extension has just been enabled, then we will run its settings
|
// If an extension has just been enabled, then we will run its settings
|
||||||
// callback.
|
// callback.
|
||||||
const enabled = localStorage.getItem('enabledExtension');
|
const enabled = localStorage.getItem('enabledExtension');
|
||||||
if (enabled && this.extensionSettings[enabled]) {
|
if (enabled && this.extensionSettings[enabled] && typeof this.extensionSettings[enabled] === 'function') {
|
||||||
this.extensionSettings[enabled]();
|
this.extensionSettings[enabled]();
|
||||||
localStorage.removeItem('enabledExtension');
|
localStorage.removeItem('enabledExtension');
|
||||||
}
|
}
|
||||||
|
@@ -1,19 +1,24 @@
|
|||||||
import compat from '../common/compat';
|
import compat from '../common/compat';
|
||||||
|
|
||||||
import saveSettings from './utils/saveSettings';
|
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 SettingDropdown from './components/SettingDropdown';
|
||||||
import EditCustomFooterModal from './components/EditCustomFooterModal';
|
import EditCustomFooterModal from './components/EditCustomFooterModal';
|
||||||
import SessionDropdown from './components/SessionDropdown';
|
import SessionDropdown from './components/SessionDropdown';
|
||||||
import HeaderPrimary from './components/HeaderPrimary';
|
import HeaderPrimary from './components/HeaderPrimary';
|
||||||
import AppearancePage from './components/AppearancePage';
|
import AppearancePage from './components/AppearancePage';
|
||||||
import StatusWidget from './components/StatusWidget';
|
import StatusWidget from './components/StatusWidget';
|
||||||
|
import ExtensionsWidget from './components/ExtensionsWidget';
|
||||||
import HeaderSecondary from './components/HeaderSecondary';
|
import HeaderSecondary from './components/HeaderSecondary';
|
||||||
import SettingsModal from './components/SettingsModal';
|
import SettingsModal from './components/SettingsModal';
|
||||||
import DashboardWidget from './components/DashboardWidget';
|
import DashboardWidget from './components/DashboardWidget';
|
||||||
import AddExtensionModal from './components/AddExtensionModal';
|
import ExtensionPage from './components/ExtensionPage';
|
||||||
import ExtensionsPage from './components/ExtensionsPage';
|
import ExtensionLinkButton from './components/ExtensionLinkButton';
|
||||||
import AdminLinkButton from './components/AdminLinkButton';
|
import AdminLinkButton from './components/AdminLinkButton';
|
||||||
import PermissionGrid from './components/PermissionGrid';
|
import PermissionGrid from './components/PermissionGrid';
|
||||||
|
import ExtensionPermissionGrid from './components/ExtensionPermissionGrid';
|
||||||
import MailPage from './components/MailPage';
|
import MailPage from './components/MailPage';
|
||||||
import UploadImageButton from './components/UploadImageButton';
|
import UploadImageButton from './components/UploadImageButton';
|
||||||
import LoadingModal from './components/LoadingModal';
|
import LoadingModal from './components/LoadingModal';
|
||||||
@@ -23,6 +28,7 @@ import EditCustomHeaderModal from './components/EditCustomHeaderModal';
|
|||||||
import PermissionsPage from './components/PermissionsPage';
|
import PermissionsPage from './components/PermissionsPage';
|
||||||
import PermissionDropdown from './components/PermissionDropdown';
|
import PermissionDropdown from './components/PermissionDropdown';
|
||||||
import AdminNav from './components/AdminNav';
|
import AdminNav from './components/AdminNav';
|
||||||
|
import AdminHeader from './components/AdminHeader';
|
||||||
import EditCustomCssModal from './components/EditCustomCssModal';
|
import EditCustomCssModal from './components/EditCustomCssModal';
|
||||||
import EditGroupModal from './components/EditGroupModal';
|
import EditGroupModal from './components/EditGroupModal';
|
||||||
import routes from './routes';
|
import routes from './routes';
|
||||||
@@ -30,19 +36,24 @@ import AdminApplication from './AdminApplication';
|
|||||||
|
|
||||||
export default Object.assign(compat, {
|
export default Object.assign(compat, {
|
||||||
'utils/saveSettings': saveSettings,
|
'utils/saveSettings': saveSettings,
|
||||||
|
'utils/ExtensionData': ExtensionData,
|
||||||
|
'utils/isExtensionEnabled': isExtensionEnabled,
|
||||||
|
'utils/getCategorizedExtensions': getCategorizedExtensions,
|
||||||
'components/SettingDropdown': SettingDropdown,
|
'components/SettingDropdown': SettingDropdown,
|
||||||
'components/EditCustomFooterModal': EditCustomFooterModal,
|
'components/EditCustomFooterModal': EditCustomFooterModal,
|
||||||
'components/SessionDropdown': SessionDropdown,
|
'components/SessionDropdown': SessionDropdown,
|
||||||
'components/HeaderPrimary': HeaderPrimary,
|
'components/HeaderPrimary': HeaderPrimary,
|
||||||
'components/AppearancePage': AppearancePage,
|
'components/AppearancePage': AppearancePage,
|
||||||
'components/StatusWidget': StatusWidget,
|
'components/StatusWidget': StatusWidget,
|
||||||
|
'components/ExtensionsWidget': ExtensionsWidget,
|
||||||
'components/HeaderSecondary': HeaderSecondary,
|
'components/HeaderSecondary': HeaderSecondary,
|
||||||
'components/SettingsModal': SettingsModal,
|
'components/SettingsModal': SettingsModal,
|
||||||
'components/DashboardWidget': DashboardWidget,
|
'components/DashboardWidget': DashboardWidget,
|
||||||
'components/AddExtensionModal': AddExtensionModal,
|
'components/ExtensionPage': ExtensionPage,
|
||||||
'components/ExtensionsPage': ExtensionsPage,
|
'components/ExtensionLinkButton': ExtensionLinkButton,
|
||||||
'components/AdminLinkButton': AdminLinkButton,
|
'components/AdminLinkButton': AdminLinkButton,
|
||||||
'components/PermissionGrid': PermissionGrid,
|
'components/PermissionGrid': PermissionGrid,
|
||||||
|
'components/ExtensionPermissionGrid': ExtensionPermissionGrid,
|
||||||
'components/MailPage': MailPage,
|
'components/MailPage': MailPage,
|
||||||
'components/UploadImageButton': UploadImageButton,
|
'components/UploadImageButton': UploadImageButton,
|
||||||
'components/LoadingModal': LoadingModal,
|
'components/LoadingModal': LoadingModal,
|
||||||
@@ -52,6 +63,7 @@ export default Object.assign(compat, {
|
|||||||
'components/PermissionsPage': PermissionsPage,
|
'components/PermissionsPage': PermissionsPage,
|
||||||
'components/PermissionDropdown': PermissionDropdown,
|
'components/PermissionDropdown': PermissionDropdown,
|
||||||
'components/AdminNav': AdminNav,
|
'components/AdminNav': AdminNav,
|
||||||
|
'components/AdminHeader': AdminHeader,
|
||||||
'components/EditCustomCssModal': EditCustomCssModal,
|
'components/EditCustomCssModal': EditCustomCssModal,
|
||||||
'components/EditGroupModal': EditGroupModal,
|
'components/EditGroupModal': EditGroupModal,
|
||||||
routes: routes,
|
routes: routes,
|
||||||
|
19
js/src/admin/components/AdminHeader.js
Normal file
19
js/src/admin/components/AdminHeader.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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>,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
@@ -1,106 +1,150 @@
|
|||||||
/*
|
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 Component from '../../common/Component';
|
||||||
import AdminLinkButton from './AdminLinkButton';
|
import LinkButton from '../../common/components/LinkButton';
|
||||||
import SelectDropdown from '../../common/components/SelectDropdown';
|
import SelectDropdown from '../../common/components/SelectDropdown';
|
||||||
|
import getCategorizedExtensions from '../utils/getCategorizedExtensions';
|
||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
|
import Stream from '../../common/utils/Stream';
|
||||||
|
|
||||||
export default class AdminNav extends Component {
|
export default class AdminNav extends Component {
|
||||||
|
oninit(vnode) {
|
||||||
|
super.oninit(vnode);
|
||||||
|
|
||||||
|
this.query = Stream('');
|
||||||
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
return (
|
return (
|
||||||
<SelectDropdown className="AdminNav App-titleControl" buttonClassName="Button">
|
<SelectDropdown className="AdminNav App-titleControl AdminNav-Main" buttonClassName="Button">
|
||||||
{this.items().toArray()}
|
{this.items().toArray().concat(this.extensionItems().toArray())}
|
||||||
</SelectDropdown>
|
</SelectDropdown>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oncreate(vnode) {
|
||||||
|
super.oncreate(vnode);
|
||||||
|
|
||||||
|
this.scrollToActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
onupdate() {
|
||||||
|
this.scrollToActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToActive() {
|
||||||
|
const children = $('.Dropdown-menu').children('.active');
|
||||||
|
const nav = $('#admin-navigation');
|
||||||
|
const time = app.previous.type ? 250 : 0;
|
||||||
|
|
||||||
|
if (
|
||||||
|
children.length > 0 &&
|
||||||
|
(children[0].offsetTop > nav.scrollTop() + nav.outerHeight() || children[0].offsetTop + children[0].offsetHeight < nav.scrollTop())
|
||||||
|
) {
|
||||||
|
nav.animate(
|
||||||
|
{
|
||||||
|
scrollTop: children[0].offsetTop - nav.height() / 2,
|
||||||
|
},
|
||||||
|
time
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build an item list of links to show in the admin navigation.
|
* Build an item list of main links to show in the admin navigation.
|
||||||
*
|
*
|
||||||
* @return {ItemList}
|
* @return {ItemList}
|
||||||
*/
|
*/
|
||||||
items() {
|
items() {
|
||||||
const items = new ItemList();
|
const items = new ItemList();
|
||||||
|
|
||||||
|
items.add('category-core', <h4 className="ExtensionListTitle">{app.translator.trans('core.admin.nav.categories.core')}</h4>);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'dashboard',
|
'dashboard',
|
||||||
AdminLinkButton.component(
|
<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')}
|
||||||
href: app.route('dashboard'),
|
</LinkButton>
|
||||||
icon: 'far fa-chart-bar',
|
|
||||||
description: app.translator.trans('core.admin.nav.dashboard_text'),
|
|
||||||
},
|
|
||||||
app.translator.trans('core.admin.nav.dashboard_button')
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'basics',
|
'basics',
|
||||||
AdminLinkButton.component(
|
<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')}
|
||||||
href: app.route('basics'),
|
</LinkButton>
|
||||||
icon: 'fas fa-pencil-alt',
|
|
||||||
description: app.translator.trans('core.admin.nav.basics_text'),
|
|
||||||
},
|
|
||||||
app.translator.trans('core.admin.nav.basics_button')
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'mail',
|
'mail',
|
||||||
AdminLinkButton.component(
|
<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')}
|
||||||
href: app.route('mail'),
|
</LinkButton>
|
||||||
icon: 'fas fa-envelope',
|
|
||||||
description: app.translator.trans('core.admin.nav.email_text'),
|
|
||||||
},
|
|
||||||
app.translator.trans('core.admin.nav.email_button')
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'permissions',
|
'permissions',
|
||||||
AdminLinkButton.component(
|
<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')}
|
||||||
href: app.route('permissions'),
|
</LinkButton>
|
||||||
icon: 'fas fa-key',
|
|
||||||
description: app.translator.trans('core.admin.nav.permissions_text'),
|
|
||||||
},
|
|
||||||
app.translator.trans('core.admin.nav.permissions_button')
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'appearance',
|
'appearance',
|
||||||
AdminLinkButton.component(
|
<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')}
|
||||||
href: app.route('appearance'),
|
</LinkButton>
|
||||||
icon: 'fas fa-paint-brush',
|
|
||||||
description: app.translator.trans('core.admin.nav.appearance_text'),
|
|
||||||
},
|
|
||||||
app.translator.trans('core.admin.nav.appearance_button')
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'extensions',
|
'search',
|
||||||
AdminLinkButton.component(
|
<div className="Search-input">
|
||||||
{
|
<input
|
||||||
href: app.route('extensions'),
|
className="FormControl SearchBar"
|
||||||
icon: 'fas fa-puzzle-piece',
|
bidi={this.query}
|
||||||
description: app.translator.trans('core.admin.nav.extensions_text'),
|
type="search"
|
||||||
},
|
placeholder={app.translator.trans('core.admin.nav.search_placeholder')}
|
||||||
app.translator.trans('core.admin.nav.extensions_button')
|
/>
|
||||||
)
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return items;
|
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-${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-${extension.id}`,
|
||||||
|
<ExtensionLinkButton
|
||||||
|
href={app.route('extension', { id: extension.id })}
|
||||||
|
extensionId={extension.id}
|
||||||
|
className="ExtensionNavButton"
|
||||||
|
title={extension.description}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</ExtensionLinkButton>,
|
||||||
|
categories[category]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -7,6 +7,7 @@ import EditCustomHeaderModal from './EditCustomHeaderModal';
|
|||||||
import EditCustomFooterModal from './EditCustomFooterModal';
|
import EditCustomFooterModal from './EditCustomFooterModal';
|
||||||
import UploadImageButton from './UploadImageButton';
|
import UploadImageButton from './UploadImageButton';
|
||||||
import saveSettings from '../utils/saveSettings';
|
import saveSettings from '../utils/saveSettings';
|
||||||
|
import AdminHeader from './AdminHeader';
|
||||||
|
|
||||||
export default class AppearancePage extends Page {
|
export default class AppearancePage extends Page {
|
||||||
oninit(vnode) {
|
oninit(vnode) {
|
||||||
@@ -21,6 +22,13 @@ export default class AppearancePage extends Page {
|
|||||||
view() {
|
view() {
|
||||||
return (
|
return (
|
||||||
<div className="AppearancePage">
|
<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">
|
<div className="container">
|
||||||
<form onsubmit={this.onsubmit.bind(this)}>
|
<form onsubmit={this.onsubmit.bind(this)}>
|
||||||
<fieldset className="AppearancePage-colors">
|
<fieldset className="AppearancePage-colors">
|
||||||
|
@@ -7,6 +7,7 @@ import ItemList from '../../common/utils/ItemList';
|
|||||||
import Switch from '../../common/components/Switch';
|
import Switch from '../../common/components/Switch';
|
||||||
import Stream from '../../common/utils/Stream';
|
import Stream from '../../common/utils/Stream';
|
||||||
import withAttr from '../../common/utils/withAttr';
|
import withAttr from '../../common/utils/withAttr';
|
||||||
|
import AdminHeader from './AdminHeader';
|
||||||
|
|
||||||
export default class BasicsPage extends Page {
|
export default class BasicsPage extends Page {
|
||||||
oninit(vnode) {
|
oninit(vnode) {
|
||||||
@@ -24,10 +25,6 @@ export default class BasicsPage extends Page {
|
|||||||
'welcome_message',
|
'welcome_message',
|
||||||
'display_name_driver',
|
'display_name_driver',
|
||||||
];
|
];
|
||||||
this.values = {};
|
|
||||||
|
|
||||||
const settings = app.data.settings;
|
|
||||||
this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
|
|
||||||
|
|
||||||
this.localeOptions = {};
|
this.localeOptions = {};
|
||||||
const locales = app.data.locales;
|
const locales = app.data.locales;
|
||||||
@@ -41,14 +38,38 @@ export default class BasicsPage extends Page {
|
|||||||
this.displayNameOptions[identifier] = identifier;
|
this.displayNameOptions[identifier] = identifier;
|
||||||
}, this);
|
}, this);
|
||||||
|
|
||||||
|
this.slugDriverOptions = {};
|
||||||
|
Object.keys(app.data.slugDrivers).forEach((model) => {
|
||||||
|
this.fields.push(`slug_driver_${model}`);
|
||||||
|
this.slugDriverOptions[model] = {};
|
||||||
|
|
||||||
|
app.data.slugDrivers[model].forEach((option) => {
|
||||||
|
this.slugDriverOptions[model][option] = option;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.values = {};
|
||||||
|
|
||||||
|
const settings = app.data.settings;
|
||||||
|
this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
|
||||||
|
|
||||||
if (!this.values.display_name_driver() && displayNameDrivers.includes('username')) this.values.display_name_driver('username');
|
if (!this.values.display_name_driver() && displayNameDrivers.includes('username')) this.values.display_name_driver('username');
|
||||||
|
|
||||||
|
Object.keys(app.data.slugDrivers).forEach((model) => {
|
||||||
|
if (!this.values[`slug_driver_${model}`]() && 'default' in this.slugDriverOptions[model]) {
|
||||||
|
this.values[`slug_driver_${model}`]('default');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
|
if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
return (
|
return (
|
||||||
<div className="BasicsPage">
|
<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">
|
<div className="container">
|
||||||
<form onsubmit={this.onsubmit.bind(this)}>
|
<form onsubmit={this.onsubmit.bind(this)}>
|
||||||
{FieldSet.component(
|
{FieldSet.component(
|
||||||
@@ -128,20 +149,30 @@ export default class BasicsPage extends Page {
|
|||||||
]
|
]
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{Object.keys(this.displayNameOptions).length > 1
|
{Object.keys(this.displayNameOptions).length > 1 ? (
|
||||||
? FieldSet.component(
|
<FieldSet label={app.translator.trans('core.admin.basics.display_name_heading')}>
|
||||||
{
|
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>
|
||||||
label: app.translator.trans('core.admin.basics.display_name_heading'),
|
<Select
|
||||||
},
|
options={this.displayNameOptions}
|
||||||
[
|
value={this.values.display_name_driver()}
|
||||||
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>,
|
onchange={this.values.display_name_driver}
|
||||||
Select.component({
|
></Select>
|
||||||
options: this.displayNameOptions,
|
</FieldSet>
|
||||||
bidi: this.values.display_name_driver,
|
) : (
|
||||||
}),
|
''
|
||||||
]
|
)}
|
||||||
)
|
|
||||||
: ''}
|
{Object.keys(this.slugDriverOptions).map((model) => {
|
||||||
|
const options = this.slugDriverOptions[model];
|
||||||
|
if (Object.keys(options).length > 1) {
|
||||||
|
return (
|
||||||
|
<FieldSet label={app.translator.trans('core.admin.basics.slug_driver_heading', { model })}>
|
||||||
|
<div className="helpText">{app.translator.trans('core.admin.basics.slug_driver_text', { model })}</div>
|
||||||
|
<Select options={options} value={this.values[`slug_driver_${model}`]()} onchange={this.values[`slug_driver_${model}`]}></Select>
|
||||||
|
</FieldSet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
|
||||||
{Button.component(
|
{Button.component(
|
||||||
{
|
{
|
||||||
|
@@ -1,16 +1,29 @@
|
|||||||
import Page from '../../common/components/Page';
|
import Page from '../../common/components/Page';
|
||||||
import StatusWidget from './StatusWidget';
|
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 {
|
export default class DashboardPage extends Page {
|
||||||
view() {
|
view() {
|
||||||
return (
|
return (
|
||||||
<div className="DashboardPage">
|
<div className="DashboardPage">
|
||||||
<div className="container">{this.availableWidgets()}</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
availableWidgets() {
|
availableWidgets() {
|
||||||
return [<StatusWidget />];
|
const items = new ItemList();
|
||||||
|
|
||||||
|
items.add('status', <StatusWidget />, 30);
|
||||||
|
|
||||||
|
items.add('extensions', <ExtensionsWidget />, 10);
|
||||||
|
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
29
js/src/admin/components/ExtensionLinkButton.js
Normal file
29
js/src/admin/components/ExtensionLinkButton.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
363
js/src/admin/components/ExtensionPage.js
Normal file
363
js/src/admin/components/ExtensionPage.js
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
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 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',
|
||||||
|
source: 'fas fa-code',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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">
|
||||||
|
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.enable_to_see')}</h3>
|
||||||
|
</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 })
|
||||||
|
) : (
|
||||||
|
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_permissions')}</h3>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
) : (
|
||||||
|
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_settings')}</h3>
|
||||||
|
)}
|
||||||
|
</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();
|
||||||
|
|
||||||
|
const links = this.extension.links;
|
||||||
|
|
||||||
|
if (links.authors.length) {
|
||||||
|
let authors = [];
|
||||||
|
|
||||||
|
links.authors.map((author) => {
|
||||||
|
authors.push(
|
||||||
|
<Link href={author.link} external={true} target="_blank">
|
||||||
|
{author.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
items.add('authors', [icon('fas fa-user'), <span>{punctuateSeries(authors)}</span>]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(this.infoFields).map((field) => {
|
||||||
|
if (links[field]) {
|
||||||
|
items.add(
|
||||||
|
field,
|
||||||
|
<LinkButton href={links[field]} icon={this.infoFields[field]} 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.
|
||||||
|
*
|
||||||
|
* Alternatively, you can pass a callback that will be executed in ExtensionPage's
|
||||||
|
* context to include custom JSX elements.
|
||||||
|
*
|
||||||
|
* @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) {
|
||||||
|
if (typeof entry === 'function') {
|
||||||
|
return entry.call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(', '),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
39
js/src/admin/components/ExtensionPermissionGrid.js
Normal file
39
js/src/admin/components/ExtensionPermissionGrid.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
@@ -1,158 +0,0 @@
|
|||||||
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();
|
|
||||||
}, 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(', '),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
46
js/src/admin/components/ExtensionsWidget.js
Normal file
46
js/src/admin/components/ExtensionsWidget.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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">
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,4 +1,5 @@
|
|||||||
import Component from '../../common/Component';
|
import Component from '../../common/Component';
|
||||||
|
import LinkButton from '../../common/components/LinkButton';
|
||||||
import SessionDropdown from './SessionDropdown';
|
import SessionDropdown from './SessionDropdown';
|
||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
import listItems from '../../common/helpers/listItems';
|
import listItems from '../../common/helpers/listItems';
|
||||||
@@ -19,6 +20,13 @@ export default class HeaderSecondary extends Component {
|
|||||||
items() {
|
items() {
|
||||||
const items = new ItemList();
|
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());
|
items.add('session', SessionDropdown.component());
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
|
@@ -6,6 +6,8 @@ import Select from '../../common/components/Select';
|
|||||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||||
import saveSettings from '../utils/saveSettings';
|
import saveSettings from '../utils/saveSettings';
|
||||||
import Stream from '../../common/utils/Stream';
|
import Stream from '../../common/utils/Stream';
|
||||||
|
import icon from '../../common/helpers/icon';
|
||||||
|
import AdminHeader from './AdminHeader';
|
||||||
|
|
||||||
export default class MailPage extends Page {
|
export default class MailPage extends Page {
|
||||||
oninit(vnode) {
|
oninit(vnode) {
|
||||||
@@ -65,11 +67,11 @@ export default class MailPage extends Page {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="MailPage">
|
<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">
|
<div className="container">
|
||||||
<form onsubmit={this.onsubmit.bind(this)}>
|
<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(
|
{FieldSet.component(
|
||||||
{
|
{
|
||||||
label: app.translator.trans('core.admin.email.addresses_heading'),
|
label: app.translator.trans('core.admin.email.addresses_heading'),
|
||||||
|
@@ -6,12 +6,6 @@ import ItemList from '../../common/utils/ItemList';
|
|||||||
import icon from '../../common/helpers/icon';
|
import icon from '../../common/helpers/icon';
|
||||||
|
|
||||||
export default class PermissionGrid extends Component {
|
export default class PermissionGrid extends Component {
|
||||||
oninit(vnode) {
|
|
||||||
super.oninit(vnode);
|
|
||||||
|
|
||||||
this.permissions = this.permissionItems().toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
const scopes = this.scopeItems().toArray();
|
const scopes = this.scopeItems().toArray();
|
||||||
|
|
||||||
@@ -35,7 +29,9 @@ export default class PermissionGrid extends Component {
|
|||||||
<th>{this.scopeControlItems().toArray()}</th>
|
<th>{this.scopeControlItems().toArray()}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
{this.permissions.map((section) => (
|
{this.permissionItems()
|
||||||
|
.toArray()
|
||||||
|
.map((section) => (
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr className="PermissionGrid-section">
|
<tr className="PermissionGrid-section">
|
||||||
<th>{section.label}</th>
|
<th>{section.label}</th>
|
||||||
@@ -158,6 +154,8 @@ export default class PermissionGrid extends Component {
|
|||||||
permission: 'user.viewLastSeenAt',
|
permission: 'user.viewLastSeenAt',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
items.merge(app.extensionData.getAllExtensionPermissions('view'));
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +196,8 @@ export default class PermissionGrid extends Component {
|
|||||||
90
|
90
|
||||||
);
|
);
|
||||||
|
|
||||||
|
items.merge(app.extensionData.getAllExtensionPermissions('start'));
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +238,8 @@ export default class PermissionGrid extends Component {
|
|||||||
90
|
90
|
||||||
);
|
);
|
||||||
|
|
||||||
|
items.merge(app.extensionData.getAllExtensionPermissions('reply'));
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +336,8 @@ export default class PermissionGrid extends Component {
|
|||||||
60
|
60
|
||||||
);
|
);
|
||||||
|
|
||||||
|
items.merge(app.extensionData.getAllExtensionPermissions('moderate'));
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -4,11 +4,15 @@ import EditGroupModal from './EditGroupModal';
|
|||||||
import Group from '../../common/models/Group';
|
import Group from '../../common/models/Group';
|
||||||
import icon from '../../common/helpers/icon';
|
import icon from '../../common/helpers/icon';
|
||||||
import PermissionGrid from './PermissionGrid';
|
import PermissionGrid from './PermissionGrid';
|
||||||
|
import AdminHeader from './AdminHeader';
|
||||||
|
|
||||||
export default class PermissionsPage extends Page {
|
export default class PermissionsPage extends Page {
|
||||||
view() {
|
view() {
|
||||||
return (
|
return (
|
||||||
<div className="PermissionsPage">
|
<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="PermissionsPage-groups">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
{app.store
|
{app.store
|
||||||
|
@@ -10,8 +10,9 @@ export { app };
|
|||||||
// Export public API
|
// Export public API
|
||||||
|
|
||||||
// Export compat API
|
// Export compat API
|
||||||
import compat from './compat';
|
import compatObj from './compat';
|
||||||
|
import proxifyCompat from '../common/utils/proxifyCompat';
|
||||||
|
|
||||||
compat.app = app;
|
compatObj.app = app;
|
||||||
|
|
||||||
export { compat };
|
export const compat = proxifyCompat(compatObj, 'admin');
|
||||||
|
19
js/src/admin/resolvers/ExtensionPageResolver.ts
Normal file
19
js/src/admin/resolvers/ExtensionPageResolver.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
@@ -2,8 +2,9 @@ import DashboardPage from './components/DashboardPage';
|
|||||||
import BasicsPage from './components/BasicsPage';
|
import BasicsPage from './components/BasicsPage';
|
||||||
import PermissionsPage from './components/PermissionsPage';
|
import PermissionsPage from './components/PermissionsPage';
|
||||||
import AppearancePage from './components/AppearancePage';
|
import AppearancePage from './components/AppearancePage';
|
||||||
import ExtensionsPage from './components/ExtensionsPage';
|
|
||||||
import MailPage from './components/MailPage';
|
import MailPage from './components/MailPage';
|
||||||
|
import ExtensionPage from './components/ExtensionPage';
|
||||||
|
import ExtensionPageResolver from './resolvers/ExtensionPageResolver';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `routes` initializer defines the forum app's routes.
|
* The `routes` initializer defines the forum app's routes.
|
||||||
@@ -16,7 +17,7 @@ export default function (app) {
|
|||||||
basics: { path: '/basics', component: BasicsPage },
|
basics: { path: '/basics', component: BasicsPage },
|
||||||
permissions: { path: '/permissions', component: PermissionsPage },
|
permissions: { path: '/permissions', component: PermissionsPage },
|
||||||
appearance: { path: '/appearance', component: AppearancePage },
|
appearance: { path: '/appearance', component: AppearancePage },
|
||||||
extensions: { path: '/extensions', component: ExtensionsPage },
|
|
||||||
mail: { path: '/mail', component: MailPage },
|
mail: { path: '/mail', component: MailPage },
|
||||||
|
extension: { path: '/extension/:id', component: ExtensionPage, resolverClass: ExtensionPageResolver },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
177
js/src/admin/utils/ExtensionData.js
Normal file
177
js/src/admin/utils/ExtensionData.js
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
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
|
||||||
|
*
|
||||||
|
* It takes either a settings object or a callback.
|
||||||
|
*
|
||||||
|
* @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();
|
||||||
|
|
||||||
|
// Callbacks can be passed in instead of settings to display custom content.
|
||||||
|
// By default, they will be added with the `null` key, since they don't have a `.setting` attr.
|
||||||
|
// To support multiple such items for one extension, we assign a random ID.
|
||||||
|
// 36 is arbitrary length, but makes collisions very unlikely.
|
||||||
|
if (typeof content === 'function') {
|
||||||
|
content.setting = Math.random().toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
25
js/src/admin/utils/getCategorizedExtensions.js
Normal file
25
js/src/admin/utils/getCategorizedExtensions.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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;
|
||||||
|
}
|
5
js/src/admin/utils/isExtensionEnabled.js
Normal file
5
js/src/admin/utils/isExtensionEnabled.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default function isExtensionEnabled(name) {
|
||||||
|
const enabled = JSON.parse(app.data.settings.extensions_enabled);
|
||||||
|
|
||||||
|
return enabled.includes(name);
|
||||||
|
}
|
@@ -1,8 +1,5 @@
|
|||||||
import * as Mithril from 'mithril';
|
import * as Mithril from 'mithril';
|
||||||
|
|
||||||
let deprecatedPropsWarned = false;
|
|
||||||
let deprecatedInitPropsWarned = false;
|
|
||||||
|
|
||||||
export interface ComponentAttrs extends Mithril.Attributes {}
|
export interface ComponentAttrs extends Mithril.Attributes {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,38 +128,5 @@ export default abstract class Component<T extends ComponentAttrs = ComponentAttr
|
|||||||
*
|
*
|
||||||
* This can be used to assign default values for missing, optional attrs.
|
* 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
|
|
||||||
}
|
}
|
||||||
|
@@ -21,6 +21,7 @@ import stringToColor from './utils/stringToColor';
|
|||||||
import subclassOf from './utils/subclassOf';
|
import subclassOf from './utils/subclassOf';
|
||||||
import SuperTextarea from './utils/SuperTextarea';
|
import SuperTextarea from './utils/SuperTextarea';
|
||||||
import patchMithril from './utils/patchMithril';
|
import patchMithril from './utils/patchMithril';
|
||||||
|
import proxifyCompat from './utils/proxifyCompat';
|
||||||
import classList from './utils/classList';
|
import classList from './utils/classList';
|
||||||
import extractText from './utils/extractText';
|
import extractText from './utils/extractText';
|
||||||
import formatNumber from './utils/formatNumber';
|
import formatNumber from './utils/formatNumber';
|
||||||
@@ -94,6 +95,7 @@ export default {
|
|||||||
'utils/SuperTextarea': SuperTextarea,
|
'utils/SuperTextarea': SuperTextarea,
|
||||||
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
|
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
|
||||||
'utils/patchMithril': patchMithril,
|
'utils/patchMithril': patchMithril,
|
||||||
|
'utils/proxifyCompat': proxifyCompat,
|
||||||
'utils/classList': classList,
|
'utils/classList': classList,
|
||||||
'utils/extractText': extractText,
|
'utils/extractText': extractText,
|
||||||
'utils/formatNumber': formatNumber,
|
'utils/formatNumber': formatNumber,
|
||||||
|
@@ -35,6 +35,11 @@ export default class Button extends Component {
|
|||||||
attrs['aria-label'] = attrs.title;
|
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 nothing else is provided, we use the textual button content as tooltip
|
||||||
if (!attrs.title && vnode.children) {
|
if (!attrs.title && vnode.children) {
|
||||||
attrs.title = extractText(vnode.children);
|
attrs.title = extractText(vnode.children);
|
||||||
|
@@ -29,6 +29,13 @@ export default class Page extends Component {
|
|||||||
* @type {Boolean}
|
* @type {Boolean}
|
||||||
*/
|
*/
|
||||||
this.scrollTopOnCreate = true;
|
this.scrollTopOnCreate = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the browser should restore scroll state on refreshes.
|
||||||
|
*
|
||||||
|
* @type {Boolean}
|
||||||
|
*/
|
||||||
|
this.useBrowserScrollRestoration = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
oncreate(vnode) {
|
oncreate(vnode) {
|
||||||
@@ -41,6 +48,10 @@ export default class Page extends Component {
|
|||||||
if (this.scrollTopOnCreate) {
|
if (this.scrollTopOnCreate) {
|
||||||
$(window).scrollTop(0);
|
$(window).scrollTop(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ('scrollRestoration' in history) {
|
||||||
|
history.scrollRestoration = this.useBrowserScrollRestoration ? 'auto' : 'manual';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onremove() {
|
onremove() {
|
||||||
|
@@ -12,6 +12,9 @@ import icon from '../helpers/icon';
|
|||||||
function isActive(vnode) {
|
function isActive(vnode) {
|
||||||
const tag = vnode.tag;
|
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) {
|
if ('initAttrs' in tag) {
|
||||||
tag.initAttrs(vnode.attrs);
|
tag.initAttrs(vnode.attrs);
|
||||||
}
|
}
|
||||||
|
@@ -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>
|
* The `fullTime` helper displays a formatted time string wrapped in a <time>
|
||||||
* tag.
|
* tag.
|
||||||
*
|
|
||||||
* @param {Date} time
|
|
||||||
* @return {Object}
|
|
||||||
*/
|
*/
|
||||||
export default function fullTime(time) {
|
export default function fullTime(time: Date): Mithril.Vnode {
|
||||||
const d = dayjs(time);
|
const d = dayjs(time);
|
||||||
|
|
||||||
const datetime = d.format();
|
const datetime = d.format();
|
@@ -1,14 +1,13 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import * as Mithril from 'mithril';
|
||||||
import humanTimeUtil from '../utils/humanTime';
|
import humanTimeUtil from '../utils/humanTime';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `humanTime` helper displays a time in a human-friendly time-ago format
|
* 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
|
* (e.g. '12 days ago'), wrapped in a <time> tag with other information about
|
||||||
* the time.
|
* the time.
|
||||||
*
|
|
||||||
* @param {Date} time
|
|
||||||
* @return {Object}
|
|
||||||
*/
|
*/
|
||||||
export default function humanTime(time) {
|
export default function humanTime(time: Date): Mithril.Vnode {
|
||||||
const d = dayjs(time);
|
const d = dayjs(time);
|
||||||
|
|
||||||
const datetime = d.format();
|
const datetime = d.format();
|
@@ -1,6 +1,6 @@
|
|||||||
import 'expose-loader?$!expose-loader?jQuery!jquery';
|
import 'expose-loader?$!expose-loader?jQuery!jquery';
|
||||||
import 'expose-loader?m!mithril';
|
import 'expose-loader?m!mithril';
|
||||||
import 'expose-loader?moment!expose-loader?dayjs!dayjs';
|
import 'expose-loader?dayjs!dayjs';
|
||||||
import 'expose-loader?m.bidi!m.attrs.bidi';
|
import 'expose-loader?m.bidi!m.attrs.bidi';
|
||||||
import 'bootstrap/js/affix';
|
import 'bootstrap/js/affix';
|
||||||
import 'bootstrap/js/dropdown';
|
import 'bootstrap/js/dropdown';
|
||||||
|
@@ -10,6 +10,7 @@ export default class User extends Model {}
|
|||||||
|
|
||||||
Object.assign(User.prototype, {
|
Object.assign(User.prototype, {
|
||||||
username: Model.attribute('username'),
|
username: Model.attribute('username'),
|
||||||
|
slug: Model.attribute('slug'),
|
||||||
displayName: Model.attribute('displayName'),
|
displayName: Model.attribute('displayName'),
|
||||||
email: Model.attribute('email'),
|
email: Model.attribute('email'),
|
||||||
isEmailConfirmed: Model.attribute('isEmailConfirmed'),
|
isEmailConfirmed: Model.attribute('isEmailConfirmed'),
|
||||||
|
80
js/src/common/utils/Pagination.ts
Normal file
80
js/src/common/utils/Pagination.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
export default class Pagination<T> {
|
||||||
|
private readonly loadFunction: (page: number) => Promise<any>;
|
||||||
|
|
||||||
|
public loading = {
|
||||||
|
prev: false,
|
||||||
|
next: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
public page: number;
|
||||||
|
|
||||||
|
public data: { [page: number]: T } = {};
|
||||||
|
|
||||||
|
public pages: {
|
||||||
|
first: number;
|
||||||
|
last: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(load: (page: number) => Promise<any>, page: number = 1) {
|
||||||
|
this.loadFunction = load;
|
||||||
|
this.page = page;
|
||||||
|
|
||||||
|
this.pages = {
|
||||||
|
first: page,
|
||||||
|
last: page,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.data = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(page: number) {
|
||||||
|
this.clear();
|
||||||
|
|
||||||
|
this.page = page;
|
||||||
|
this.pages.last = page - 1;
|
||||||
|
this.pages.first = page;
|
||||||
|
|
||||||
|
return this.loadNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadNext() {
|
||||||
|
this.loading.next = true;
|
||||||
|
const page = this.pages.last + 1;
|
||||||
|
|
||||||
|
return this.load(
|
||||||
|
page,
|
||||||
|
() => (this.loading.next = false),
|
||||||
|
() => (this.pages.last = this.page = page)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPrev() {
|
||||||
|
this.loading.prev = true;
|
||||||
|
const page = this.pages.first - 1;
|
||||||
|
|
||||||
|
return this.load(
|
||||||
|
page,
|
||||||
|
() => (this.loading.prev = false),
|
||||||
|
() => (this.pages.first = this.page = page)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(page, done, success) {
|
||||||
|
return this.loadFunction(page)
|
||||||
|
.then((out) => {
|
||||||
|
done();
|
||||||
|
success();
|
||||||
|
|
||||||
|
this.data[this.page] = out;
|
||||||
|
|
||||||
|
return out;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
done();
|
||||||
|
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@@ -1,3 +1,6 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import 'dayjs/plugin/relativeTime';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `humanTime` utility converts a date to a localized, human-readable time-
|
* The `humanTime` utility converts a date to a localized, human-readable time-
|
||||||
* ago string.
|
* ago string.
|
||||||
|
@@ -1,9 +1,3 @@
|
|||||||
import withAttr from './withAttr';
|
|
||||||
import Stream from './Stream';
|
|
||||||
|
|
||||||
let deprecatedMPropWarned = false;
|
|
||||||
let deprecatedMWithAttrWarned = false;
|
|
||||||
|
|
||||||
export default function patchMithril(global) {
|
export default function patchMithril(global) {
|
||||||
const defaultMithril = global.m;
|
const defaultMithril = global.m;
|
||||||
|
|
||||||
@@ -22,23 +16,5 @@ export default function patchMithril(global) {
|
|||||||
|
|
||||||
Object.keys(defaultMithril).forEach((key) => (modifiedMithril[key] = defaultMithril[key]));
|
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;
|
global.m = modifiedMithril;
|
||||||
}
|
}
|
||||||
|
10
js/src/common/utils/proxifyCompat.ts
Normal file
10
js/src/common/utils/proxifyCompat.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export default (compat: { [key: string]: any }, namespace: string) => {
|
||||||
|
// regex to replace common/ and NAMESPACE/ for core & core extensions
|
||||||
|
// e.g. admin/utils/extract --> utils/extract
|
||||||
|
// e.g. tags/common/utils/sortTags --> tags/utils/sortTags
|
||||||
|
const regex = new RegExp(`(\\w+\\/)?(${namespace}|common)\\/`);
|
||||||
|
|
||||||
|
return new Proxy(compat, {
|
||||||
|
get: (obj, prop: string) => obj[prop] || obj[prop.replace(regex, '$1')],
|
||||||
|
});
|
||||||
|
};
|
@@ -90,11 +90,6 @@ export default class ForumApplication extends Application {
|
|||||||
* @type {DiscussionListState}
|
* @type {DiscussionListState}
|
||||||
*/
|
*/
|
||||||
this.discussions = new DiscussionListState({}, this);
|
this.discussions = new DiscussionListState({}, this);
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated beta 14, remove in beta 15.
|
|
||||||
*/
|
|
||||||
this.cache.discussionList = this.discussions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -118,7 +118,10 @@ export default class ChangeEmailModal extends Modal {
|
|||||||
meta: { password: this.password() },
|
meta: { password: this.password() },
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.then(() => (this.success = true))
|
.then(() => {
|
||||||
|
this.success = true;
|
||||||
|
this.alertAttrs = null;
|
||||||
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.then(this.loaded.bind(this));
|
.then(this.loaded.bind(this));
|
||||||
}
|
}
|
||||||
|
@@ -265,7 +265,7 @@ export default class Composer extends Component {
|
|||||||
this.animateHeightChange().then(() => this.focus());
|
this.animateHeightChange().then(() => this.focus());
|
||||||
|
|
||||||
if (app.screen() === 'phone') {
|
if (app.screen() === 'phone') {
|
||||||
this.$().css('top', $(window).scrollTop());
|
this.$().css('top', 0);
|
||||||
this.showBackdrop();
|
this.showBackdrop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -44,12 +44,6 @@ export default class ComposerBody extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.composer.fields.content(this.attrs.originalContent || '');
|
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() {
|
||||||
|
@@ -100,7 +100,7 @@ export default class DiscussionComposer extends ComposerBody {
|
|||||||
.save(data)
|
.save(data)
|
||||||
.then((discussion) => {
|
.then((discussion) => {
|
||||||
this.composer.hide();
|
this.composer.hide();
|
||||||
app.discussions.refresh();
|
app.discussions.refresh({ deferClear: true });
|
||||||
m.route.set(app.route.discussion(discussion));
|
m.route.set(app.route.discussion(discussion));
|
||||||
}, this.loaded.bind(this));
|
}, this.loaded.bind(this));
|
||||||
}
|
}
|
||||||
|
@@ -21,13 +21,7 @@ export default class DiscussionList extends Component {
|
|||||||
if (state.isLoading()) {
|
if (state.isLoading()) {
|
||||||
loading = LoadingIndicator.component();
|
loading = LoadingIndicator.component();
|
||||||
} else if (state.moreResults) {
|
} else if (state.moreResults) {
|
||||||
loading = Button.component(
|
loading = this.getLoadButton('more', state.loadMore.bind(state));
|
||||||
{
|
|
||||||
className: 'Button',
|
|
||||||
onclick: state.loadMore.bind(state),
|
|
||||||
},
|
|
||||||
app.translator.trans('core.forum.discussion_list.load_more_button')
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.empty()) {
|
if (state.empty()) {
|
||||||
@@ -35,8 +29,18 @@ export default class DiscussionList extends Component {
|
|||||||
return <div className="DiscussionList">{Placeholder.component({ text })}</div>;
|
return <div className="DiscussionList">{Placeholder.component({ text })}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(state);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'DiscussionList' + (state.isSearchResults() ? ' DiscussionList--searchResults' : '')}>
|
<div className={'DiscussionList' + (state.isSearchResults() ? ' DiscussionList--searchResults' : '')}>
|
||||||
|
{state.isLoadingPrev() ? (
|
||||||
|
<LoadingIndicator />
|
||||||
|
) : state.pagination.pages.first !== 1 ? (
|
||||||
|
<div className="DiscussionList-loadMore">{this.getLoadButton('prev', state.loadPrev.bind(state))}</div>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
|
||||||
<ul className="DiscussionList-discussions">
|
<ul className="DiscussionList-discussions">
|
||||||
{state.discussions.map((discussion) => {
|
{state.discussions.map((discussion) => {
|
||||||
return (
|
return (
|
||||||
@@ -46,8 +50,17 @@ export default class DiscussionList extends Component {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="DiscussionList-loadMore">{loading}</div>
|
<div className="DiscussionList-loadMore">{loading}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLoadButton(key, onclick) {
|
||||||
|
return (
|
||||||
|
<Button className="Button" onclick={onclick}>
|
||||||
|
{app.translator.trans(`core.forum.discussion_list.load_${key}_button`)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -14,6 +14,7 @@ import DiscussionControls from '../utils/DiscussionControls';
|
|||||||
import slidable from '../utils/slidable';
|
import slidable from '../utils/slidable';
|
||||||
import extractText from '../../common/utils/extractText';
|
import extractText from '../../common/utils/extractText';
|
||||||
import classList from '../../common/utils/classList';
|
import classList from '../../common/utils/classList';
|
||||||
|
import DiscussionPage from './DiscussionPage';
|
||||||
|
|
||||||
import { escapeRegExp } from 'lodash-es';
|
import { escapeRegExp } from 'lodash-es';
|
||||||
/**
|
/**
|
||||||
@@ -156,9 +157,7 @@ export default class DiscussionListItem extends Component {
|
|||||||
* @return {Boolean}
|
* @return {Boolean}
|
||||||
*/
|
*/
|
||||||
active() {
|
active() {
|
||||||
const idParam = m.route.param('id');
|
return app.current.matches(DiscussionPage, { discussion: this.attrs.discussion });
|
||||||
|
|
||||||
return idParam && idParam.split('-')[0] === this.attrs.discussion.id();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -18,6 +18,8 @@ export default class DiscussionPage extends Page {
|
|||||||
oninit(vnode) {
|
oninit(vnode) {
|
||||||
super.oninit(vnode);
|
super.oninit(vnode);
|
||||||
|
|
||||||
|
this.useBrowserScrollRestoration = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The discussion that is being viewed.
|
* The discussion that is being viewed.
|
||||||
*
|
*
|
||||||
@@ -107,7 +109,7 @@ export default class DiscussionPage extends Page {
|
|||||||
} else {
|
} else {
|
||||||
const params = this.requestParams();
|
const params = this.requestParams();
|
||||||
|
|
||||||
app.store.find('discussions', m.route.param('id').split('-')[0], params).then(this.show.bind(this));
|
app.store.find('discussions', m.route.param('id'), params).then(this.show.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
m.redraw();
|
m.redraw();
|
||||||
@@ -121,6 +123,7 @@ export default class DiscussionPage extends Page {
|
|||||||
*/
|
*/
|
||||||
requestParams() {
|
requestParams() {
|
||||||
return {
|
return {
|
||||||
|
bySlug: true,
|
||||||
page: { near: this.near },
|
page: { near: this.near },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@@ -149,6 +149,11 @@ export default class PostStream extends Component {
|
|||||||
*/
|
*/
|
||||||
onscroll(top = window.pageYOffset) {
|
onscroll(top = window.pageYOffset) {
|
||||||
if (this.stream.paused) return;
|
if (this.stream.paused) return;
|
||||||
|
|
||||||
|
this.updateScrubber(top);
|
||||||
|
|
||||||
|
if (this.stream.pagesLoading) return;
|
||||||
|
|
||||||
const marginTop = this.getMarginTop();
|
const marginTop = this.getMarginTop();
|
||||||
const viewportHeight = $(window).height() - marginTop;
|
const viewportHeight = $(window).height() - marginTop;
|
||||||
const viewportTop = top + marginTop;
|
const viewportTop = top + marginTop;
|
||||||
@@ -174,8 +179,6 @@ export default class PostStream extends Component {
|
|||||||
// viewport) to 100ms.
|
// viewport) to 100ms.
|
||||||
clearTimeout(this.calculatePositionTimeout);
|
clearTimeout(this.calculatePositionTimeout);
|
||||||
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this, top), 100);
|
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this, top), 100);
|
||||||
|
|
||||||
this.updateScrubber(top);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateScrubber(top = window.pageYOffset) {
|
updateScrubber(top = window.pageYOffset) {
|
||||||
@@ -287,7 +290,9 @@ export default class PostStream extends Component {
|
|||||||
* @return {Integer}
|
* @return {Integer}
|
||||||
*/
|
*/
|
||||||
getMarginTop() {
|
getMarginTop() {
|
||||||
return this.$() && $('#header').outerHeight() + parseInt(this.$().css('margin-top'), 10);
|
const headerId = app.screen() === 'phone' ? '#app-navigation' : '#header';
|
||||||
|
|
||||||
|
return this.$() && $(headerId).outerHeight() + parseInt(this.$().css('margin-top'), 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -21,6 +21,8 @@ import UsersSearchSource from './UsersSearchSource';
|
|||||||
* - state: SearchState instance.
|
* - state: SearchState instance.
|
||||||
*/
|
*/
|
||||||
export default class Search extends Component {
|
export default class Search extends Component {
|
||||||
|
static MIN_SEARCH_LEN = 3;
|
||||||
|
|
||||||
oninit(vnode) {
|
oninit(vnode) {
|
||||||
super.oninit(vnode);
|
super.oninit(vnode);
|
||||||
this.state = this.attrs.state;
|
this.state = this.attrs.state;
|
||||||
@@ -152,7 +154,7 @@ export default class Search extends Component {
|
|||||||
search.searchTimeout = setTimeout(() => {
|
search.searchTimeout = setTimeout(() => {
|
||||||
if (state.isCached(query)) return;
|
if (state.isCached(query)) return;
|
||||||
|
|
||||||
if (query.length >= 3) {
|
if (query.length >= Search.MIN_SEARCH_LEN) {
|
||||||
search.sources.map((source) => {
|
search.sources.map((source) => {
|
||||||
if (!source.search) return;
|
if (!source.search) return;
|
||||||
|
|
||||||
|
@@ -102,7 +102,7 @@ export default class UserPage extends Page {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!this.user) {
|
if (!this.user) {
|
||||||
app.store.find('users', username).then(this.show.bind(this));
|
app.store.find('users', username, { bySlug: true }).then(this.show.bind(this));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -15,8 +15,9 @@ export { app };
|
|||||||
// export { IndexPage, DicsussionList } from './components';
|
// export { IndexPage, DicsussionList } from './components';
|
||||||
|
|
||||||
// Export compat API
|
// Export compat API
|
||||||
import compat from './compat';
|
import compatObj from './compat';
|
||||||
|
import proxifyCompat from '../common/utils/proxifyCompat';
|
||||||
|
|
||||||
compat.app = app;
|
compatObj.app = app;
|
||||||
|
|
||||||
export { compat };
|
export const compat = proxifyCompat(compatObj, 'forum');
|
||||||
|
@@ -1,15 +1,6 @@
|
|||||||
import DefaultResolver from '../../common/resolvers/DefaultResolver';
|
import DefaultResolver from '../../common/resolvers/DefaultResolver';
|
||||||
import DiscussionPage from '../components/DiscussionPage';
|
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
|
* 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
|
* on the same discussion. It triggers a scroll when going from one post to another
|
||||||
@@ -18,17 +9,32 @@ function getDiscussionIdFromSlug(slug: string | undefined) {
|
|||||||
export default class DiscussionPageResolver extends DefaultResolver {
|
export default class DiscussionPageResolver extends DefaultResolver {
|
||||||
static scrollToPostNumber: string | null = null;
|
static scrollToPostNumber: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove optional parts of a discussion's slug to keep the substring
|
||||||
|
* that bijectively maps to a discussion object. By default this just
|
||||||
|
* extracts the numerical ID from the slug. If a custom discussion
|
||||||
|
* slugging driver is used, this may need to be overriden.
|
||||||
|
* @param slug
|
||||||
|
*/
|
||||||
|
canonicalizeDiscussionSlug(slug: string | undefined) {
|
||||||
|
if (!slug) return;
|
||||||
|
return slug.split('-')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
makeKey() {
|
makeKey() {
|
||||||
const params = { ...m.route.param() };
|
const params = { ...m.route.param() };
|
||||||
if ('near' in params) {
|
if ('near' in params) {
|
||||||
delete params.near;
|
delete params.near;
|
||||||
}
|
}
|
||||||
params.id = getDiscussionIdFromSlug(params.id);
|
params.id = this.canonicalizeDiscussionSlug(params.id);
|
||||||
return this.routeName.replace('.near', '') + JSON.stringify(params);
|
return this.routeName.replace('.near', '') + JSON.stringify(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
onmatch(args, requestedPath, route) {
|
onmatch(args, requestedPath, route) {
|
||||||
if (app.current.matches(DiscussionPage) && getDiscussionIdFromSlug(args.id) === getDiscussionIdFromSlug(m.route.param('id'))) {
|
if (app.current.matches(DiscussionPage) && this.canonicalizeDiscussionSlug(args.id) === this.canonicalizeDiscussionSlug(m.route.param('id'))) {
|
||||||
// By default, the first post number of any discussion is 1
|
// By default, the first post number of any discussion is 1
|
||||||
DiscussionPageResolver.scrollToPostNumber = args.near || '1';
|
DiscussionPageResolver.scrollToPostNumber = args.near || '1';
|
||||||
}
|
}
|
||||||
|
@@ -34,9 +34,8 @@ export default function (app) {
|
|||||||
* @return {String}
|
* @return {String}
|
||||||
*/
|
*/
|
||||||
app.route.discussion = (discussion, near) => {
|
app.route.discussion = (discussion, near) => {
|
||||||
const slug = discussion.slug();
|
|
||||||
return app.route(near && near !== 1 ? 'discussion.near' : 'discussion', {
|
return app.route(near && near !== 1 ? 'discussion.near' : 'discussion', {
|
||||||
id: discussion.id() + (slug.trim() ? '-' + slug : ''),
|
id: discussion.slug(),
|
||||||
near: near && near !== 1 ? near : undefined,
|
near: near && near !== 1 ? near : undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -59,7 +58,7 @@ export default function (app) {
|
|||||||
*/
|
*/
|
||||||
app.route.user = (user) => {
|
app.route.user = (user) => {
|
||||||
return app.route('user', {
|
return app.route('user', {
|
||||||
username: user.username(),
|
username: user.slug(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@@ -34,11 +34,6 @@ class ComposerState {
|
|||||||
this.editor = null;
|
this.editor = null;
|
||||||
|
|
||||||
this.clear();
|
this.clear();
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated BC layer, remove in Beta 15.
|
|
||||||
*/
|
|
||||||
this.component = this;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,12 +72,6 @@ class ComposerState {
|
|||||||
this.fields = {
|
this.fields = {
|
||||||
content: Stream(''),
|
content: Stream(''),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated BC layer, remove in Beta 15.
|
|
||||||
*/
|
|
||||||
this.content = this.fields.content;
|
|
||||||
this.value = this.fields.content;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -1,4 +1,8 @@
|
|||||||
|
import Pagination from '../../common/utils/Pagination';
|
||||||
|
|
||||||
export default class DiscussionListState {
|
export default class DiscussionListState {
|
||||||
|
static DISCUSSIONS_PER_PAGE = 20;
|
||||||
|
|
||||||
constructor(params = {}, app = window.app) {
|
constructor(params = {}, app = window.app) {
|
||||||
this.params = params;
|
this.params = params;
|
||||||
|
|
||||||
@@ -8,7 +12,7 @@ export default class DiscussionListState {
|
|||||||
|
|
||||||
this.moreResults = false;
|
this.moreResults = false;
|
||||||
|
|
||||||
this.loading = false;
|
this.pagination = new Pagination(this.load.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,33 +86,16 @@ export default class DiscussionListState {
|
|||||||
* This can be used to refresh discussions without loading animations.
|
* This can be used to refresh discussions without loading animations.
|
||||||
*/
|
*/
|
||||||
refresh({ deferClear = false } = {}) {
|
refresh({ deferClear = false } = {}) {
|
||||||
this.loading = true;
|
this.pagination.loading.next = true;
|
||||||
|
|
||||||
if (!deferClear) {
|
if (!deferClear) {
|
||||||
this.clear();
|
this.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.loadResults().then(
|
return this.pagination.refresh(Number(m.route.param('page')) || 1).then(this.parse.bind(this));
|
||||||
(results) => {
|
|
||||||
// This ensures that any changes made while waiting on this request
|
|
||||||
// are ignored. Otherwise, we could get duplicate discussions.
|
|
||||||
// We don't use `this.clear()` to avoid an unnecessary redraw.
|
|
||||||
this.discussions = [];
|
|
||||||
this.parseResults(results);
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
this.loading = false;
|
|
||||||
m.redraw();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
load(page) {
|
||||||
* Load a new page of discussion results.
|
|
||||||
*
|
|
||||||
* @param offset The index to start the page at.
|
|
||||||
*/
|
|
||||||
loadResults(offset) {
|
|
||||||
const preloadedDiscussions = this.app.preloadedApiDocument();
|
const preloadedDiscussions = this.app.preloadedApiDocument();
|
||||||
|
|
||||||
if (preloadedDiscussions) {
|
if (preloadedDiscussions) {
|
||||||
@@ -116,44 +103,54 @@ export default class DiscussionListState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const params = this.requestParams();
|
const params = this.requestParams();
|
||||||
params.page = { offset };
|
params.page = { offset: DiscussionListState.DISCUSSIONS_PER_PAGE * (page - 1) };
|
||||||
params.include = params.include.join(',');
|
params.include = params.include.join(',');
|
||||||
|
|
||||||
return this.app.store.find('discussions', params);
|
return this.app.store.find('discussions', params);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
loadPrev() {
|
||||||
* Load the next page of discussion results.
|
return this.pagination.loadPrev().then(this.parse.bind(this));
|
||||||
*/
|
}
|
||||||
loadMore() {
|
|
||||||
this.loading = true;
|
|
||||||
|
|
||||||
this.loadResults(this.discussions.length).then(this.parseResults.bind(this));
|
loadMore() {
|
||||||
|
return this.pagination.loadNext().then(this.parse.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse results and append them to the discussion list.
|
* Parse results and append them to the discussion list.
|
||||||
*/
|
*/
|
||||||
parseResults(results) {
|
parse() {
|
||||||
this.discussions.push(...results);
|
const discussions = [];
|
||||||
|
const { first, last } = this.pagination.pages;
|
||||||
|
|
||||||
this.loading = false;
|
for (let page = first; page <= last; page++) {
|
||||||
this.moreResults = !!results.payload.links && !!results.payload.links.next;
|
const results = this.pagination.data[page];
|
||||||
|
|
||||||
|
if (Array.isArray(results)) discussions.push(...results);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.discussions = discussions;
|
||||||
|
|
||||||
|
const results = this.pagination.data[last];
|
||||||
|
this.moreResults = !!results.payload.links.next;
|
||||||
|
|
||||||
m.redraw();
|
m.redraw();
|
||||||
|
|
||||||
return results;
|
return discussions;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a discussion from the list if it is present.
|
* Remove a discussion from the list if it is present.
|
||||||
*/
|
*/
|
||||||
removeDiscussion(discussion) {
|
removeDiscussion(discussion) {
|
||||||
const index = this.discussions.indexOf(discussion);
|
Object.keys(this.pagination.data).forEach((key) => {
|
||||||
|
const index = this.pagination.data[key].indexOf(discussion);
|
||||||
|
|
||||||
if (index !== -1) {
|
this.pagination.data[key].splice(index, 1);
|
||||||
this.discussions.splice(index, 1);
|
});
|
||||||
}
|
|
||||||
|
this.parse();
|
||||||
|
|
||||||
m.redraw();
|
m.redraw();
|
||||||
}
|
}
|
||||||
@@ -177,7 +174,11 @@ export default class DiscussionListState {
|
|||||||
* Are discussions currently being loaded?
|
* Are discussions currently being loaded?
|
||||||
*/
|
*/
|
||||||
isLoading() {
|
isLoading() {
|
||||||
return this.loading;
|
return this.pagination.loading.next;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingPrev() {
|
||||||
|
return this.pagination.loading.prev;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -172,7 +172,7 @@ class PostStreamState {
|
|||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
*/
|
*/
|
||||||
loadNearIndex(index) {
|
loadNearIndex(index) {
|
||||||
if (index >= this.visibleStart && index <= this.visibleEnd) {
|
if (index >= this.visibleStart && index < this.visibleEnd) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,23 +238,26 @@ class PostStreamState {
|
|||||||
* @param {Boolean} backwards
|
* @param {Boolean} backwards
|
||||||
*/
|
*/
|
||||||
loadPage(start, end, backwards = false) {
|
loadPage(start, end, backwards = false) {
|
||||||
m.redraw();
|
this.pagesLoading++;
|
||||||
|
|
||||||
|
const redraw = () => {
|
||||||
|
if (start < this.visibleStart || end > this.visibleEnd) return;
|
||||||
|
|
||||||
|
const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
|
||||||
|
anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, m.redraw.sync);
|
||||||
|
};
|
||||||
|
redraw();
|
||||||
|
|
||||||
this.loadPageTimeouts[start] = setTimeout(
|
this.loadPageTimeouts[start] = setTimeout(
|
||||||
() => {
|
() => {
|
||||||
this.loadRange(start, end).then(() => {
|
this.loadRange(start, end).then(() => {
|
||||||
if (start >= this.visibleStart && end <= this.visibleEnd) {
|
redraw();
|
||||||
const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
|
|
||||||
anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, () => m.redraw.sync());
|
|
||||||
}
|
|
||||||
this.pagesLoading--;
|
this.pagesLoading--;
|
||||||
});
|
});
|
||||||
this.loadPageTimeouts[start] = null;
|
this.loadPageTimeouts[start] = null;
|
||||||
},
|
},
|
||||||
this.pagesLoading ? 1000 : 0
|
this.pagesLoading - 1 ? 1000 : 0
|
||||||
);
|
);
|
||||||
|
|
||||||
this.pagesLoading++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
"files": ["shims.d.ts"],
|
"files": ["shims.d.ts"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"allowUmdGlobalAccess": true,
|
"allowUmdGlobalAccess": true,
|
||||||
|
@@ -1,10 +1,12 @@
|
|||||||
@import "common/common";
|
@import "common/common";
|
||||||
|
|
||||||
|
@import "admin/AdminHeader";
|
||||||
@import "admin/AdminNav";
|
@import "admin/AdminNav";
|
||||||
@import "admin/DashboardPage";
|
@import "admin/DashboardPage";
|
||||||
@import "admin/BasicsPage";
|
@import "admin/BasicsPage";
|
||||||
@import "admin/PermissionsPage";
|
@import "admin/PermissionsPage";
|
||||||
@import "admin/EditGroupModal";
|
@import "admin/EditGroupModal";
|
||||||
@import "admin/ExtensionsPage";
|
@import "admin/ExtensionPage";
|
||||||
|
@import "admin/ExtensionWidget";
|
||||||
@import "admin/AppearancePage";
|
@import "admin/AppearancePage";
|
||||||
@import "admin/MailPage";
|
@import "admin/MailPage";
|
||||||
|
20
less/admin/AdminHeader.less
Normal file
20
less/admin/AdminHeader.less
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
.AdminHeader {
|
||||||
|
background: @control-bg;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 20px 0;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: @muted-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.AdminHeader-description {
|
||||||
|
margin: 0;
|
||||||
|
color: @control-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
}
|
@@ -1,18 +1,83 @@
|
|||||||
@admin-pane-width: 300px;
|
@admin-pane-width: 250px;
|
||||||
|
|
||||||
.App {
|
.App {
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
.AdminLinkButton-description {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.AdminContent {
|
.AdminContent {
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
}
|
}
|
||||||
.App-content .sideNavOffset {
|
.App-content .sideNavOffset {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
@media @desktop, @desktop-hd {
|
|
||||||
|
.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 {
|
||||||
|
.AdminNav {
|
||||||
|
.item-search,
|
||||||
|
li[class^="item-category"],
|
||||||
|
li[class^="item-extension"],
|
||||||
|
.AdminLinkButton-description {
|
||||||
|
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-up {
|
||||||
.App-nav {
|
.App-nav {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: @header-height;
|
top: @header-height;
|
||||||
@@ -20,60 +85,95 @@
|
|||||||
width: @admin-pane-width;
|
width: @admin-pane-width;
|
||||||
.box-shadow(0 6px 6px @shadow-color);
|
.box-shadow(0 6px 6px @shadow-color);
|
||||||
background: @body-bg;
|
background: @body-bg;
|
||||||
border-top: 1px solid @control-bg;
|
|
||||||
z-index: @zindex-pane;
|
z-index: @zindex-pane;
|
||||||
overflow: auto;
|
overflow-y: scroll;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
|
||||||
.affix & {
|
.affix & {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
height: auto;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.App-content .sideNavOffset {
|
.App-content .sideNavOffset {
|
||||||
margin-left: @admin-pane-width;
|
margin-left: @admin-pane-width;
|
||||||
}
|
}
|
||||||
.App-nav .AdminNav {
|
.App-nav .AdminNav {
|
||||||
.Dropdown-menu > li {
|
.Dropdown-menu {
|
||||||
|
.item-search {
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-category-core {
|
||||||
|
> .ExtensionListTitle {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> li {
|
||||||
> a {
|
> a {
|
||||||
padding: 15px 15px 15px 45px;
|
padding: 10px 10px 10px 45px;
|
||||||
display: block;
|
display: block;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
white-space: normal;
|
|
||||||
}
|
}
|
||||||
> a, > a:hover, &.active > a {
|
|
||||||
color: @muted-color;
|
> a,
|
||||||
|
> a:hover,
|
||||||
|
&.active > a {
|
||||||
|
color: @text-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
> a:hover {
|
> a:hover {
|
||||||
background: @control-bg;
|
background: @control-bg;
|
||||||
}
|
}
|
||||||
&.active > a {
|
|
||||||
background: @control-bg;
|
|
||||||
font-weight: normal;
|
|
||||||
|
|
||||||
.Button-label, .Button-icon {
|
&.active > a {
|
||||||
color: @text-color;
|
background: @control-color;
|
||||||
|
font-weight: normal;
|
||||||
|
color: @body-bg;
|
||||||
|
|
||||||
|
.Button-label,
|
||||||
|
.Button-icon {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.Button-icon {
|
.Button-icon {
|
||||||
float: left;
|
float: left;
|
||||||
margin-left: -30px;
|
font-size: 13px !important;
|
||||||
font-size: 14px;
|
margin-left: -25px !important;
|
||||||
margin-top: 4px !important;
|
margin-top: 4px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Button-label {
|
.Button-label {
|
||||||
display: block;
|
padding-left: 5px;
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
margin: 0 0 5px;
|
|
||||||
}
|
}
|
||||||
.AdminLinkButton-description {
|
|
||||||
display: block;
|
.Search-input,
|
||||||
font-size: 12px;
|
.SearchBar {
|
||||||
|
max-width: 215px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ExtensionListTitle {
|
||||||
|
color: @muted-color;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 25px 0 8px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ExtensionIcon {
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
font-size: 15px;
|
||||||
|
margin-left: -29px;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -85,4 +185,38 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.AdminLinkButton-description {
|
||||||
|
white-space: normal;
|
||||||
|
padding-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
}
|
}
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
.AppearancePage {
|
.AppearancePage {
|
||||||
|
|
||||||
@media @desktop-up {
|
@media @desktop-up {
|
||||||
.container {
|
.container {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
padding: 30px;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
.BasicsPage {
|
.BasicsPage {
|
||||||
padding: 20px 0;
|
|
||||||
|
|
||||||
@media @desktop-up {
|
@media @desktop-up {
|
||||||
.container {
|
.container {
|
||||||
|
@@ -1,33 +1,30 @@
|
|||||||
.DashboardPage {
|
.DashboardPage {
|
||||||
background: @control-bg;
|
background: @body-bg;
|
||||||
color: @control-color;
|
color: @control-color;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
|
||||||
@media @desktop-up {
|
|
||||||
.container {
|
|
||||||
padding: 30px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.DashboardWidget {
|
.Widget {
|
||||||
background: @body-bg;
|
background: @control-bg;
|
||||||
color: @text-color;
|
color: @text-color;
|
||||||
border-radius: @border-radius;
|
border-radius: @border-radius;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.Button {
|
||||||
|
.Button--color(@control-color, @body-bg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.StatusWidget {
|
.StatusWidget {
|
||||||
color: @muted-color;
|
color: @muted-color;
|
||||||
|
|
||||||
> ul {
|
>ul {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
|
|
||||||
> li {
|
>li {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-right: 30px;
|
margin-right: 30px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
@@ -38,6 +35,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.item-tools {
|
&.item-tools {
|
||||||
float: right;
|
float: right;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
|
157
less/admin/ExtensionPage.less
Normal file
157
less/admin/ExtensionPage.less
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
.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, .ExtensionPage-permissions {
|
||||||
|
.ExtensionPage-subHeader {
|
||||||
|
margin: 5px 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ExtensionPage-settings {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 10px 0;
|
||||||
|
|
||||||
|
input {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ExtensionPage-subHeader {
|
||||||
|
color: @muted-color;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.ExtensionPage-permissions {
|
||||||
|
|
||||||
|
@media @phone {
|
||||||
|
> .container {
|
||||||
|
overflow-x: scroll;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ExtensionPage-permissions-header {
|
||||||
|
margin: 20px 0 20px;
|
||||||
|
padding: 5px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
91
less/admin/ExtensionWidget.less
Normal file
91
less/admin/ExtensionWidget.less
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
.ExtensionsWidget {
|
||||||
|
background-color: @body-bg;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ExtensionsWidget-list {
|
||||||
|
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;
|
||||||
|
}
|
@@ -1,115 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,5 +1,4 @@
|
|||||||
.MailPage {
|
.MailPage {
|
||||||
padding: 20px 0;
|
|
||||||
|
|
||||||
@media @desktop-up {
|
@media @desktop-up {
|
||||||
.container {
|
.container {
|
||||||
|
@@ -1,6 +1,15 @@
|
|||||||
.PermissionsPage-groups {
|
.PermissionsPage-groups {
|
||||||
background: @control-bg;
|
background: @control-bg;
|
||||||
padding: 20px 0;
|
border-radius: @border-radius;
|
||||||
|
max-width: calc(~'100% - 60px');
|
||||||
|
display: block;
|
||||||
|
margin-left: 30px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 10px 0 8px;
|
||||||
|
|
||||||
|
> .container {
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.Group {
|
.Group {
|
||||||
width: 90px;
|
width: 90px;
|
||||||
@@ -37,6 +46,7 @@
|
|||||||
|
|
||||||
.PermissionGrid {
|
.PermissionGrid {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
padding-left: 0 12px;
|
||||||
|
|
||||||
td, th {
|
td, th {
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
@@ -112,7 +122,7 @@
|
|||||||
}
|
}
|
||||||
.PermissionGrid-section {
|
.PermissionGrid-section {
|
||||||
td, th {
|
td, th {
|
||||||
padding-top: 20px;
|
padding-top: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.PermissionGrid-child {
|
.PermissionGrid-child {
|
||||||
|
@@ -10,29 +10,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// PHONES: Somewhere on the page there will be a .App-backControl, a
|
||||||
// .App-primaryControl, and a .App-titleControl. We will position these on the
|
// .App-primaryControl, and a .App-titleControl. We will position these on the
|
||||||
// left, right, and center of the header respectively.
|
// left, right, and center of the header respectively.
|
||||||
@media @phone {
|
@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 {
|
.App-primaryControl, .App-titleControl, .App-backControl {
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
z-index: @zindex-header + 1;
|
z-index: @zindex-header + 1;
|
||||||
@@ -234,18 +228,19 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.App-header {
|
.App-header {
|
||||||
|
.header-background();
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
height: @header-height;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
border-bottom: 0;
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: @zindex-header;
|
|
||||||
|
|
||||||
.affix & {
|
.affix & {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scrolled & {
|
||||||
|
.box-shadow(0 2px 6px @shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
& when (@config-colored-header = true) {
|
& when (@config-colored-header = true) {
|
||||||
.light-contents(@header-color, @header-control-bg, @header-control-color);
|
.light-contents(@header-color, @header-control-bg, @header-control-color);
|
||||||
}
|
}
|
||||||
|
@@ -105,6 +105,10 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.off.Checkbox--switch .Checkbox-display {
|
||||||
|
background: @muted-more-color;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.Modal-footer {
|
.Modal-footer {
|
||||||
border: 0;
|
border: 0;
|
||||||
|
@@ -6,7 +6,6 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
z-index: @zindex-header;
|
z-index: @zindex-header;
|
||||||
border-bottom: 1px solid @control-bg;
|
border-bottom: 1px solid @control-bg;
|
||||||
.translate3d(0, 0, 0);
|
|
||||||
.transition(~"box-shadow 0.2s, -webkit-transform 0.2s");
|
.transition(~"box-shadow 0.2s, -webkit-transform 0.2s");
|
||||||
|
|
||||||
@media @phone {
|
@media @phone {
|
||||||
|
@@ -114,7 +114,7 @@
|
|||||||
background: @body-bg;
|
background: @body-bg;
|
||||||
|
|
||||||
&:not(.minimized) {
|
&:not(.minimized) {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
height: 350px !important;
|
height: 350px !important;
|
||||||
padding-top: @header-height-phone;
|
padding-top: @header-height-phone;
|
||||||
@@ -219,7 +219,6 @@
|
|||||||
.Composer {
|
.Composer {
|
||||||
border-radius: @border-radius @border-radius 0 0;
|
border-radius: @border-radius @border-radius 0 0;
|
||||||
background: fade(@body-bg, 95%);
|
background: fade(@body-bg, 95%);
|
||||||
transform: translateZ(0); // Fix for Chrome bug where a transparent white background is actually gray
|
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
.transition(~"background 0.2s, box-shadow 0.2s");
|
.transition(~"background 0.2s, box-shadow 0.2s");
|
||||||
|
@@ -54,9 +54,10 @@ class AdminServiceProvider extends AbstractServiceProvider
|
|||||||
HttpMiddleware\StartSession::class,
|
HttpMiddleware\StartSession::class,
|
||||||
HttpMiddleware\RememberFromCookie::class,
|
HttpMiddleware\RememberFromCookie::class,
|
||||||
HttpMiddleware\AuthenticateWithSession::class,
|
HttpMiddleware\AuthenticateWithSession::class,
|
||||||
HttpMiddleware\CheckCsrfToken::class,
|
|
||||||
HttpMiddleware\SetLocale::class,
|
HttpMiddleware\SetLocale::class,
|
||||||
Middleware\RequireAdministrateAbility::class,
|
'flarum.admin.route_resolver',
|
||||||
|
HttpMiddleware\CheckCsrfToken::class,
|
||||||
|
Middleware\RequireAdministrateAbility::class
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,6 +69,10 @@ 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 () {
|
$this->app->singleton('flarum.admin.handler', function () {
|
||||||
$pipe = new MiddlewarePipe;
|
$pipe = new MiddlewarePipe;
|
||||||
|
|
||||||
@@ -75,7 +80,7 @@ class AdminServiceProvider extends AbstractServiceProvider
|
|||||||
$pipe->pipe($this->app->make($middleware));
|
$pipe->pipe($this->app->make($middleware));
|
||||||
}
|
}
|
||||||
|
|
||||||
$pipe->pipe(new HttpMiddleware\DispatchRoute($this->app->make('flarum.admin.routes')));
|
$pipe->pipe(new HttpMiddleware\ExecuteRoute());
|
||||||
|
|
||||||
return $pipe;
|
return $pipe;
|
||||||
});
|
});
|
||||||
|
@@ -75,6 +75,9 @@ class AdminPayload
|
|||||||
$document->payload['extensions'] = $this->extensions->getExtensions()->toArray();
|
$document->payload['extensions'] = $this->extensions->getExtensions()->toArray();
|
||||||
|
|
||||||
$document->payload['displayNameDrivers'] = array_keys($this->container->make('flarum.user.display_name.supported_drivers'));
|
$document->payload['displayNameDrivers'] = array_keys($this->container->make('flarum.user.display_name.supported_drivers'));
|
||||||
|
$document->payload['slugDrivers'] = array_map(function ($resourceDrivers) {
|
||||||
|
return array_keys($resourceDrivers);
|
||||||
|
}, $this->container->make('flarum.http.slugDrivers'));
|
||||||
|
|
||||||
$document->payload['phpVersion'] = PHP_VERSION;
|
$document->payload['phpVersion'] = PHP_VERSION;
|
||||||
$document->payload['mysqlVersion'] = $this->db->selectOne('select version() as version')->version;
|
$document->payload['mysqlVersion'] = $this->db->selectOne('select version() as version')->version;
|
||||||
|
@@ -42,6 +42,20 @@ class ApiServiceProvider extends AbstractServiceProvider
|
|||||||
return $routes;
|
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 () {
|
$this->app->singleton('flarum.api.middleware', function () {
|
||||||
return [
|
return [
|
||||||
'flarum.api.error_handler',
|
'flarum.api.error_handler',
|
||||||
@@ -51,8 +65,10 @@ class ApiServiceProvider extends AbstractServiceProvider
|
|||||||
HttpMiddleware\RememberFromCookie::class,
|
HttpMiddleware\RememberFromCookie::class,
|
||||||
HttpMiddleware\AuthenticateWithSession::class,
|
HttpMiddleware\AuthenticateWithSession::class,
|
||||||
HttpMiddleware\AuthenticateWithHeader::class,
|
HttpMiddleware\AuthenticateWithHeader::class,
|
||||||
HttpMiddleware\CheckCsrfToken::class,
|
|
||||||
HttpMiddleware\SetLocale::class,
|
HttpMiddleware\SetLocale::class,
|
||||||
|
'flarum.api.route_resolver',
|
||||||
|
HttpMiddleware\CheckCsrfToken::class,
|
||||||
|
Middleware\ThrottleApi::class
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,6 +80,10 @@ 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 () {
|
$this->app->singleton('flarum.api.handler', function () {
|
||||||
$pipe = new MiddlewarePipe;
|
$pipe = new MiddlewarePipe;
|
||||||
|
|
||||||
@@ -71,10 +91,16 @@ class ApiServiceProvider extends AbstractServiceProvider
|
|||||||
$pipe->pipe($this->app->make($middleware));
|
$pipe->pipe($this->app->make($middleware));
|
||||||
}
|
}
|
||||||
|
|
||||||
$pipe->pipe(new HttpMiddleware\DispatchRoute($this->app->make('flarum.api.routes')));
|
$pipe->pipe(new HttpMiddleware\ExecuteRoute());
|
||||||
|
|
||||||
return $pipe;
|
return $pipe;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$this->app->singleton('flarum.api.notification_serializers', function () {
|
||||||
|
return [
|
||||||
|
'discussionRenamed' => BasicDiscussionSerializer::class
|
||||||
|
];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,7 +108,7 @@ class ApiServiceProvider extends AbstractServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot()
|
public function boot()
|
||||||
{
|
{
|
||||||
$this->registerNotificationSerializers();
|
$this->setNotificationSerializers();
|
||||||
|
|
||||||
AbstractSerializeController::setContainer($this->app);
|
AbstractSerializeController::setContainer($this->app);
|
||||||
AbstractSerializeController::setEventDispatcher($events = $this->app->make('events'));
|
AbstractSerializeController::setEventDispatcher($events = $this->app->make('events'));
|
||||||
@@ -94,13 +120,12 @@ class ApiServiceProvider extends AbstractServiceProvider
|
|||||||
/**
|
/**
|
||||||
* Register notification serializers.
|
* Register notification serializers.
|
||||||
*/
|
*/
|
||||||
protected function registerNotificationSerializers()
|
protected function setNotificationSerializers()
|
||||||
{
|
{
|
||||||
$blueprints = [];
|
$blueprints = [];
|
||||||
$serializers = [
|
$serializers = $this->app->make('flarum.api.notification_serializers');
|
||||||
'discussionRenamed' => BasicDiscussionSerializer::class
|
|
||||||
];
|
|
||||||
|
|
||||||
|
// Deprecated in beta 15, remove in beta 16
|
||||||
$this->app->make('events')->dispatch(
|
$this->app->make('events')->dispatch(
|
||||||
new ConfigureNotificationTypes($blueprints, $serializers)
|
new ConfigureNotificationTypes($blueprints, $serializers)
|
||||||
);
|
);
|
||||||
|
@@ -82,6 +82,16 @@ abstract class AbstractSerializeController implements RequestHandlerInterface
|
|||||||
*/
|
*/
|
||||||
protected static $events;
|
protected static $events;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected static $beforeDataCallbacks = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected static $beforeSerializationCallbacks = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritdoc}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
@@ -89,12 +99,30 @@ abstract class AbstractSerializeController implements RequestHandlerInterface
|
|||||||
{
|
{
|
||||||
$document = new Document;
|
$document = new Document;
|
||||||
|
|
||||||
|
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
|
||||||
|
if (isset(static::$beforeDataCallbacks[$class])) {
|
||||||
|
foreach (static::$beforeDataCallbacks[$class] as $callback) {
|
||||||
|
$callback($this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprected in beta 15, removed in beta 16
|
||||||
static::$events->dispatch(
|
static::$events->dispatch(
|
||||||
new WillGetData($this)
|
new WillGetData($this)
|
||||||
);
|
);
|
||||||
|
|
||||||
$data = $this->data($request, $document);
|
$data = $this->data($request, $document);
|
||||||
|
|
||||||
|
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
|
||||||
|
if (isset(static::$beforeSerializationCallbacks[$class])) {
|
||||||
|
foreach (static::$beforeSerializationCallbacks[$class] as $callback) {
|
||||||
|
$callback($this, $data, $request, $document);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated in beta 15, removed in beta 16
|
||||||
static::$events->dispatch(
|
static::$events->dispatch(
|
||||||
new WillSerializeData($this, $data, $request, $document)
|
new WillSerializeData($this, $data, $request, $document)
|
||||||
);
|
);
|
||||||
@@ -197,6 +225,106 @@ abstract class AbstractSerializeController implements RequestHandlerInterface
|
|||||||
return new Parameters($request->getQueryParams());
|
return new Parameters($request->getQueryParams());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the serializer that will serialize data for the endpoint.
|
||||||
|
*
|
||||||
|
* @param string $serializer
|
||||||
|
*/
|
||||||
|
public function setSerializer(string $serializer)
|
||||||
|
{
|
||||||
|
$this->serializer = $serializer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Include the given relationship by default.
|
||||||
|
*
|
||||||
|
* @param string|array $name
|
||||||
|
*/
|
||||||
|
public function addInclude($name)
|
||||||
|
{
|
||||||
|
$this->include = array_merge($this->include, (array) $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Don't include the given relationship by default.
|
||||||
|
*
|
||||||
|
* @param string|array $name
|
||||||
|
*/
|
||||||
|
public function removeInclude($name)
|
||||||
|
{
|
||||||
|
$this->include = array_diff($this->include, (array) $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the given relationship available for inclusion.
|
||||||
|
*
|
||||||
|
* @param string|array $name
|
||||||
|
*/
|
||||||
|
public function addOptionalInclude($name)
|
||||||
|
{
|
||||||
|
$this->optionalInclude = array_merge($this->optionalInclude, (array) $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Don't allow the given relationship to be included.
|
||||||
|
*
|
||||||
|
* @param string|array $name
|
||||||
|
*/
|
||||||
|
public function removeOptionalInclude($name)
|
||||||
|
{
|
||||||
|
$this->optionalInclude = array_diff($this->optionalInclude, (array) $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the default number of results.
|
||||||
|
*
|
||||||
|
* @param int $limit
|
||||||
|
*/
|
||||||
|
public function setLimit(int $limit)
|
||||||
|
{
|
||||||
|
$this->limit = $limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the maximum number of results.
|
||||||
|
*
|
||||||
|
* @param int $max
|
||||||
|
*/
|
||||||
|
public function setMaxLimit(int $max)
|
||||||
|
{
|
||||||
|
$this->maxLimit = $max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allow sorting results by the given field.
|
||||||
|
*
|
||||||
|
* @param string|array $field
|
||||||
|
*/
|
||||||
|
public function addSortField($field)
|
||||||
|
{
|
||||||
|
$this->sortFields = array_merge($this->sortFields, (array) $field);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disallow sorting results by the given field.
|
||||||
|
*
|
||||||
|
* @param string|array $field
|
||||||
|
*/
|
||||||
|
public function removeSortField($field)
|
||||||
|
{
|
||||||
|
$this->sortFields = array_diff($this->sortFields, (array) $field);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the default sort order for the results.
|
||||||
|
*
|
||||||
|
* @param array $sort
|
||||||
|
*/
|
||||||
|
public function setSort(array $sort)
|
||||||
|
{
|
||||||
|
$this->sort = $sort;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Dispatcher
|
* @return Dispatcher
|
||||||
*/
|
*/
|
||||||
@@ -228,4 +356,30 @@ abstract class AbstractSerializeController implements RequestHandlerInterface
|
|||||||
{
|
{
|
||||||
static::$container = $container;
|
static::$container = $container;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $controllerClass
|
||||||
|
* @param callable $callback
|
||||||
|
*/
|
||||||
|
public static function addDataPreparationCallback(string $controllerClass, callable $callback)
|
||||||
|
{
|
||||||
|
if (! isset(static::$beforeDataCallbacks[$controllerClass])) {
|
||||||
|
static::$beforeDataCallbacks[$controllerClass] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
static::$beforeDataCallbacks[$controllerClass][] = $callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $controllerClass
|
||||||
|
* @param callable $callback
|
||||||
|
*/
|
||||||
|
public static function addSerializationPreparationCallback(string $controllerClass, callable $callback)
|
||||||
|
{
|
||||||
|
if (! isset(static::$beforeSerializationCallbacks[$controllerClass])) {
|
||||||
|
static::$beforeSerializationCallbacks[$controllerClass] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
static::$beforeSerializationCallbacks[$controllerClass][] = $callback;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -64,6 +64,9 @@ class CreateDiscussionController extends AbstractCreateController
|
|||||||
$actor = $request->getAttribute('actor');
|
$actor = $request->getAttribute('actor');
|
||||||
$ipAddress = Arr::get($request->getServerParams(), 'REMOTE_ADDR', '127.0.0.1');
|
$ipAddress = Arr::get($request->getServerParams(), 'REMOTE_ADDR', '127.0.0.1');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated, remove in beta 15.
|
||||||
|
*/
|
||||||
if (! $request->getAttribute('bypassFloodgate')) {
|
if (! $request->getAttribute('bypassFloodgate')) {
|
||||||
$this->floodgate->assertNotFlooding($actor);
|
$this->floodgate->assertNotFlooding($actor);
|
||||||
}
|
}
|
||||||
|
@@ -65,6 +65,9 @@ class CreatePostController extends AbstractCreateController
|
|||||||
$discussionId = Arr::get($data, 'relationships.discussion.data.id');
|
$discussionId = Arr::get($data, 'relationships.discussion.data.id');
|
||||||
$ipAddress = Arr::get($request->getServerParams(), 'REMOTE_ADDR', '127.0.0.1');
|
$ipAddress = Arr::get($request->getServerParams(), 'REMOTE_ADDR', '127.0.0.1');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated, remove in beta 15.
|
||||||
|
*/
|
||||||
if (! $request->getAttribute('bypassFloodgate')) {
|
if (! $request->getAttribute('bypassFloodgate')) {
|
||||||
$this->floodgate->assertNotFlooding($actor);
|
$this->floodgate->assertNotFlooding($actor);
|
||||||
}
|
}
|
||||||
|
@@ -12,6 +12,7 @@ namespace Flarum\Api\Controller;
|
|||||||
use Flarum\Api\Serializer\DiscussionSerializer;
|
use Flarum\Api\Serializer\DiscussionSerializer;
|
||||||
use Flarum\Discussion\Discussion;
|
use Flarum\Discussion\Discussion;
|
||||||
use Flarum\Discussion\DiscussionRepository;
|
use Flarum\Discussion\DiscussionRepository;
|
||||||
|
use Flarum\Http\SlugManager;
|
||||||
use Flarum\Post\PostRepository;
|
use Flarum\Post\PostRepository;
|
||||||
use Flarum\User\User;
|
use Flarum\User\User;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
@@ -31,6 +32,11 @@ class ShowDiscussionController extends AbstractShowController
|
|||||||
*/
|
*/
|
||||||
protected $posts;
|
protected $posts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var SlugManager
|
||||||
|
*/
|
||||||
|
protected $slugManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritdoc}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
@@ -61,11 +67,13 @@ class ShowDiscussionController extends AbstractShowController
|
|||||||
/**
|
/**
|
||||||
* @param \Flarum\Discussion\DiscussionRepository $discussions
|
* @param \Flarum\Discussion\DiscussionRepository $discussions
|
||||||
* @param \Flarum\Post\PostRepository $posts
|
* @param \Flarum\Post\PostRepository $posts
|
||||||
|
* @param \Flarum\Http\SlugManager $slugManager
|
||||||
*/
|
*/
|
||||||
public function __construct(DiscussionRepository $discussions, PostRepository $posts)
|
public function __construct(DiscussionRepository $discussions, PostRepository $posts, SlugManager $slugManager)
|
||||||
{
|
{
|
||||||
$this->discussions = $discussions;
|
$this->discussions = $discussions;
|
||||||
$this->posts = $posts;
|
$this->posts = $posts;
|
||||||
|
$this->slugManager = $slugManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,7 +85,11 @@ class ShowDiscussionController extends AbstractShowController
|
|||||||
$actor = $request->getAttribute('actor');
|
$actor = $request->getAttribute('actor');
|
||||||
$include = $this->extractInclude($request);
|
$include = $this->extractInclude($request);
|
||||||
|
|
||||||
|
if (Arr::get($request->getQueryParams(), 'bySlug', false)) {
|
||||||
|
$discussion = $this->slugManager->forResource(Discussion::class)->fromSlug($discussionId, $actor);
|
||||||
|
} else {
|
||||||
$discussion = $this->discussions->findOrFail($discussionId, $actor);
|
$discussion = $this->discussions->findOrFail($discussionId, $actor);
|
||||||
|
}
|
||||||
|
|
||||||
if (in_array('posts', $include)) {
|
if (in_array('posts', $include)) {
|
||||||
$postRelationships = $this->getPostRelationships($include);
|
$postRelationships = $this->getPostRelationships($include);
|
||||||
|
@@ -11,6 +11,8 @@ namespace Flarum\Api\Controller;
|
|||||||
|
|
||||||
use Flarum\Api\Serializer\CurrentUserSerializer;
|
use Flarum\Api\Serializer\CurrentUserSerializer;
|
||||||
use Flarum\Api\Serializer\UserSerializer;
|
use Flarum\Api\Serializer\UserSerializer;
|
||||||
|
use Flarum\Http\SlugManager;
|
||||||
|
use Flarum\User\User;
|
||||||
use Flarum\User\UserRepository;
|
use Flarum\User\UserRepository;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
@@ -29,15 +31,22 @@ class ShowUserController extends AbstractShowController
|
|||||||
public $include = ['groups'];
|
public $include = ['groups'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var \Flarum\User\UserRepository
|
* @var SlugManager
|
||||||
|
*/
|
||||||
|
protected $slugManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var UserRepository
|
||||||
*/
|
*/
|
||||||
protected $users;
|
protected $users;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param \Flarum\User\UserRepository $users
|
* @param SlugManager $slugManager
|
||||||
|
* @param UserRepository $users
|
||||||
*/
|
*/
|
||||||
public function __construct(UserRepository $users)
|
public function __construct(SlugManager $slugManager, UserRepository $users)
|
||||||
{
|
{
|
||||||
|
$this->slugManager = $slugManager;
|
||||||
$this->users = $users;
|
$this->users = $users;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,17 +56,18 @@ class ShowUserController extends AbstractShowController
|
|||||||
protected function data(ServerRequestInterface $request, Document $document)
|
protected function data(ServerRequestInterface $request, Document $document)
|
||||||
{
|
{
|
||||||
$id = Arr::get($request->getQueryParams(), 'id');
|
$id = Arr::get($request->getQueryParams(), 'id');
|
||||||
|
|
||||||
if (! is_numeric($id)) {
|
|
||||||
$id = $this->users->getIdForUsername($id);
|
|
||||||
}
|
|
||||||
|
|
||||||
$actor = $request->getAttribute('actor');
|
$actor = $request->getAttribute('actor');
|
||||||
|
|
||||||
if ($actor->id == $id) {
|
if (Arr::get($request->getQueryParams(), 'bySlug', false)) {
|
||||||
|
$user = $this->slugManager->forResource(User::class)->fromSlug($id, $actor);
|
||||||
|
} else {
|
||||||
|
$user = $this->users->findOrFail($id, $actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($actor->id === $user->id) {
|
||||||
$this->serializer = CurrentUserSerializer::class;
|
$this->serializer = CurrentUserSerializer::class;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->users->findOrFail($id, $actor);
|
return $user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -9,69 +9,36 @@
|
|||||||
|
|
||||||
namespace Flarum\Api\Controller;
|
namespace Flarum\Api\Controller;
|
||||||
|
|
||||||
use Flarum\Settings\SettingsRepositoryInterface;
|
use Intervention\Image\Image;
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Intervention\Image\ImageManager;
|
use Intervention\Image\ImageManager;
|
||||||
use League\Flysystem\FilesystemInterface;
|
use Psr\Http\Message\UploadedFileInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
|
||||||
use Tobscure\JsonApi\Document;
|
|
||||||
|
|
||||||
class UploadFaviconController extends ShowForumController
|
class UploadFaviconController extends UploadImageController
|
||||||
{
|
{
|
||||||
/**
|
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}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
public function data(ServerRequestInterface $request, Document $document)
|
protected function makeImage(UploadedFileInterface $file): Image
|
||||||
{
|
{
|
||||||
$request->getAttribute('actor')->assertAdmin();
|
$this->fileExtension = pathinfo($file->getClientFilename(), PATHINFO_EXTENSION);
|
||||||
|
|
||||||
$file = Arr::get($request->getUploadedFiles(), 'favicon');
|
if ($this->fileExtension === 'ico') {
|
||||||
$extension = pathinfo($file->getClientFilename(), PATHINFO_EXTENSION);
|
$encodedImage = $file->getStream();
|
||||||
|
|
||||||
if ($extension === 'ico') {
|
|
||||||
$image = $file->getStream();
|
|
||||||
} else {
|
} else {
|
||||||
$manager = new ImageManager;
|
$manager = new ImageManager();
|
||||||
|
|
||||||
$image = $manager->make($file->getStream())->resize(64, 64, function ($constraint) {
|
$encodedImage = $manager->make($file->getStream())->resize(64, 64, function ($constraint) {
|
||||||
$constraint->aspectRatio();
|
$constraint->aspectRatio();
|
||||||
$constraint->upsize();
|
$constraint->upsize();
|
||||||
})->encode('png');
|
})->encode('png');
|
||||||
|
|
||||||
$extension = 'png';
|
$this->fileExtension = 'png';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (($path = $this->settings->get('favicon_path')) && $this->uploadDir->has($path)) {
|
return $encodedImage;
|
||||||
$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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
87
src/Api/Controller/UploadImageController.php
Normal file
87
src/Api/Controller/UploadImageController.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
@@ -9,61 +9,27 @@
|
|||||||
|
|
||||||
namespace Flarum\Api\Controller;
|
namespace Flarum\Api\Controller;
|
||||||
|
|
||||||
use Flarum\Settings\SettingsRepositoryInterface;
|
use Intervention\Image\Image;
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Intervention\Image\ImageManager;
|
use Intervention\Image\ImageManager;
|
||||||
use League\Flysystem\FilesystemInterface;
|
use Psr\Http\Message\UploadedFileInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
|
||||||
use Tobscure\JsonApi\Document;
|
|
||||||
|
|
||||||
class UploadLogoController extends ShowForumController
|
class UploadLogoController extends UploadImageController
|
||||||
{
|
{
|
||||||
/**
|
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}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
public function data(ServerRequestInterface $request, Document $document)
|
protected function makeImage(UploadedFileInterface $file): Image
|
||||||
{
|
{
|
||||||
$request->getAttribute('actor')->assertAdmin();
|
$manager = new ImageManager();
|
||||||
|
|
||||||
$file = Arr::get($request->getUploadedFiles(), 'logo');
|
|
||||||
|
|
||||||
$manager = new ImageManager;
|
|
||||||
|
|
||||||
$encodedImage = $manager->make($file->getStream())->heighten(60, function ($constraint) {
|
$encodedImage = $manager->make($file->getStream())->heighten(60, function ($constraint) {
|
||||||
$constraint->upsize();
|
$constraint->upsize();
|
||||||
})->encode('png');
|
})->encode('png');
|
||||||
|
|
||||||
if (($path = $this->settings->get('logo_path')) && $this->uploadDir->has($path)) {
|
return $encodedImage;
|
||||||
$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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -17,6 +17,8 @@ use Flarum\Api\Serializer\AbstractSerializer;
|
|||||||
*
|
*
|
||||||
* This event is fired when a serializer is constructing an array of resource
|
* This event is fired when a serializer is constructing an array of resource
|
||||||
* attributes for API output.
|
* attributes for API output.
|
||||||
|
*
|
||||||
|
* @deprecated in beta 15, removed in beta 16
|
||||||
*/
|
*/
|
||||||
class Serializing
|
class Serializing
|
||||||
{
|
{
|
||||||
|
@@ -12,6 +12,9 @@ namespace Flarum\Api\Event;
|
|||||||
use Flarum\Api\Controller\AbstractSerializeController;
|
use Flarum\Api\Controller\AbstractSerializeController;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated in beta 15, removed in beta 16
|
||||||
|
*/
|
||||||
class WillGetData
|
class WillGetData
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
@@ -13,6 +13,9 @@ use Flarum\Api\Controller\AbstractSerializeController;
|
|||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Tobscure\JsonApi\Document;
|
use Tobscure\JsonApi\Document;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated in beta 15, removed in beta 16
|
||||||
|
*/
|
||||||
class WillSerializeData
|
class WillSerializeData
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
57
src/Api/Middleware/ThrottleApi.php
Normal file
57
src/Api/Middleware/ThrottleApi.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
@@ -16,6 +16,7 @@ use Flarum\Event\GetApiRelationship;
|
|||||||
use Flarum\User\User;
|
use Flarum\User\User;
|
||||||
use Illuminate\Contracts\Container\Container;
|
use Illuminate\Contracts\Container\Container;
|
||||||
use Illuminate\Contracts\Events\Dispatcher;
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use LogicException;
|
use LogicException;
|
||||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
@@ -47,6 +48,16 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
|
|||||||
*/
|
*/
|
||||||
protected static $container;
|
protected static $container;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var callable[]
|
||||||
|
*/
|
||||||
|
protected static $mutators = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected static $customRelations = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Request
|
* @return Request
|
||||||
*/
|
*/
|
||||||
@@ -83,6 +94,18 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
|
|||||||
|
|
||||||
$attributes = $this->getDefaultAttributes($model);
|
$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(
|
static::$dispatcher->dispatch(
|
||||||
new Serializing($this, $model, $attributes)
|
new Serializing($this, $model, $attributes)
|
||||||
);
|
);
|
||||||
@@ -102,7 +125,7 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
|
|||||||
* @param DateTime|null $date
|
* @param DateTime|null $date
|
||||||
* @return string|null
|
* @return string|null
|
||||||
*/
|
*/
|
||||||
protected function formatDate(DateTime $date = null)
|
public function formatDate(DateTime $date = null)
|
||||||
{
|
{
|
||||||
if ($date) {
|
if ($date) {
|
||||||
return $date->format(DateTime::RFC3339);
|
return $date->format(DateTime::RFC3339);
|
||||||
@@ -130,10 +153,20 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
|
|||||||
*/
|
*/
|
||||||
protected function getCustomRelationship($model, $name)
|
protected function getCustomRelationship($model, $name)
|
||||||
{
|
{
|
||||||
|
// Deprecated in beta 15, removed in beta 16
|
||||||
$relationship = static::$dispatcher->until(
|
$relationship = static::$dispatcher->until(
|
||||||
new GetApiRelationship($this, $name, $model)
|
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)) {
|
if ($relationship && ! ($relationship instanceof Relationship)) {
|
||||||
throw new LogicException(
|
throw new LogicException(
|
||||||
'GetApiRelationship handler must return an instance of '.Relationship::class
|
'GetApiRelationship handler must return an instance of '.Relationship::class
|
||||||
@@ -280,4 +313,27 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
|
|||||||
{
|
{
|
||||||
static::$container = $container;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -10,6 +10,7 @@
|
|||||||
namespace Flarum\Api\Serializer;
|
namespace Flarum\Api\Serializer;
|
||||||
|
|
||||||
use Flarum\Discussion\Discussion;
|
use Flarum\Discussion\Discussion;
|
||||||
|
use Flarum\Http\SlugManager;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
|
||||||
class BasicDiscussionSerializer extends AbstractSerializer
|
class BasicDiscussionSerializer extends AbstractSerializer
|
||||||
@@ -19,6 +20,16 @@ class BasicDiscussionSerializer extends AbstractSerializer
|
|||||||
*/
|
*/
|
||||||
protected $type = 'discussions';
|
protected $type = 'discussions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var SlugManager
|
||||||
|
*/
|
||||||
|
protected $slugManager;
|
||||||
|
|
||||||
|
public function __construct(SlugManager $slugManager)
|
||||||
|
{
|
||||||
|
$this->slugManager = $slugManager;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritdoc}
|
* {@inheritdoc}
|
||||||
*
|
*
|
||||||
@@ -35,7 +46,7 @@ class BasicDiscussionSerializer extends AbstractSerializer
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'title' => $discussion->title,
|
'title' => $discussion->title,
|
||||||
'slug' => $discussion->slug,
|
'slug' => $this->slugManager->forResource(Discussion::class)->toSlug($discussion),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
namespace Flarum\Api\Serializer;
|
namespace Flarum\Api\Serializer;
|
||||||
|
|
||||||
|
use Flarum\Http\SlugManager;
|
||||||
use Flarum\User\User;
|
use Flarum\User\User;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
|
||||||
@@ -19,6 +20,16 @@ class BasicUserSerializer extends AbstractSerializer
|
|||||||
*/
|
*/
|
||||||
protected $type = 'users';
|
protected $type = 'users';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var SlugManager
|
||||||
|
*/
|
||||||
|
protected $slugManager;
|
||||||
|
|
||||||
|
public function __construct(SlugManager $slugManager)
|
||||||
|
{
|
||||||
|
$this->slugManager = $slugManager;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritdoc}
|
* {@inheritdoc}
|
||||||
*
|
*
|
||||||
@@ -36,7 +47,8 @@ class BasicUserSerializer extends AbstractSerializer
|
|||||||
return [
|
return [
|
||||||
'username' => $user->username,
|
'username' => $user->username,
|
||||||
'displayName' => $user->display_name,
|
'displayName' => $user->display_name,
|
||||||
'avatarUrl' => $user->avatar_url
|
'avatarUrl' => $user->avatar_url,
|
||||||
|
'slug' => $this->slugManager->forResource(User::class)->toSlug($user)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -12,19 +12,49 @@ namespace Flarum\Database;
|
|||||||
use Flarum\Event\ScopeModelVisibility;
|
use Flarum\Event\ScopeModelVisibility;
|
||||||
use Flarum\User\User;
|
use Flarum\User\User;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
trait ScopeVisibilityTrait
|
trait ScopeVisibilityTrait
|
||||||
{
|
{
|
||||||
|
protected static $visibilityScopers = [];
|
||||||
|
|
||||||
|
public static function registerVisibilityScoper($scoper, $ability = null)
|
||||||
|
{
|
||||||
|
$model = static::class;
|
||||||
|
|
||||||
|
if ($ability === null) {
|
||||||
|
$ability = '*';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Arr::has(static::$visibilityScopers, "$model.$ability")) {
|
||||||
|
Arr::set(static::$visibilityScopers, "$model.$ability", []);
|
||||||
|
}
|
||||||
|
|
||||||
|
static::$visibilityScopers[$model][$ability][] = $scoper;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scope a query to only include records that are visible to a user.
|
* Scope a query to only include records that are visible to a user.
|
||||||
*
|
*
|
||||||
* @param Builder $query
|
* @param Builder $query
|
||||||
* @param User $actor
|
* @param User $actor
|
||||||
*/
|
*/
|
||||||
public function scopeWhereVisibleTo(Builder $query, User $actor)
|
public function scopeWhereVisibleTo(Builder $query, User $actor, string $ability = 'view')
|
||||||
{
|
{
|
||||||
static::$dispatcher->dispatch(
|
/**
|
||||||
new ScopeModelVisibility($query, $actor, 'view')
|
* @deprecated beta 15, remove beta 15
|
||||||
);
|
*/
|
||||||
|
static::$dispatcher->dispatch(new ScopeModelVisibility($query, $actor, $ability));
|
||||||
|
|
||||||
|
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
|
||||||
|
foreach (Arr::get(static::$visibilityScopers, "$class.*", []) as $listener) {
|
||||||
|
$listener($actor, $query, $ability);
|
||||||
|
}
|
||||||
|
foreach (Arr::get(static::$visibilityScopers, "$class.$ability", []) as $listener) {
|
||||||
|
$listener($actor, $query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
79
src/Discussion/Access/DiscussionPolicy.php
Normal file
79
src/Discussion/Access/DiscussionPolicy.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?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\Discussion\Access;
|
||||||
|
|
||||||
|
use Flarum\Discussion\Discussion;
|
||||||
|
use Flarum\Settings\SettingsRepositoryInterface;
|
||||||
|
use Flarum\User\Access\AbstractPolicy;
|
||||||
|
use Flarum\User\User;
|
||||||
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
|
|
||||||
|
class DiscussionPolicy extends AbstractPolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var SettingsRepositoryInterface
|
||||||
|
*/
|
||||||
|
protected $settings;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param SettingsRepositoryInterface $settings
|
||||||
|
* @param Dispatcher $events
|
||||||
|
*/
|
||||||
|
public function __construct(SettingsRepositoryInterface $settings)
|
||||||
|
{
|
||||||
|
$this->settings = $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param User $actor
|
||||||
|
* @param string $ability
|
||||||
|
* @return bool|null
|
||||||
|
*/
|
||||||
|
public function can(User $actor, $ability)
|
||||||
|
{
|
||||||
|
if ($actor->hasPermission('discussion.'.$ability)) {
|
||||||
|
return $this->allow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param User $actor
|
||||||
|
* @param \Flarum\Discussion\Discussion $discussion
|
||||||
|
* @return bool|null
|
||||||
|
*/
|
||||||
|
public function rename(User $actor, Discussion $discussion)
|
||||||
|
{
|
||||||
|
if ($discussion->user_id == $actor->id && $actor->can('reply', $discussion)) {
|
||||||
|
$allowRenaming = $this->settings->get('allow_renaming');
|
||||||
|
|
||||||
|
if ($allowRenaming === '-1'
|
||||||
|
|| ($allowRenaming === 'reply' && $discussion->participant_count <= 1)
|
||||||
|
|| ($discussion->created_at->diffInMinutes() < $allowRenaming)) {
|
||||||
|
return $this->allow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param User $actor
|
||||||
|
* @param \Flarum\Discussion\Discussion $discussion
|
||||||
|
* @return bool|null
|
||||||
|
*/
|
||||||
|
public function hide(User $actor, Discussion $discussion)
|
||||||
|
{
|
||||||
|
if ($discussion->user_id == $actor->id
|
||||||
|
&& $discussion->participant_count <= 1
|
||||||
|
&& (! $discussion->hidden_at || $discussion->hidden_user_id == $actor->id)
|
||||||
|
&& $actor->can('reply', $discussion)
|
||||||
|
) {
|
||||||
|
return $this->allow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
61
src/Discussion/Access/ScopeDiscussionVisibility.php
Normal file
61
src/Discussion/Access/ScopeDiscussionVisibility.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?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\Discussion\Access;
|
||||||
|
|
||||||
|
use Flarum\User\User;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class ScopeDiscussionVisibility
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param User $actor
|
||||||
|
* @param Builder $query
|
||||||
|
*/
|
||||||
|
public function __invoke(User $actor, $query)
|
||||||
|
{
|
||||||
|
if ($actor->cannot('viewDiscussions')) {
|
||||||
|
$query->whereRaw('FALSE');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide private discussions by default.
|
||||||
|
$query->where(function ($query) use ($actor) {
|
||||||
|
$query->where('discussions.is_private', false)
|
||||||
|
->orWhere(function ($query) use ($actor) {
|
||||||
|
$query->whereVisibleTo($actor, 'viewPrivate');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide hidden discussions, unless they are authored by the current
|
||||||
|
// user, or the current user has permission to view hidden discussions.
|
||||||
|
if (! $actor->hasPermission('discussion.hide')) {
|
||||||
|
$query->where(function ($query) use ($actor) {
|
||||||
|
$query->whereNull('discussions.hidden_at')
|
||||||
|
->orWhere('discussions.user_id', $actor->id)
|
||||||
|
->orWhere(function ($query) use ($actor) {
|
||||||
|
$query->whereVisibleTo($actor, 'hide');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide discussions with no comments, unless they are authored by the
|
||||||
|
// current user, or the user is allowed to edit the discussion's posts.
|
||||||
|
if (! $actor->hasPermission('discussion.editPosts')) {
|
||||||
|
$query->where(function ($query) use ($actor) {
|
||||||
|
$query->where('discussions.comment_count', '>', 0)
|
||||||
|
->orWhere('discussions.user_id', $actor->id)
|
||||||
|
->orWhere(function ($query) use ($actor) {
|
||||||
|
$query->whereVisibleTo($actor, 'editPosts');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,142 +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\Discussion;
|
|
||||||
|
|
||||||
use Flarum\Event\ScopeModelVisibility;
|
|
||||||
use Flarum\Settings\SettingsRepositoryInterface;
|
|
||||||
use Flarum\User\AbstractPolicy;
|
|
||||||
use Flarum\User\User;
|
|
||||||
use Illuminate\Contracts\Events\Dispatcher;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class DiscussionPolicy extends AbstractPolicy
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* {@inheritdoc}
|
|
||||||
*/
|
|
||||||
protected $model = Discussion::class;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var SettingsRepositoryInterface
|
|
||||||
*/
|
|
||||||
protected $settings;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var Dispatcher
|
|
||||||
*/
|
|
||||||
protected $events;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param SettingsRepositoryInterface $settings
|
|
||||||
* @param Dispatcher $events
|
|
||||||
*/
|
|
||||||
public function __construct(SettingsRepositoryInterface $settings, Dispatcher $events)
|
|
||||||
{
|
|
||||||
$this->settings = $settings;
|
|
||||||
$this->events = $events;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param User $actor
|
|
||||||
* @param string $ability
|
|
||||||
* @return bool|null
|
|
||||||
*/
|
|
||||||
public function can(User $actor, $ability)
|
|
||||||
{
|
|
||||||
if ($actor->hasPermission('discussion.'.$ability)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param User $actor
|
|
||||||
* @param Builder $query
|
|
||||||
*/
|
|
||||||
public function find(User $actor, Builder $query)
|
|
||||||
{
|
|
||||||
if ($actor->cannot('viewDiscussions')) {
|
|
||||||
$query->whereRaw('FALSE');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide private discussions by default.
|
|
||||||
$query->where(function ($query) use ($actor) {
|
|
||||||
$query->where('discussions.is_private', false)
|
|
||||||
->orWhere(function ($query) use ($actor) {
|
|
||||||
$this->events->dispatch(
|
|
||||||
new ScopeModelVisibility($query, $actor, 'viewPrivate')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hide hidden discussions, unless they are authored by the current
|
|
||||||
// user, or the current user has permission to view hidden discussions.
|
|
||||||
if (! $actor->hasPermission('discussion.hide')) {
|
|
||||||
$query->where(function ($query) use ($actor) {
|
|
||||||
$query->whereNull('discussions.hidden_at')
|
|
||||||
->orWhere('discussions.user_id', $actor->id)
|
|
||||||
->orWhere(function ($query) use ($actor) {
|
|
||||||
$this->events->dispatch(
|
|
||||||
new ScopeModelVisibility($query, $actor, 'hide')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide discussions with no comments, unless they are authored by the
|
|
||||||
// current user, or the user is allowed to edit the discussion's posts.
|
|
||||||
if (! $actor->hasPermission('discussion.editPosts')) {
|
|
||||||
$query->where(function ($query) use ($actor) {
|
|
||||||
$query->where('discussions.comment_count', '>', 0)
|
|
||||||
->orWhere('discussions.user_id', $actor->id)
|
|
||||||
->orWhere(function ($query) use ($actor) {
|
|
||||||
$this->events->dispatch(
|
|
||||||
new ScopeModelVisibility($query, $actor, 'editPosts')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param User $actor
|
|
||||||
* @param \Flarum\Discussion\Discussion $discussion
|
|
||||||
* @return bool|null
|
|
||||||
*/
|
|
||||||
public function rename(User $actor, Discussion $discussion)
|
|
||||||
{
|
|
||||||
if ($discussion->user_id == $actor->id && $actor->can('reply', $discussion)) {
|
|
||||||
$allowRenaming = $this->settings->get('allow_renaming');
|
|
||||||
|
|
||||||
if ($allowRenaming === '-1'
|
|
||||||
|| ($allowRenaming === 'reply' && $discussion->participant_count <= 1)
|
|
||||||
|| ($discussion->created_at->diffInMinutes() < $allowRenaming)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param User $actor
|
|
||||||
* @param \Flarum\Discussion\Discussion $discussion
|
|
||||||
* @return bool|null
|
|
||||||
*/
|
|
||||||
public function hide(User $actor, Discussion $discussion)
|
|
||||||
{
|
|
||||||
if ($discussion->user_id == $actor->id
|
|
||||||
&& $discussion->participant_count <= 1
|
|
||||||
&& (! $discussion->hidden_at || $discussion->hidden_user_id == $actor->id)
|
|
||||||
&& $actor->can('reply', $discussion)
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
namespace Flarum\Discussion;
|
namespace Flarum\Discussion;
|
||||||
|
|
||||||
|
use Flarum\Discussion\Access\ScopeDiscussionVisibility;
|
||||||
use Flarum\Discussion\Event\Renamed;
|
use Flarum\Discussion\Event\Renamed;
|
||||||
use Flarum\Foundation\AbstractServiceProvider;
|
use Flarum\Foundation\AbstractServiceProvider;
|
||||||
|
|
||||||
@@ -22,11 +23,12 @@ class DiscussionServiceProvider extends AbstractServiceProvider
|
|||||||
$events = $this->app->make('events');
|
$events = $this->app->make('events');
|
||||||
|
|
||||||
$events->subscribe(DiscussionMetadataUpdater::class);
|
$events->subscribe(DiscussionMetadataUpdater::class);
|
||||||
$events->subscribe(DiscussionPolicy::class);
|
|
||||||
|
|
||||||
$events->listen(
|
$events->listen(
|
||||||
Renamed::class,
|
Renamed::class,
|
||||||
DiscussionRenamedLogger::class
|
DiscussionRenamedLogger::class
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Discussion::registerVisibilityScoper(new ScopeDiscussionVisibility(), 'view');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
42
src/Discussion/IdWithTransliteratedSlugDriver.php
Normal file
42
src/Discussion/IdWithTransliteratedSlugDriver.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?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\Discussion;
|
||||||
|
|
||||||
|
use Flarum\Database\AbstractModel;
|
||||||
|
use Flarum\Http\SlugDriverInterface;
|
||||||
|
use Flarum\User\User;
|
||||||
|
|
||||||
|
class IdWithTransliteratedSlugDriver implements SlugDriverInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var DiscussionRepository
|
||||||
|
*/
|
||||||
|
protected $discussions;
|
||||||
|
|
||||||
|
public function __construct(DiscussionRepository $discussions)
|
||||||
|
{
|
||||||
|
$this->discussions = $discussions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toSlug(AbstractModel $instance): string
|
||||||
|
{
|
||||||
|
return $instance->id.(trim($instance->slug) ? '-'.$instance->slug : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fromSlug(string $slug, User $actor): AbstractModel
|
||||||
|
{
|
||||||
|
if (strpos($slug, '-')) {
|
||||||
|
$slug_array = explode('-', $slug);
|
||||||
|
$slug = $slug_array[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->discussions->findOrFail($slug, $actor);
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user