mirror of
https://github.com/flarum/core.git
synced 2025-08-14 04:14:06 +02:00
Compare commits
56 Commits
as/support
...
as/export-
Author | SHA1 | Date | |
---|---|---|---|
|
3ef541a152 | ||
|
81894d7cc2 | ||
|
97aa569bfa | ||
|
7d80b88d5c | ||
|
2cd1c2964a | ||
|
8a451e0bfc | ||
|
0b9ad5425c | ||
|
da5db714c2 | ||
|
d4a2357a32 | ||
|
588a9f952f | ||
|
66233ce818 | ||
|
7d4bd8a845 | ||
|
3a6b8847f1 | ||
|
2ffec2ee71 | ||
|
7eea2476ca | ||
|
9711af42ae | ||
|
d12d52918b | ||
|
ad92d11cf9 | ||
|
3ca035f9aa | ||
|
bbff3a2748 | ||
|
f5cd5f202f | ||
|
a78cbf644c | ||
|
2de47a8656 | ||
|
b45519974a | ||
|
edaf45d133 | ||
|
6b9e991082 | ||
|
8a431dc3cc | ||
|
91b1d9029e | ||
|
e337c10bb8 | ||
|
e0258d2708 | ||
|
fcb5778705 | ||
|
40b47de9e1 | ||
|
deadd67691 | ||
|
c119731e65 | ||
|
2b7e7f3ff4 | ||
|
f4acb2c5db | ||
|
f9779284e4 | ||
|
43d6b3104d | ||
|
33bd99d376 | ||
|
eb4b18a979 | ||
|
b62debf031 | ||
|
1f2411e15e | ||
|
d99df936b1 | ||
|
9716a15c31 | ||
|
5e2340bf10 | ||
|
c84939b19c | ||
|
4974c91481 | ||
|
f67149bb06 | ||
|
a2d77d7b81 | ||
|
da4264c8a3 | ||
|
0f9526ba9f | ||
|
e77365f32f | ||
|
c7c456cb3e | ||
|
fb51fb4e6d | ||
|
5b7d364b87 | ||
|
39a6106854 |
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -11,5 +11,6 @@ phpunit.xml export-ignore
|
||||
tests export-ignore
|
||||
|
||||
js/dist/* -diff
|
||||
js/dist/* linguist-generated
|
||||
|
||||
* text=auto eol=lf
|
||||
|
@@ -63,13 +63,14 @@
|
||||
"symfony/console": "^5.2.2",
|
||||
"symfony/event-dispatcher": "^5.2.2",
|
||||
"symfony/mime": "^5.2.0",
|
||||
"symfony/polyfill-intl-messageformatter": "^1.22.0",
|
||||
"symfony/translation": "^5.1.5",
|
||||
"symfony/yaml": "^5.2.2",
|
||||
"tobscure/json-api": "^0.3.0",
|
||||
"wikimedia/less.php": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"flarum/testing": "^0.1.0-beta.16"
|
||||
"flarum/testing": "dev-main#81e25f034e2b6dceaea753ad7579b5c61d641993"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
6
js/dist/admin.js
generated
vendored
6
js/dist/admin.js
generated
vendored
File diff suppressed because one or more lines are too long
2
js/dist/admin.js.map
generated
vendored
2
js/dist/admin.js.map
generated
vendored
File diff suppressed because one or more lines are too long
8
js/dist/forum.js
generated
vendored
8
js/dist/forum.js
generated
vendored
File diff suppressed because one or more lines are too long
2
js/dist/forum.js.map
generated
vendored
2
js/dist/forum.js.map
generated
vendored
File diff suppressed because one or more lines are too long
816
js/package-lock.json
generated
816
js/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,35 +2,36 @@
|
||||
"private": true,
|
||||
"name": "@flarum/core",
|
||||
"dependencies": {
|
||||
"@askvortsov/rich-icu-message-formatter": "^0.1.0",
|
||||
"@ultraq/icu-message-formatter": "^0.10.0",
|
||||
"bootstrap": "^3.4.1",
|
||||
"clsx": "^1.1.1",
|
||||
"color-thief-browser": "^2.0.2",
|
||||
"dayjs": "^1.10.4",
|
||||
"expose-loader": "^1.0.3",
|
||||
"expose-loader": "^2.0.0",
|
||||
"jquery": "^3.6.0",
|
||||
"jquery.hotkeys": "^0.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mithril": "^2.0.4",
|
||||
"punycode": "^2.1.1",
|
||||
"spin.js": "^3.1.0",
|
||||
"textarea-caret": "^3.1.0"
|
||||
"textarea-caret": "^3.1.0",
|
||||
"throttle-debounce": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-typescript": "^7.13.0",
|
||||
"@types/jquery": "^3.5.5",
|
||||
"@types/lodash-es": "^4.17.4",
|
||||
"@types/mithril": "^2.0.7",
|
||||
"@types/punycode": "^2.1.0",
|
||||
"@types/textarea-caret": "^3.0.0",
|
||||
"bundlewatch": "^0.3.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"flarum-webpack-config": "0.1.0-beta.10",
|
||||
"flarum-webpack-config": "^1.0.0",
|
||||
"husky": "^4.3.8",
|
||||
"prettier": "^2.2.1",
|
||||
"webpack": "^4.46.0",
|
||||
"webpack-bundle-analyzer": "^4.4.0",
|
||||
"webpack-cli": "^3.3.12",
|
||||
"webpack-merge": "^4.2.2"
|
||||
"webpack": "^5.0.0",
|
||||
"webpack-bundle-analyzer": "^4.4.1",
|
||||
"webpack-cli": "^4.0.0",
|
||||
"webpack-merge": "^4.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "webpack --mode development --watch",
|
||||
|
@@ -24,6 +24,7 @@ import UploadImageButton from './components/UploadImageButton';
|
||||
import LoadingModal from './components/LoadingModal';
|
||||
import DashboardPage from './components/DashboardPage';
|
||||
import BasicsPage from './components/BasicsPage';
|
||||
import UserListPage from './components/UserListPage';
|
||||
import EditCustomHeaderModal from './components/EditCustomHeaderModal';
|
||||
import PermissionsPage from './components/PermissionsPage';
|
||||
import PermissionDropdown from './components/PermissionDropdown';
|
||||
@@ -59,6 +60,7 @@ export default Object.assign(compat, {
|
||||
'components/LoadingModal': LoadingModal,
|
||||
'components/DashboardPage': DashboardPage,
|
||||
'components/BasicsPage': BasicsPage,
|
||||
'components/UserListPage': UserListPage,
|
||||
'components/EditCustomHeaderModal': EditCustomHeaderModal,
|
||||
'components/PermissionsPage': PermissionsPage,
|
||||
'components/PermissionDropdown': PermissionDropdown,
|
||||
|
@@ -94,6 +94,13 @@ export default class AdminNav extends Component {
|
||||
</LinkButton>
|
||||
);
|
||||
|
||||
items.add(
|
||||
'userList',
|
||||
<LinkButton href={app.route('users')} icon="fas fa-users" title={app.translator.trans('core.admin.nav.userlist_title')}>
|
||||
{app.translator.trans('core.admin.nav.userlist_button')}
|
||||
</LinkButton>
|
||||
);
|
||||
|
||||
items.add(
|
||||
'search',
|
||||
<div className="Search-input">
|
||||
|
@@ -100,8 +100,6 @@ export default class AdminPage extends Page {
|
||||
|
||||
const { setting, help, ...componentAttrs } = entry;
|
||||
|
||||
delete componentAttrs.help;
|
||||
|
||||
const value = this.setting([setting])();
|
||||
if (['bool', 'checkbox', 'switch', 'boolean'].includes(componentAttrs.type)) {
|
||||
return (
|
||||
|
@@ -182,7 +182,7 @@ export default class PermissionGrid extends Component {
|
||||
|
||||
return SettingDropdown.component({
|
||||
defaultLabel: minutes
|
||||
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes })
|
||||
? app.translator.trans('core.admin.permissions_controls.allow_some_minutes_button', { count: minutes })
|
||||
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
|
||||
key: 'allow_renaming',
|
||||
options: [
|
||||
@@ -224,7 +224,7 @@ export default class PermissionGrid extends Component {
|
||||
|
||||
return SettingDropdown.component({
|
||||
defaultLabel: minutes
|
||||
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes })
|
||||
? app.translator.trans('core.admin.permissions_controls.allow_some_minutes_button', { count: minutes })
|
||||
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
|
||||
key: 'allow_post_editing',
|
||||
options: [
|
||||
|
384
js/src/admin/components/UserListPage.tsx
Normal file
384
js/src/admin/components/UserListPage.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
import EditUserModal from '../../common/components/EditUserModal';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import Button from '../../common/components/Button';
|
||||
|
||||
import icon from '../../common/helpers/icon';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
|
||||
import type User from '../../common/models/User';
|
||||
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import classList from '../../common/utils/classList';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
|
||||
import AdminPage from './AdminPage';
|
||||
|
||||
type ColumnData = {
|
||||
/**
|
||||
* Column title
|
||||
*/
|
||||
name: String;
|
||||
/**
|
||||
* Component(s) to show for this column.
|
||||
*/
|
||||
content: (user: User) => JSX.Element;
|
||||
};
|
||||
|
||||
type ApiPayload = {
|
||||
data: Record<string, unknown>[];
|
||||
included: Record<string, unknown>[];
|
||||
links: {
|
||||
first: string;
|
||||
next?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type UsersApiResponse = User[] & { payload: ApiPayload };
|
||||
|
||||
/**
|
||||
* Admin page which displays a paginated list of all users on the forum.
|
||||
*/
|
||||
export default class UserListPage extends AdminPage {
|
||||
/**
|
||||
* Number of users to load per page.
|
||||
*/
|
||||
private numPerPage: number = 50;
|
||||
|
||||
/**
|
||||
* Current page number. Zero-indexed.
|
||||
*/
|
||||
private pageNumber: number = 0;
|
||||
|
||||
/**
|
||||
* Total number of forum users.
|
||||
*
|
||||
* Fetched from the active `AdminApplication` (`app`), with
|
||||
* data provided by `AdminPayload.php`, or `flarum/statistics`
|
||||
* if installed.
|
||||
*/
|
||||
readonly userCount: number = app.data.modelStatistics.users.total;
|
||||
|
||||
/**
|
||||
* Get total number of user pages.
|
||||
*/
|
||||
private getTotalPageCount(): number {
|
||||
if (this.userCount === -1) return 0;
|
||||
|
||||
return Math.ceil(this.userCount / this.numPerPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* This page's array of users.
|
||||
*
|
||||
* `undefined` when page loads as no data has been fetched.
|
||||
*/
|
||||
private pageData: User[] | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Are there more users available?
|
||||
*/
|
||||
private moreData: boolean = false;
|
||||
|
||||
private isLoadingPage: boolean = false;
|
||||
|
||||
/**
|
||||
* Component to render.
|
||||
*/
|
||||
content() {
|
||||
if (typeof this.pageData === 'undefined') {
|
||||
this.loadPage(0);
|
||||
|
||||
return [
|
||||
<section class="UserListPage-grid UserListPage-grid--loading">
|
||||
<LoadingIndicator containerClassName="LoadingIndicator--block" size="large" />
|
||||
</section>,
|
||||
];
|
||||
}
|
||||
|
||||
const columns: (ColumnData & { itemName: string })[] = this.columns().toArray();
|
||||
|
||||
return [
|
||||
<p class="UserListPage-totalUsers">{app.translator.trans('core.admin.users.total_users', { count: this.userCount })}</p>,
|
||||
<section
|
||||
class={classList(['UserListPage-grid', this.isLoadingPage ? 'UserListPage-grid--loadingPage' : 'UserListPage-grid--loaded'])}
|
||||
style={{ '--columns': columns.length }}
|
||||
role="table"
|
||||
// +1 to account for header
|
||||
aria-rowcount={this.pageData.length + 1}
|
||||
aria-colcount={columns.length}
|
||||
aria-live="polite"
|
||||
aria-busy={this.isLoadingPage ? 'true' : 'false'}
|
||||
>
|
||||
{/* Render columns */}
|
||||
{columns.map((column, colIndex) => (
|
||||
<div class="UserListPage-grid-header" role="columnheader" aria-colindex={colIndex + 1} aria-rowindex={1}>
|
||||
{column.name}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Render user data */}
|
||||
{this.pageData.map((user, rowIndex) =>
|
||||
columns.map((col, colIndex) => {
|
||||
const columnContent = col.content && col.content(user);
|
||||
|
||||
return (
|
||||
<div
|
||||
class={classList(['UserListPage-grid-rowItem', rowIndex % 2 > 0 && 'UserListPage-grid-rowItem--shaded'])}
|
||||
data-user-id={user.id()}
|
||||
data-column-name={col.itemName}
|
||||
aria-colindex={colIndex + 1}
|
||||
// +2 to account for 0-based index, and for the header row
|
||||
aria-rowindex={rowIndex + 2}
|
||||
role="cell"
|
||||
>
|
||||
{columnContent || app.translator.trans('core.admin.users.grid.invalid_column_content')}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Loading spinner that shows when a new page is being loaded */}
|
||||
{this.isLoadingPage && <LoadingIndicator size="large" />}
|
||||
</section>,
|
||||
<nav class="UserListPage-gridPagination">
|
||||
<Button
|
||||
disabled={this.pageNumber === 0}
|
||||
title={app.translator.trans('core.admin.users.pagination.back_button')}
|
||||
onclick={this.previousPage.bind(this)}
|
||||
icon="fas fa-chevron-left"
|
||||
className="Button Button--icon UserListPage-backBtn"
|
||||
/>
|
||||
<span class="UserListPage-pageNumber">
|
||||
{app.translator.trans('core.admin.users.pagination.page_counter', {
|
||||
current: this.pageNumber + 1,
|
||||
total: this.getTotalPageCount(),
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
disabled={!this.moreData}
|
||||
title={app.translator.trans('core.admin.users.pagination.next_button')}
|
||||
onclick={this.nextPage.bind(this)}
|
||||
icon="fas fa-chevron-right"
|
||||
className="Button Button--icon UserListPage-nextBtn"
|
||||
/>
|
||||
</nav>,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list of columns to show for each user.
|
||||
*
|
||||
* Each column in the list should be an object with keys `name` and `content`.
|
||||
*
|
||||
* `name` is a string that will be used as the column name.
|
||||
* `content` is a function with the User model passed as the first and only argument.
|
||||
*
|
||||
* See `UserListPage.tsx` for examples.
|
||||
*/
|
||||
columns(): ItemList {
|
||||
const columns = new ItemList();
|
||||
|
||||
columns.add(
|
||||
'id',
|
||||
{
|
||||
name: app.translator.trans('core.admin.users.grid.columns.user_id.title'),
|
||||
content: (user: User) => user.id(),
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
columns.add(
|
||||
'username',
|
||||
{
|
||||
name: app.translator.trans('core.admin.users.grid.columns.username.title'),
|
||||
content: (user: User) => {
|
||||
const profileUrl = `${app.forum.attribute('baseUrl')}/u/${user.slug()}`;
|
||||
|
||||
return (
|
||||
<a
|
||||
target="_blank"
|
||||
href={profileUrl}
|
||||
title={extractText(app.translator.trans('core.admin.users.grid.columns.username.profile_link_tooltip', { username: user.username() }))}
|
||||
>
|
||||
{user.username()}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
90
|
||||
);
|
||||
|
||||
columns.add(
|
||||
'joinDate',
|
||||
{
|
||||
name: app.translator.trans('core.admin.users.grid.columns.join_time.title'),
|
||||
content: (user: User) => (
|
||||
<span class="UserList-joinDate" title={user.joinTime()}>
|
||||
{dayjs(user.joinTime()).format('LLL')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
80
|
||||
);
|
||||
|
||||
columns.add(
|
||||
'groupBadges',
|
||||
{
|
||||
name: app.translator.trans('core.admin.users.grid.columns.group_badges.title'),
|
||||
content: (user: User) => {
|
||||
const badges = user.badges().toArray();
|
||||
|
||||
if (badges.length) {
|
||||
return <ul className="DiscussionHero-badges badges">{listItems(badges)}</ul>;
|
||||
} else {
|
||||
return app.translator.trans('core.admin.users.grid.columns.group_badges.no_badges');
|
||||
}
|
||||
},
|
||||
},
|
||||
70
|
||||
);
|
||||
|
||||
columns.add(
|
||||
'emailAddress',
|
||||
{
|
||||
name: app.translator.trans('core.admin.users.grid.columns.email.title'),
|
||||
content: (user: User) => {
|
||||
function setEmailVisibility(visible: boolean) {
|
||||
// Get needed jQuery element refs
|
||||
const emailContainer = $(`[data-column-name=emailAddress][data-user-id=${user.id()}] .UserList-email`);
|
||||
const emailAddress = emailContainer.find('.UserList-emailAddress');
|
||||
const emailToggleButton = emailContainer.find('.UserList-emailIconBtn');
|
||||
const emailToggleButtonIcon = emailToggleButton.find('.icon');
|
||||
|
||||
emailToggleButton.attr(
|
||||
'title',
|
||||
extractText(
|
||||
visible
|
||||
? app.translator.trans('core.admin.users.grid.columns.email.visibility_hide')
|
||||
: app.translator.trans('core.admin.users.grid.columns.email.visibility_show')
|
||||
)
|
||||
);
|
||||
|
||||
emailAddress.attr('aria-hidden', visible ? 'false' : 'true');
|
||||
|
||||
if (visible) {
|
||||
emailToggleButtonIcon.addClass('fa-eye');
|
||||
emailToggleButtonIcon.removeClass('fa-eye-slash');
|
||||
} else {
|
||||
emailToggleButtonIcon.removeClass('fa-eye');
|
||||
emailToggleButtonIcon.addClass('fa-eye-slash');
|
||||
}
|
||||
|
||||
// Need the string interpolation to prevent TS error.
|
||||
emailContainer.attr('data-email-shown', `${visible}`);
|
||||
}
|
||||
|
||||
function toggleEmailVisibility() {
|
||||
const emailContainer = $(`[data-column-name=emailAddress][data-user-id=${user.id()}] .UserList-email`);
|
||||
const emailShown = emailContainer.attr('data-email-shown') === 'true';
|
||||
|
||||
if (emailShown) {
|
||||
setEmailVisibility(false);
|
||||
} else {
|
||||
setEmailVisibility(true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="UserList-email" key={user.id()} data-email-shown="false">
|
||||
<span class="UserList-emailAddress" aria-hidden onclick={() => setEmailVisibility(true)}>
|
||||
{user.email()}
|
||||
</span>
|
||||
<button
|
||||
onclick={toggleEmailVisibility}
|
||||
class="Button Button--text UserList-emailIconBtn"
|
||||
title={app.translator.trans('core.admin.users.grid.columns.email.visibility_show')}
|
||||
>
|
||||
{icon('far fa-eye-slash fa-fw', { className: 'icon' })}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
70
|
||||
);
|
||||
|
||||
columns.add(
|
||||
'editUser',
|
||||
{
|
||||
name: app.translator.trans('core.admin.users.grid.columns.edit_user.title'),
|
||||
content: (user: User) => (
|
||||
<Button
|
||||
className="Button UserList-editModalBtn"
|
||||
title={app.translator.trans('core.admin.users.grid.columns.edit_user.tooltip', { username: user.username() })}
|
||||
onclick={() => app.modal.show(EditUserModal, { user })}
|
||||
>
|
||||
{app.translator.trans('core.admin.users.grid.columns.edit_user.button')}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
-90
|
||||
);
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
headerInfo() {
|
||||
return {
|
||||
className: 'UserListPage',
|
||||
icon: 'fas fa-users',
|
||||
title: app.translator.trans('core.admin.users.title'),
|
||||
description: app.translator.trans('core.admin.users.description'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously fetch the next set of users to be rendered.
|
||||
*
|
||||
* Returns an array of Users, plus the raw API payload.
|
||||
*
|
||||
* Uses the `this.numPerPage` as the response limit, and automatically calculates the offset required from `pageNumber`.
|
||||
*
|
||||
* @param pageNumber The page number to load and display
|
||||
*/
|
||||
async loadPage(pageNumber: number) {
|
||||
if (pageNumber < 0) pageNumber = 0;
|
||||
|
||||
app.store
|
||||
.find('users', {
|
||||
page: {
|
||||
limit: this.numPerPage,
|
||||
offset: pageNumber * this.numPerPage,
|
||||
},
|
||||
})
|
||||
.then((apiData: UsersApiResponse) => {
|
||||
// Next link won't be present if there's no more data
|
||||
this.moreData = !!apiData.payload.links.next;
|
||||
|
||||
let data = apiData;
|
||||
|
||||
// @ts-ignore
|
||||
delete data.payload;
|
||||
|
||||
this.pageData = data;
|
||||
this.pageNumber = pageNumber;
|
||||
this.isLoadingPage = false;
|
||||
|
||||
m.redraw();
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
console.error(err);
|
||||
this.pageData = [];
|
||||
});
|
||||
}
|
||||
|
||||
nextPage() {
|
||||
this.isLoadingPage = true;
|
||||
this.loadPage(this.pageNumber + 1);
|
||||
}
|
||||
|
||||
previousPage() {
|
||||
this.isLoadingPage = true;
|
||||
this.loadPage(this.pageNumber - 1);
|
||||
}
|
||||
}
|
@@ -3,6 +3,7 @@ import BasicsPage from './components/BasicsPage';
|
||||
import PermissionsPage from './components/PermissionsPage';
|
||||
import AppearancePage from './components/AppearancePage';
|
||||
import MailPage from './components/MailPage';
|
||||
import UserListPage from './components/UserListPage';
|
||||
import ExtensionPage from './components/ExtensionPage';
|
||||
import ExtensionPageResolver from './resolvers/ExtensionPageResolver';
|
||||
|
||||
@@ -18,6 +19,7 @@ export default function (app) {
|
||||
permissions: { path: '/permissions', component: PermissionsPage },
|
||||
appearance: { path: '/appearance', component: AppearancePage },
|
||||
mail: { path: '/mail', component: MailPage },
|
||||
users: { path: '/users', component: UserListPage },
|
||||
extension: { path: '/extension/:id', component: ExtensionPage, resolverClass: ExtensionPageResolver },
|
||||
};
|
||||
}
|
||||
|
@@ -12,6 +12,7 @@ import mapRoutes from './utils/mapRoutes';
|
||||
import RequestError from './utils/RequestError';
|
||||
import ScrollListener from './utils/ScrollListener';
|
||||
import liveHumanTimes from './utils/liveHumanTimes';
|
||||
import patchMithril from './utils/patchMithril';
|
||||
import { extend } from './extend';
|
||||
|
||||
import Forum from './models/Forum';
|
||||
@@ -20,7 +21,6 @@ import Discussion from './models/Discussion';
|
||||
import Post from './models/Post';
|
||||
import Group from './models/Group';
|
||||
import Notification from './models/Notification';
|
||||
import { flattenDeep } from 'lodash-es';
|
||||
import PageState from './states/PageState';
|
||||
import ModalManagerState from './states/ModalManagerState';
|
||||
import AlertManagerState from './states/AlertManagerState';
|
||||
@@ -163,10 +163,12 @@ export default class Application {
|
||||
|
||||
load(payload) {
|
||||
this.data = payload;
|
||||
this.translator.locale = payload.locale;
|
||||
this.translator.setLocale(payload.locale);
|
||||
}
|
||||
|
||||
boot() {
|
||||
patchMithril(window);
|
||||
|
||||
this.initializers.toArray().forEach((initializer) => initializer(this));
|
||||
|
||||
this.store.pushPayload({ data: this.data.resources });
|
||||
@@ -180,11 +182,15 @@ export default class Application {
|
||||
this.initialRoute = window.location.href;
|
||||
}
|
||||
|
||||
// TODO: This entire system needs a do-over for v2
|
||||
bootExtensions(extensions) {
|
||||
Object.keys(extensions).forEach((name) => {
|
||||
const extension = extensions[name];
|
||||
|
||||
const extenders = flattenDeep(extension.extend);
|
||||
// If an extension doesn't define extenders, there's nothing more to do here.
|
||||
if (!extension.extend) return;
|
||||
|
||||
const extenders = extension.extend.flat(Infinity);
|
||||
|
||||
for (const extender of extenders) {
|
||||
extender.extend(this, { name, exports: extension });
|
||||
|
60
js/src/common/FlarumRegistry.ts
Normal file
60
js/src/common/FlarumRegistry.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
interface ExportRegistry {
|
||||
moduleExports: object;
|
||||
|
||||
onLoads: object;
|
||||
|
||||
/**
|
||||
* Add an instance to the registry.
|
||||
* This serves as the equivalent of `flarum.core.compat[id] = object`
|
||||
*/
|
||||
add(namespace: string, id: string, object: any): void;
|
||||
|
||||
/**
|
||||
* Add a function to run when object of id "id" is added (or overriden).
|
||||
* If such an object is already registered, the handler will be applied immediately.
|
||||
*/
|
||||
onLoad(namespace: string, id: string, handler: Function): void;
|
||||
|
||||
/**
|
||||
* Retrieve an object of type `id` from the registry.
|
||||
*/
|
||||
get(namespace: string, id: string): any;
|
||||
}
|
||||
|
||||
export default class FlarumRegistry implements ExportRegistry {
|
||||
moduleExports = new Map<string, any>();
|
||||
onLoads = new Map<string, Function[]>();
|
||||
|
||||
protected genKey(namespace: string, id: string): string {
|
||||
return `${namespace};${id}`;
|
||||
}
|
||||
|
||||
add(namespace: string, id: string, object: any) {
|
||||
const key = this.genKey(namespace, id);
|
||||
|
||||
const onLoads = this.onLoads.get(key);
|
||||
if (onLoads) {
|
||||
onLoads.reduce((acc, handler) => handler(acc), object);
|
||||
}
|
||||
|
||||
this.moduleExports.set(key, object);
|
||||
}
|
||||
|
||||
onLoad(namespace: string, id: string, handler: Function) {
|
||||
const key = this.genKey(namespace, id);
|
||||
|
||||
const loadedObject = this.moduleExports.get(key);
|
||||
if (loadedObject) {
|
||||
this.moduleExports[id] = handler(loadedObject);
|
||||
} else {
|
||||
const currOnLoads = this.onLoads.get(key);
|
||||
this.onLoads.set(key, [...(currOnLoads || []), handler]);
|
||||
}
|
||||
}
|
||||
|
||||
get(namespace: string, id: string): any {
|
||||
const key = this.genKey(namespace, id);
|
||||
|
||||
return this.moduleExports.get(key);
|
||||
}
|
||||
}
|
@@ -1,13 +1,8 @@
|
||||
import { RichMessageFormatter, mithrilRichHandler } from '@askvortsov/rich-icu-message-formatter';
|
||||
import { pluralTypeHandler, selectTypeHandler } from '@ultraq/icu-message-formatter';
|
||||
import username from './helpers/username';
|
||||
import extract from './utils/extract';
|
||||
|
||||
/**
|
||||
* Translator with the same API as Symfony's.
|
||||
*
|
||||
* Derived from https://github.com/willdurand/BazingaJsTranslationBundle
|
||||
* which is available under the MIT License.
|
||||
* Copyright (c) William Durand <william.durand1@gmail.com>
|
||||
*/
|
||||
export default class Translator {
|
||||
constructor() {
|
||||
/**
|
||||
@@ -18,288 +13,53 @@ export default class Translator {
|
||||
*/
|
||||
this.translations = {};
|
||||
|
||||
this.locale = null;
|
||||
this.formatter = new RichMessageFormatter(null, this.formatterTypeHandlers(), mithrilRichHandler);
|
||||
}
|
||||
|
||||
formatterTypeHandlers() {
|
||||
return {
|
||||
plural: pluralTypeHandler,
|
||||
select: selectTypeHandler,
|
||||
};
|
||||
}
|
||||
|
||||
setLocale(locale) {
|
||||
this.formatter.locale = locale;
|
||||
}
|
||||
|
||||
addTranslations(translations) {
|
||||
Object.assign(this.translations, translations);
|
||||
}
|
||||
|
||||
trans(id, parameters) {
|
||||
const translation = this.translations[id];
|
||||
|
||||
if (translation) {
|
||||
return this.apply(translation, parameters || {});
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
transChoice(id, number, parameters) {
|
||||
let translation = this.translations[id];
|
||||
|
||||
if (translation) {
|
||||
number = parseInt(number, 10);
|
||||
|
||||
translation = this.pluralize(translation, number);
|
||||
|
||||
return this.apply(translation, parameters || {});
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
apply(translation, input) {
|
||||
preprocessParameters(parameters) {
|
||||
// If we've been given a user model as one of the input parameters, then
|
||||
// we'll extract the username and use that for the translation. In the
|
||||
// future there should be a hook here to inspect the user and change the
|
||||
// translation key. This will allow a gender property to determine which
|
||||
// translation key is used.
|
||||
if ('user' in input) {
|
||||
const user = extract(input, 'user');
|
||||
if ('user' in parameters) {
|
||||
const user = extract(parameters, 'user');
|
||||
|
||||
if (!input.username) input.username = username(user);
|
||||
if (!parameters.username) parameters.username = username(user);
|
||||
}
|
||||
|
||||
translation = translation.split(new RegExp('({[a-z0-9_]+}|</?[a-z0-9_]+>)', 'gi'));
|
||||
|
||||
const hydrated = [];
|
||||
const open = [hydrated];
|
||||
|
||||
translation.forEach((part) => {
|
||||
const match = part.match(new RegExp('{([a-z0-9_]+)}|<(/?)([a-z0-9_]+)>', 'i'));
|
||||
|
||||
if (match) {
|
||||
// Either an opening or closing tag.
|
||||
if (match[1]) {
|
||||
open[0].push(input[match[1]]);
|
||||
} else if (match[3]) {
|
||||
if (match[2]) {
|
||||
// Closing tag. We start by removing all raw children (generally in the form of strings) from the temporary
|
||||
// holding array, then run them through m.fragment to convert them to vnodes. Usually this will just give us a
|
||||
// text vnode, but using m.fragment as opposed to an explicit conversion should be more flexible. This is necessary because
|
||||
// otherwise, our generated vnode will have raw strings as its children, and mithril expects vnodes.
|
||||
// Finally, we add the now-processed vnodes back onto the holding array (which is the same object in memory as the
|
||||
// children array of the vnode we are currently processing), and remove the reference to the holding array so that
|
||||
// further text will be added to the full set of returned elements.
|
||||
const rawChildren = open[0].splice(0, open[0].length);
|
||||
open[0].push(...m.fragment(rawChildren).children);
|
||||
open.shift();
|
||||
} else {
|
||||
// If a vnode with a matching tag was provided in the translator input, we use that. Otherwise, we create a new vnode
|
||||
// with this tag, and an empty children array (since we're expecting to insert children, as that's the point of having this in translator)
|
||||
let tag = input[match[3]] || { tag: match[3], children: [] };
|
||||
open[0].push(tag);
|
||||
// Insert the tag's children array as the first element of open, so that text in between the opening
|
||||
// and closing tags will be added to the tag's children, not to the full set of returned elements.
|
||||
open.unshift(tag.children || tag);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not an html tag, we add it to open[0], which is either the full set of returned elements (vnodes and text),
|
||||
// or if an html tag is currently being processed, the children attribute of that html tag's vnode.
|
||||
open[0].push(part);
|
||||
}
|
||||
});
|
||||
|
||||
return hydrated.filter((part) => part);
|
||||
return parameters;
|
||||
}
|
||||
|
||||
pluralize(translation, number) {
|
||||
const sPluralRegex = new RegExp(/^\w+\: +(.+)$/),
|
||||
cPluralRegex = new RegExp(/^\s*((\{\s*(\-?\d+[\s*,\s*\-?\d+]*)\s*\})|([\[\]])\s*(-Inf|\-?\d+)\s*,\s*(\+?Inf|\-?\d+)\s*([\[\]]))\s?(.+?)$/),
|
||||
iPluralRegex = new RegExp(/^\s*(\{\s*(\-?\d+[\s*,\s*\-?\d+]*)\s*\})|([\[\]])\s*(-Inf|\-?\d+)\s*,\s*(\+?Inf|\-?\d+)\s*([\[\]])/),
|
||||
standardRules = [],
|
||||
explicitRules = [];
|
||||
trans(id, parameters) {
|
||||
const translation = this.translations[id];
|
||||
|
||||
translation.split('|').forEach((part) => {
|
||||
if (cPluralRegex.test(part)) {
|
||||
const matches = part.match(cPluralRegex);
|
||||
explicitRules[matches[0]] = matches[matches.length - 1];
|
||||
} else if (sPluralRegex.test(part)) {
|
||||
const matches = part.match(sPluralRegex);
|
||||
standardRules.push(matches[1]);
|
||||
} else {
|
||||
standardRules.push(part);
|
||||
}
|
||||
});
|
||||
if (translation) {
|
||||
parameters = this.preprocessParameters(parameters || {});
|
||||
return this.formatter.rich(translation, parameters);
|
||||
}
|
||||
|
||||
explicitRules.forEach((rule, e) => {
|
||||
if (iPluralRegex.test(e)) {
|
||||
const matches = e.match(iPluralRegex);
|
||||
|
||||
if (matches[1]) {
|
||||
const ns = matches[2].split(',');
|
||||
|
||||
for (let n in ns) {
|
||||
if (number == ns[n]) {
|
||||
return explicitRules[e];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var leftNumber = this.convertNumber(matches[4]);
|
||||
var rightNumber = this.convertNumber(matches[5]);
|
||||
|
||||
if (
|
||||
('[' === matches[3] ? number >= leftNumber : number > leftNumber) &&
|
||||
(']' === matches[6] ? number <= rightNumber : number < rightNumber)
|
||||
) {
|
||||
return explicitRules[e];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return standardRules[this.pluralPosition(number, this.locale)] || standardRules[0] || undefined;
|
||||
return id;
|
||||
}
|
||||
|
||||
convertNumber(number) {
|
||||
if ('-Inf' === number) {
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
} else if ('+Inf' === number || 'Inf' === number) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
return parseInt(number, 10);
|
||||
}
|
||||
|
||||
pluralPosition(number, locale) {
|
||||
if ('pt_BR' === locale) {
|
||||
locale = 'xbr';
|
||||
}
|
||||
|
||||
if (locale.length > 3) {
|
||||
locale = locale.split('_')[0];
|
||||
}
|
||||
|
||||
switch (locale) {
|
||||
case 'bo':
|
||||
case 'dz':
|
||||
case 'id':
|
||||
case 'ja':
|
||||
case 'jv':
|
||||
case 'ka':
|
||||
case 'km':
|
||||
case 'kn':
|
||||
case 'ko':
|
||||
case 'ms':
|
||||
case 'th':
|
||||
case 'vi':
|
||||
case 'zh':
|
||||
return 0;
|
||||
|
||||
case 'af':
|
||||
case 'az':
|
||||
case 'bn':
|
||||
case 'bg':
|
||||
case 'ca':
|
||||
case 'da':
|
||||
case 'de':
|
||||
case 'el':
|
||||
case 'en':
|
||||
case 'eo':
|
||||
case 'es':
|
||||
case 'et':
|
||||
case 'eu':
|
||||
case 'fa':
|
||||
case 'fi':
|
||||
case 'fo':
|
||||
case 'fur':
|
||||
case 'fy':
|
||||
case 'gl':
|
||||
case 'gu':
|
||||
case 'ha':
|
||||
case 'he':
|
||||
case 'hu':
|
||||
case 'is':
|
||||
case 'it':
|
||||
case 'ku':
|
||||
case 'lb':
|
||||
case 'ml':
|
||||
case 'mn':
|
||||
case 'mr':
|
||||
case 'nah':
|
||||
case 'nb':
|
||||
case 'ne':
|
||||
case 'nl':
|
||||
case 'nn':
|
||||
case 'no':
|
||||
case 'om':
|
||||
case 'or':
|
||||
case 'pa':
|
||||
case 'pap':
|
||||
case 'ps':
|
||||
case 'pt':
|
||||
case 'so':
|
||||
case 'sq':
|
||||
case 'sv':
|
||||
case 'sw':
|
||||
case 'ta':
|
||||
case 'te':
|
||||
case 'tk':
|
||||
case 'tr':
|
||||
case 'ur':
|
||||
case 'zu':
|
||||
return number == 1 ? 0 : 1;
|
||||
|
||||
case 'am':
|
||||
case 'bh':
|
||||
case 'fil':
|
||||
case 'fr':
|
||||
case 'gun':
|
||||
case 'hi':
|
||||
case 'ln':
|
||||
case 'mg':
|
||||
case 'nso':
|
||||
case 'xbr':
|
||||
case 'ti':
|
||||
case 'wa':
|
||||
return number === 0 || number == 1 ? 0 : 1;
|
||||
|
||||
case 'be':
|
||||
case 'bs':
|
||||
case 'hr':
|
||||
case 'ru':
|
||||
case 'sr':
|
||||
case 'uk':
|
||||
return number % 10 == 1 && number % 100 != 11 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2;
|
||||
|
||||
case 'cs':
|
||||
case 'sk':
|
||||
return number == 1 ? 0 : number >= 2 && number <= 4 ? 1 : 2;
|
||||
|
||||
case 'ga':
|
||||
return number == 1 ? 0 : number == 2 ? 1 : 2;
|
||||
|
||||
case 'lt':
|
||||
return number % 10 == 1 && number % 100 != 11 ? 0 : number % 10 >= 2 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2;
|
||||
|
||||
case 'sl':
|
||||
return number % 100 == 1 ? 0 : number % 100 == 2 ? 1 : number % 100 == 3 || number % 100 == 4 ? 2 : 3;
|
||||
|
||||
case 'mk':
|
||||
return number % 10 == 1 ? 0 : 1;
|
||||
|
||||
case 'mt':
|
||||
return number == 1 ? 0 : number === 0 || (number % 100 > 1 && number % 100 < 11) ? 1 : number % 100 > 10 && number % 100 < 20 ? 2 : 3;
|
||||
|
||||
case 'lv':
|
||||
return number === 0 ? 0 : number % 10 == 1 && number % 100 != 11 ? 1 : 2;
|
||||
|
||||
case 'pl':
|
||||
return number == 1 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 12 || number % 100 > 14) ? 1 : 2;
|
||||
|
||||
case 'cy':
|
||||
return number == 1 ? 0 : number == 2 ? 1 : number == 8 || number == 11 ? 2 : 3;
|
||||
|
||||
case 'ro':
|
||||
return number == 1 ? 0 : number === 0 || (number % 100 > 0 && number % 100 < 20) ? 1 : 2;
|
||||
|
||||
case 'ar':
|
||||
return number === 0 ? 0 : number == 1 ? 1 : number == 2 ? 2 : number >= 3 && number <= 10 ? 3 : number >= 11 && number <= 99 ? 4 : 5;
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
/**
|
||||
* @deprecated, remove before stable
|
||||
*/
|
||||
transChoice(id, number, parameters) {
|
||||
return this.trans(id, parameters);
|
||||
}
|
||||
}
|
||||
|
@@ -12,7 +12,9 @@ import Drawer from './utils/Drawer';
|
||||
import anchorScroll from './utils/anchorScroll';
|
||||
import RequestError from './utils/RequestError';
|
||||
import abbreviateNumber from './utils/abbreviateNumber';
|
||||
import escapeRegExp from './utils/escapeRegExp';
|
||||
import * as string from './utils/string';
|
||||
import * as ThrottleDebounce from './utils/throttleDebounce';
|
||||
import Stream from './utils/Stream';
|
||||
import SubtreeRetainer from './utils/SubtreeRetainer';
|
||||
import setRouteWithForcedRefresh from './utils/setRouteWithForcedRefresh';
|
||||
@@ -59,6 +61,7 @@ import Modal from './components/Modal';
|
||||
import GroupBadge from './components/GroupBadge';
|
||||
import TextEditor from './components/TextEditor';
|
||||
import TextEditorButton from './components/TextEditorButton';
|
||||
import EditUserModal from './components/EditUserModal';
|
||||
import Model from './Model';
|
||||
import Application from './Application';
|
||||
import fullTime from './helpers/fullTime';
|
||||
@@ -90,6 +93,7 @@ export default {
|
||||
'utils/abbreviateNumber': abbreviateNumber,
|
||||
'utils/string': string,
|
||||
'utils/SubtreeRetainer': SubtreeRetainer,
|
||||
'utils/escapeRegExp': escapeRegExp,
|
||||
'utils/extract': extract,
|
||||
'utils/ScrollListener': ScrollListener,
|
||||
'utils/stringToColor': stringToColor,
|
||||
@@ -103,6 +107,7 @@ export default {
|
||||
'utils/formatNumber': formatNumber,
|
||||
'utils/mapRoutes': mapRoutes,
|
||||
'utils/withAttr': withAttr,
|
||||
'utils/throttleDebounce': ThrottleDebounce,
|
||||
'models/Notification': Notification,
|
||||
'models/User': User,
|
||||
'models/Post': Post,
|
||||
@@ -136,6 +141,7 @@ export default {
|
||||
'components/GroupBadge': GroupBadge,
|
||||
'components/TextEditor': TextEditor,
|
||||
'components/TextEditorButton': TextEditorButton,
|
||||
'components/EditUserModal': EditUserModal,
|
||||
Model: Model,
|
||||
Application: Application,
|
||||
'helpers/fullTime': fullTime,
|
||||
|
@@ -69,7 +69,7 @@ export default class Button extends Component {
|
||||
return [
|
||||
iconName && iconName !== true ? icon(iconName, { className: 'Button-icon' }) : '',
|
||||
children ? <span className="Button-label">{children}</span> : '',
|
||||
this.attrs.loading ? <LoadingIndicator size="tiny" className="LoadingIndicator--inline" /> : '',
|
||||
this.attrs.loading ? <LoadingIndicator size="small" display="inline" /> : '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@@ -46,7 +46,7 @@ export default class Checkbox extends Component {
|
||||
* @protected
|
||||
*/
|
||||
getDisplay() {
|
||||
return this.attrs.loading ? <LoadingIndicator size="tiny" /> : icon(this.attrs.state ? 'fas fa-check' : 'fas fa-times');
|
||||
return this.attrs.loading ? <LoadingIndicator display="unset" size="small" /> : icon(this.attrs.state ? 'fas fa-check' : 'fas fa-times');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -1,10 +1,10 @@
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Button from '../../common/components/Button';
|
||||
import GroupBadge from '../../common/components/GroupBadge';
|
||||
import Group from '../../common/models/Group';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import Modal from './Modal';
|
||||
import Button from './Button';
|
||||
import GroupBadge from './GroupBadge';
|
||||
import Group from '../models/Group';
|
||||
import extractText from '../utils/extractText';
|
||||
import ItemList from '../utils/ItemList';
|
||||
import Stream from '../utils/Stream';
|
||||
|
||||
/**
|
||||
* The `EditUserModal` component displays a modal dialog with a login form.
|
||||
@@ -33,14 +33,14 @@ export default class EditUserModal extends Modal {
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.forum.edit_user.title');
|
||||
return app.translator.trans('core.lib.edit_user.title');
|
||||
}
|
||||
|
||||
content() {
|
||||
const fields = this.fields().toArray();
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
{fields.length > 1 ? <div className="Form">{this.fields().toArray()}</div> : app.translator.trans('core.forum.edit_user.nothing_available')}
|
||||
{fields.length > 1 ? <div className="Form">{this.fields().toArray()}</div> : app.translator.trans('core.lib.edit_user.nothing_available')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,10 +52,10 @@ export default class EditUserModal extends Modal {
|
||||
items.add(
|
||||
'username',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.forum.edit_user.username_heading')}</label>
|
||||
<label>{app.translator.trans('core.lib.edit_user.username_heading')}</label>
|
||||
<input
|
||||
className="FormControl"
|
||||
placeholder={extractText(app.translator.trans('core.forum.edit_user.username_label'))}
|
||||
placeholder={extractText(app.translator.trans('core.lib.edit_user.username_label'))}
|
||||
bidi={this.username}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
/>
|
||||
@@ -67,11 +67,11 @@ export default class EditUserModal extends Modal {
|
||||
items.add(
|
||||
'email',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.forum.edit_user.email_heading')}</label>
|
||||
<label>{app.translator.trans('core.lib.edit_user.email_heading')}</label>
|
||||
<div>
|
||||
<input
|
||||
className="FormControl"
|
||||
placeholder={extractText(app.translator.trans('core.forum.edit_user.email_label'))}
|
||||
placeholder={extractText(app.translator.trans('core.lib.edit_user.email_label'))}
|
||||
bidi={this.email}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
/>
|
||||
@@ -84,7 +84,7 @@ export default class EditUserModal extends Modal {
|
||||
loading: this.loading,
|
||||
onclick: this.activate.bind(this),
|
||||
},
|
||||
app.translator.trans('core.forum.edit_user.activate_button')
|
||||
app.translator.trans('core.lib.edit_user.activate_button')
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -97,7 +97,7 @@ export default class EditUserModal extends Modal {
|
||||
items.add(
|
||||
'password',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.forum.edit_user.password_heading')}</label>
|
||||
<label>{app.translator.trans('core.lib.edit_user.password_heading')}</label>
|
||||
<div>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
@@ -110,14 +110,14 @@ export default class EditUserModal extends Modal {
|
||||
}}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
/>
|
||||
{app.translator.trans('core.forum.edit_user.set_password_label')}
|
||||
{app.translator.trans('core.lib.edit_user.set_password_label')}
|
||||
</label>
|
||||
{this.setPassword() ? (
|
||||
<input
|
||||
className="FormControl"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={extractText(app.translator.trans('core.forum.edit_user.password_label'))}
|
||||
placeholder={extractText(app.translator.trans('core.lib.edit_user.password_label'))}
|
||||
bidi={this.password}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
/>
|
||||
@@ -135,7 +135,7 @@ export default class EditUserModal extends Modal {
|
||||
items.add(
|
||||
'groups',
|
||||
<div className="Form-group EditUserModal-groups">
|
||||
<label>{app.translator.trans('core.forum.edit_user.groups_heading')}</label>
|
||||
<label>{app.translator.trans('core.lib.edit_user.groups_heading')}</label>
|
||||
<div>
|
||||
{Object.keys(this.groups)
|
||||
.map((id) => app.store.getById('groups', id))
|
||||
@@ -164,7 +164,7 @@ export default class EditUserModal extends Modal {
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
},
|
||||
app.translator.trans('core.forum.edit_user.submit_button')
|
||||
app.translator.trans('core.lib.edit_user.submit_button')
|
||||
)}
|
||||
</div>,
|
||||
-10
|
@@ -18,6 +18,12 @@ export interface LoadingIndicatorAttrs extends ComponentAttrs {
|
||||
* Optional attributes to apply to the loading indicator's container.
|
||||
*/
|
||||
containerAttrs?: Partial<ComponentAttrs>;
|
||||
/**
|
||||
* Display type of the spinner.
|
||||
*
|
||||
* @default 'block'
|
||||
*/
|
||||
display?: 'block' | 'inline' | 'unset';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,12 +32,13 @@ export interface LoadingIndicatorAttrs extends ComponentAttrs {
|
||||
* To set a custom color, use the CSS `color` property.
|
||||
*
|
||||
* To increase spacing around the spinner, use the CSS `height` property on the
|
||||
* spinner's **container**.
|
||||
* spinner's **container**. Setting the `display` attribute to `block` will set
|
||||
* a height of `100px` by default.
|
||||
*
|
||||
* To apply a custom size to the loading indicator, set the `--size` and
|
||||
* `--thickness` custom properties on the loading indicator itself.
|
||||
* `--thickness` CSS custom properties on the loading indicator container.
|
||||
*
|
||||
* If you really want to change how this looks as part of your custom theme,
|
||||
* If you *really* want to change how this looks as part of your custom theme,
|
||||
* you can override the `border-radius` and `border` then set either a
|
||||
* background image, or use `content: "\<glyph>"` (e.g. `content: "\f1ce"`)
|
||||
* and `font-family: 'Font Awesome 5 Free'` to set an FA icon if you'd rather.
|
||||
@@ -40,21 +47,33 @@ export interface LoadingIndicatorAttrs extends ComponentAttrs {
|
||||
*
|
||||
* - `containerClassName` Class name(s) to apply to the indicator's parent
|
||||
* - `className` Class name(s) to apply to the indicator itself
|
||||
* - `size` Size of the loading indicator
|
||||
* - `display` Determines how the spinner should be displayed (`inline`, `block` (default) or `unset`)
|
||||
* - `size` Size of the loading indicator (`small`, `medium` or `large`)
|
||||
* - `containerAttrs` Optional attrs to be applied to the container DOM element
|
||||
*
|
||||
* All other attrs will be assigned as attributes on the DOM element.
|
||||
*/
|
||||
export default class LoadingIndicator extends Component<LoadingIndicatorAttrs> {
|
||||
view() {
|
||||
const { size, ...attrs } = this.attrs;
|
||||
const { display = 'block', size = 'medium', containerClassName, className, ...attrs } = this.attrs;
|
||||
|
||||
attrs.className = classList({ LoadingIndicator: true, [attrs.className || '']: true });
|
||||
attrs.containerClassName = classList({ 'LoadingIndicator-container': true, [attrs.containerClassName || '']: true });
|
||||
const completeClassName = classList('LoadingIndicator', className);
|
||||
const completeContainerClassName = classList(
|
||||
'LoadingIndicator-container',
|
||||
display !== 'unset' && `LoadingIndicator-container--${display}`,
|
||||
size && `LoadingIndicator-container--${size}`,
|
||||
containerClassName
|
||||
);
|
||||
|
||||
return (
|
||||
<div {...attrs.containerAttrs} data-size={size} className={attrs.containerClassName}>
|
||||
<div {...attrs}></div>
|
||||
<div
|
||||
aria-label={app.translator.trans('core.lib.loading_indicator.accessible_label')}
|
||||
role="status"
|
||||
{...attrs.containerAttrs}
|
||||
data-size={size}
|
||||
className={completeContainerClassName}
|
||||
>
|
||||
<div aria-hidden className={completeClassName} {...attrs} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -9,27 +9,36 @@
|
||||
* Care should be taken to extend the correct object – in most cases, a class'
|
||||
* prototype will be the desired target of extension, not the class itself.
|
||||
*
|
||||
* @example
|
||||
* @example <caption>Example usage of extending one method.</caption>
|
||||
* extend(Discussion.prototype, 'badges', function(badges) {
|
||||
* // do something with `badges`
|
||||
* });
|
||||
*
|
||||
* @param {Object} object The object that owns the method
|
||||
* @param {String} method The name of the method to extend
|
||||
* @example <caption>Example usage of extending multiple methods.</caption>
|
||||
* extend(IndexPage.prototype, ['oncreate', 'onupdate'], function(vnode) {
|
||||
* // something that needs to be run on creation and update
|
||||
* });
|
||||
*
|
||||
* @param {object} object The object that owns the method
|
||||
* @param {string|string[]} methods The name or names of the method(s) to extend
|
||||
* @param {function} callback A callback which mutates the method's output
|
||||
*/
|
||||
export function extend(object, method, callback) {
|
||||
const original = object[method];
|
||||
export function extend(object, methods, callback) {
|
||||
const allMethods = Array.isArray(methods) ? methods : [methods];
|
||||
|
||||
object[method] = function (...args) {
|
||||
const value = original ? original.apply(this, args) : undefined;
|
||||
allMethods.forEach((method) => {
|
||||
const original = object[method];
|
||||
|
||||
callback.apply(this, [value].concat(args));
|
||||
object[method] = function (...args) {
|
||||
const value = original ? original.apply(this, args) : undefined;
|
||||
|
||||
return value;
|
||||
};
|
||||
callback.apply(this, [value].concat(args));
|
||||
|
||||
Object.assign(object[method], original);
|
||||
return value;
|
||||
};
|
||||
|
||||
Object.assign(object[method], original);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,29 +46,38 @@ export function extend(object, method, callback) {
|
||||
* new function will be run every time the object's method is called.
|
||||
*
|
||||
* The replacement function accepts the original method as its first argument,
|
||||
* which is like a call to 'super'. Any arguments passed to the original method
|
||||
* which is like a call to `super`. Any arguments passed to the original method
|
||||
* are also passed to the replacement.
|
||||
*
|
||||
* Care should be taken to extend the correct object – in most cases, a class'
|
||||
* prototype will be the desired target of extension, not the class itself.
|
||||
*
|
||||
* @example
|
||||
* @example <caption>Example usage of overriding one method.</caption>
|
||||
* override(Discussion.prototype, 'badges', function(original) {
|
||||
* const badges = original();
|
||||
* // do something with badges
|
||||
* return badges;
|
||||
* });
|
||||
*
|
||||
* @param {Object} object The object that owns the method
|
||||
* @param {String} method The name of the method to override
|
||||
* @example <caption>Example usage of overriding multiple methods.</caption>
|
||||
* extend(Discussion.prototype, ['oncreate', 'onupdate'], function(original, vnode) {
|
||||
* // something that needs to be run on creation and update
|
||||
* });
|
||||
*
|
||||
* @param {object} object The object that owns the method
|
||||
* @param {string|string[]} method The name or names of the method(s) to override
|
||||
* @param {function} newMethod The method to replace it with
|
||||
*/
|
||||
export function override(object, method, newMethod) {
|
||||
const original = object[method];
|
||||
export function override(object, methods, newMethod) {
|
||||
const allMethods = Array.isArray(methods) ? methods : [methods];
|
||||
|
||||
object[method] = function (...args) {
|
||||
return newMethod.apply(this, [original.bind(this)].concat(args));
|
||||
};
|
||||
allMethods.forEach((method) => {
|
||||
const original = object[method];
|
||||
|
||||
Object.assign(object[method], original);
|
||||
object[method] = function (...args) {
|
||||
return newMethod.apply(this, [original.bind(this)].concat(args));
|
||||
};
|
||||
|
||||
Object.assign(object[method], original);
|
||||
});
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
// Expose jQuery, mithril and dayjs to the window browser object
|
||||
import 'expose-loader?exposes[]=$&exposes[]=jQuery!jquery';
|
||||
import 'expose-loader?exposes=$,jQuery!jquery';
|
||||
import 'expose-loader?exposes=m!mithril';
|
||||
import 'expose-loader?exposes=dayjs!dayjs';
|
||||
|
||||
@@ -16,10 +16,12 @@ import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
import patchMithril from './utils/patchMithril';
|
||||
import FlarumRegistry from './FlarumRegistry';
|
||||
|
||||
patchMithril(window);
|
||||
window.flreg = new FlarumRegistry();
|
||||
|
||||
import * as Extend from './extend/index';
|
||||
|
||||
export { Extend };
|
||||
|
||||
import './utils/arrayFlatPolyfill';
|
||||
|
14
js/src/common/utils/arrayFlatPolyfill.ts
Normal file
14
js/src/common/utils/arrayFlatPolyfill.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Based off of the polyfill on MDN
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flat#reduce_concat_isarray_recursivity
|
||||
//
|
||||
// Needed to provide support for Safari on iOS < 12
|
||||
|
||||
if (!Array.prototype['flat']) {
|
||||
Array.prototype['flat'] = function flat(this: any[], depth: number = 1): any[] {
|
||||
return depth > 0
|
||||
? Array.prototype.reduce.call(this, (acc, val): any[] => acc.concat(Array.isArray(val) ? flat.call(val, depth - 1) : val), [])
|
||||
: // If no depth is provided, or depth is 0, just return a copy of
|
||||
// the array. Spread is supported in all major browsers (iOS 8+)
|
||||
[...this];
|
||||
};
|
||||
}
|
10
js/src/common/utils/escapeRegExp.ts
Normal file
10
js/src/common/utils/escapeRegExp.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
const specialChars = /[.*+?^${}()|[\]\\]/g;
|
||||
|
||||
/**
|
||||
* Escapes the `RegExp` special characters in `input`.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
|
||||
*/
|
||||
export default function escapeRegExp(input: string): string {
|
||||
return input.replace(specialChars, '\\$&');
|
||||
}
|
@@ -2,7 +2,7 @@
|
||||
* The `evented` mixin provides methods allowing an object to trigger events,
|
||||
* running externally registered event handlers.
|
||||
*/
|
||||
export default {
|
||||
const evented = {
|
||||
/**
|
||||
* Arrays of registered event handlers, grouped by the event name.
|
||||
*
|
||||
@@ -79,3 +79,5 @@ export default {
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default evented;
|
||||
|
3
js/src/common/utils/throttleDebounce.ts
Normal file
3
js/src/common/utils/throttleDebounce.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Re-exports `throttle-debounce` to be used in `compat.js`.
|
||||
|
||||
export { throttle, debounce } from 'throttle-debounce';
|
@@ -51,7 +51,6 @@ import PostPreview from './components/PostPreview';
|
||||
import EventPost from './components/EventPost';
|
||||
import DiscussionHero from './components/DiscussionHero';
|
||||
import PostMeta from './components/PostMeta';
|
||||
import EditUserModal from './components/EditUserModal';
|
||||
import SearchSource from './components/SearchSource';
|
||||
import DiscussionRenamedPost from './components/DiscussionRenamedPost';
|
||||
import DiscussionComposer from './components/DiscussionComposer';
|
||||
@@ -70,6 +69,10 @@ import Search from './components/Search';
|
||||
import DiscussionListItem from './components/DiscussionListItem';
|
||||
import LoadingPost from './components/LoadingPost';
|
||||
import PostsUserPage from './components/PostsUserPage';
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
import EditUserModal from '../common/components/EditUserModal';
|
||||
import DiscussionPageResolver from './resolvers/DiscussionPageResolver';
|
||||
import BasicEditorDriver from '../common/utils/BasicEditorDriver';
|
||||
import routes from './routes';
|
||||
@@ -128,6 +131,9 @@ export default Object.assign(compat, {
|
||||
'components/EventPost': EventPost,
|
||||
'components/DiscussionHero': DiscussionHero,
|
||||
'components/PostMeta': PostMeta,
|
||||
/**
|
||||
* @deprecated Used for backwards compatibility now that the EditUserModal has moved to common. Remove in beta 17.
|
||||
*/
|
||||
'components/EditUserModal': EditUserModal,
|
||||
'components/SearchSource': SearchSource,
|
||||
'components/DiscussionRenamedPost': DiscussionRenamedPost,
|
||||
|
@@ -52,7 +52,13 @@ export default class AvatarEditor extends Component {
|
||||
ondragend={this.disableDragover.bind(this)}
|
||||
ondrop={this.dropUpload.bind(this)}
|
||||
>
|
||||
{this.loading ? <LoadingIndicator /> : user.avatarUrl() ? icon('fas fa-pencil-alt') : icon('fas fa-plus-circle')}
|
||||
{this.loading ? (
|
||||
<LoadingIndicator display="unset" size="large" />
|
||||
) : user.avatarUrl() ? (
|
||||
icon('fas fa-pencil-alt')
|
||||
) : (
|
||||
icon('fas fa-plus-circle')
|
||||
)}
|
||||
</a>
|
||||
<ul className="Dropdown-menu Menu">{listItems(this.controlItems().toArray())}</ul>
|
||||
</div>
|
||||
|
@@ -5,6 +5,7 @@ import TextEditor from '../../common/components/TextEditor';
|
||||
import avatar from '../../common/helpers/avatar';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import classList from '../../common/utils/classList';
|
||||
|
||||
/**
|
||||
* The `ComposerBody` component handles the body, or the content, of the
|
||||
@@ -66,7 +67,7 @@ export default class ComposerBody extends Component {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{LoadingIndicator.component({ className: 'ComposerBody-loading' + (this.loading ? ' active' : '') })}
|
||||
<LoadingIndicator display="unset" containerClassName={classList('ComposerBody-loading', this.loading && 'active')} size="large" />
|
||||
</div>
|
||||
</ConfirmDocumentUnload>
|
||||
);
|
||||
|
@@ -19,7 +19,7 @@ export default class DiscussionList extends Component {
|
||||
let loading;
|
||||
|
||||
if (state.isLoading()) {
|
||||
loading = LoadingIndicator.component();
|
||||
loading = <LoadingIndicator />;
|
||||
} else if (state.moreResults) {
|
||||
loading = Button.component(
|
||||
{
|
||||
|
@@ -15,8 +15,8 @@ import slidable from '../utils/slidable';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
import classList from '../../common/utils/classList';
|
||||
import DiscussionPage from './DiscussionPage';
|
||||
import escapeRegExp from '../../common/utils/escapeRegExp';
|
||||
|
||||
import { escapeRegExp } from 'lodash-es';
|
||||
/**
|
||||
* The `DiscussionListItem` component shows a single discussion in the
|
||||
* discussion list.
|
||||
|
@@ -73,23 +73,25 @@ export default class DiscussionPage extends Page {
|
||||
<div className="DiscussionPage">
|
||||
<DiscussionListPane state={app.discussions} />
|
||||
<div className="DiscussionPage-discussion">
|
||||
{discussion
|
||||
? [
|
||||
DiscussionHero.component({ discussion }),
|
||||
<div className="container">
|
||||
<nav className="DiscussionPage-nav">
|
||||
<ul>{listItems(this.sidebarItems().toArray())}</ul>
|
||||
</nav>
|
||||
<div className="DiscussionPage-stream">
|
||||
{PostStream.component({
|
||||
discussion,
|
||||
stream: this.stream,
|
||||
onPositionChange: this.positionChanged.bind(this),
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
]
|
||||
: LoadingIndicator.component({ className: 'LoadingIndicator--block' })}
|
||||
{discussion ? (
|
||||
[
|
||||
DiscussionHero.component({ discussion }),
|
||||
<div className="container">
|
||||
<nav className="DiscussionPage-nav">
|
||||
<ul>{listItems(this.sidebarItems().toArray())}</ul>
|
||||
</nav>
|
||||
<div className="DiscussionPage-stream">
|
||||
{PostStream.component({
|
||||
discussion,
|
||||
stream: this.stream,
|
||||
onPositionChange: this.positionChanged.bind(this),
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
]
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -57,7 +57,7 @@ export default class EventPost extends Post {
|
||||
* @return {String|Object} The description to render in the DOM
|
||||
*/
|
||||
description(data) {
|
||||
return app.translator.transChoice(this.descriptionKey(), data.count, data);
|
||||
return app.translator.trans(this.descriptionKey(), data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -4,6 +4,7 @@ import icon from '../../common/helpers/icon';
|
||||
import humanTime from '../../common/helpers/humanTime';
|
||||
import Button from '../../common/components/Button';
|
||||
import Link from '../../common/components/Link';
|
||||
import classList from '../../common/utils/classList';
|
||||
|
||||
/**
|
||||
* The `Notification` component abstract displays a single notification.
|
||||
@@ -22,27 +23,31 @@ export default class Notification extends Component {
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={'Notification Notification--' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')}
|
||||
className={classList('Notification', `Notification--${notification.contentType()}`, [!notification.isRead() && 'unread'])}
|
||||
href={href}
|
||||
external={href.includes('://')}
|
||||
onclick={this.markAsRead.bind(this)}
|
||||
>
|
||||
{!notification.isRead() &&
|
||||
Button.component({
|
||||
className: 'Notification-action Button Button--icon Button--link',
|
||||
icon: 'fas fa-check',
|
||||
title: app.translator.trans('core.forum.notifications.mark_as_read_tooltip'),
|
||||
onclick: (e) => {
|
||||
{avatar(notification.fromUser())}
|
||||
{icon(this.icon(), { className: 'Notification-icon' })}
|
||||
<span className="Notification-title">
|
||||
<span className="Notification-content">{this.content()}</span>
|
||||
<span className="Notification-title-spring" />
|
||||
{humanTime(notification.createdAt())}
|
||||
</span>
|
||||
{!notification.isRead() && (
|
||||
<Button
|
||||
className="Notification-action Button Button--link"
|
||||
icon="fas fa-check"
|
||||
title={app.translator.trans('core.forum.notifications.mark_as_read_tooltip')}
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.markAsRead();
|
||||
},
|
||||
})}
|
||||
{avatar(notification.fromUser())}
|
||||
{icon(this.icon(), { className: 'Notification-icon' })}
|
||||
<span className="Notification-content">{this.content()}</span>
|
||||
{humanTime(notification.createdAt())}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="Notification-excerpt">{this.excerpt()}</div>
|
||||
</Link>
|
||||
);
|
||||
|
@@ -17,16 +17,16 @@ export default class NotificationList extends Component {
|
||||
return (
|
||||
<div className="NotificationList">
|
||||
<div className="NotificationList-header">
|
||||
<div className="App-primaryControl">
|
||||
{Button.component({
|
||||
className: 'Button Button--icon Button--link',
|
||||
icon: 'fas fa-check',
|
||||
title: app.translator.trans('core.forum.notifications.mark_all_as_read_tooltip'),
|
||||
onclick: state.markAllAsRead.bind(state),
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h4 className="App-titleControl App-titleControl--text">{app.translator.trans('core.forum.notifications.title')}</h4>
|
||||
|
||||
<div className="App-primaryControl">
|
||||
<Button
|
||||
className="Button Button--link"
|
||||
icon="fas fa-check"
|
||||
title={app.translator.trans('core.forum.notifications.mark_all_as_read_tooltip')}
|
||||
onclick={state.markAllAsRead.bind(state)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="NotificationList-content">
|
||||
@@ -43,7 +43,7 @@ export default class NotificationList extends Component {
|
||||
// Get the discussion that this notification is related to. If it's not
|
||||
// directly related to a discussion, it may be related to a post or
|
||||
// other entity which is related to a discussion.
|
||||
let discussion = false;
|
||||
let discussion = null;
|
||||
if (subject instanceof Discussion) discussion = subject;
|
||||
else if (subject && subject.discussion) discussion = subject.discussion();
|
||||
|
||||
@@ -65,8 +65,8 @@ export default class NotificationList extends Component {
|
||||
<div className="NotificationGroup">
|
||||
{group.discussion ? (
|
||||
<Link className="NotificationGroup-header" href={app.route.discussion(group.discussion)}>
|
||||
{badges && badges.length ? <ul className="NotificationGroup-badges badges">{listItems(badges)}</ul> : ''}
|
||||
{group.discussion.title()}
|
||||
{badges && badges.length && <ul className="NotificationGroup-badges badges">{listItems(badges)}</ul>}
|
||||
<span>{group.discussion.title()}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="NotificationGroup-header">{app.forum.attribute('title')}</div>
|
||||
@@ -84,7 +84,7 @@ export default class NotificationList extends Component {
|
||||
})
|
||||
: ''}
|
||||
{state.isLoading() ? (
|
||||
<LoadingIndicator className="LoadingIndicator--block" />
|
||||
<LoadingIndicator />
|
||||
) : pages.length ? (
|
||||
''
|
||||
) : (
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import icon from '../../common/helpers/icon';
|
||||
import classList from '../../common/utils/classList';
|
||||
import NotificationList from './NotificationList';
|
||||
|
||||
export default class NotificationsDropdown extends Dropdown {
|
||||
@@ -9,6 +10,7 @@ export default class NotificationsDropdown extends Dropdown {
|
||||
attrs.menuClassName = attrs.menuClassName || 'Dropdown-menu--right';
|
||||
attrs.label = attrs.label || app.translator.trans('core.forum.notifications.tooltip');
|
||||
attrs.icon = attrs.icon || 'fas fa-bell';
|
||||
|
||||
// For best a11y support, both `title` and `aria-label` should be used
|
||||
attrs.accessibleToggleLabel = attrs.accessibleToggleLabel || app.translator.trans('core.forum.notifications.toggle_dropdown_accessible_label');
|
||||
|
||||
@@ -21,7 +23,7 @@ export default class NotificationsDropdown extends Dropdown {
|
||||
|
||||
vdom.attrs.title = this.attrs.label;
|
||||
|
||||
vdom.attrs.className += newNotifications ? ' new' : '';
|
||||
vdom.attrs.className = classList(vdom.attrs.className, [newNotifications && 'new']);
|
||||
vdom.attrs.onclick = this.onclick.bind(this);
|
||||
|
||||
return vdom;
|
||||
@@ -32,15 +34,15 @@ export default class NotificationsDropdown extends Dropdown {
|
||||
|
||||
return [
|
||||
icon(this.attrs.icon, { className: 'Button-icon' }),
|
||||
unread ? <span className="NotificationsDropdown-unread">{unread}</span> : '',
|
||||
unread !== 0 && <span className="NotificationsDropdown-unread">{unread}</span>,
|
||||
<span className="Button-label">{this.attrs.label}</span>,
|
||||
];
|
||||
}
|
||||
|
||||
getMenu() {
|
||||
return (
|
||||
<div className={'Dropdown-menu ' + this.attrs.menuClassName} onclick={this.menuClick.bind(this)}>
|
||||
{this.showing ? NotificationList.component({ state: this.attrs.state }) : ''}
|
||||
<div className={classList('Dropdown-menu', this.attrs.menuClassName)} onclick={this.menuClick.bind(this)}>
|
||||
{this.showing && NotificationList.component({ state: this.attrs.state })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -26,9 +26,10 @@ export default class PostStreamScrubber extends Component {
|
||||
const count = this.stream.count();
|
||||
|
||||
// Index is left blank for performance reasons, it is filled in in updateScubberValues
|
||||
const viewing = app.translator.transChoice('core.forum.post_scrubber.viewing_text', count, {
|
||||
const viewing = app.translator.trans('core.forum.post_scrubber.viewing_text', {
|
||||
count,
|
||||
index: <span className="Scrubber-index"></span>,
|
||||
count: <span className="Scrubber-count">{formatNumber(count)}</span>,
|
||||
formattedCount: <span className="Scrubber-count">{formatNumber(count)}</span>,
|
||||
});
|
||||
|
||||
const unreadCount = this.stream.discussion.unreadCount();
|
||||
|
@@ -98,7 +98,7 @@ export default class Search extends Component {
|
||||
onblur={() => (this.hasFocus = false)}
|
||||
/>
|
||||
{this.loadingSources ? (
|
||||
LoadingIndicator.component({ size: 'tiny', className: 'Button Button--icon Button--link' })
|
||||
<LoadingIndicator size="small" display="inline" containerClassName="Button Button--icon Button--link" />
|
||||
) : currentSearch ? (
|
||||
<button className="Search-clear Button Button--icon Button--link" onclick={this.clear.bind(this)}>
|
||||
{icon('fas fa-times-circle')}
|
||||
@@ -114,6 +114,15 @@ export default class Search extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
updateMaxHeight() {
|
||||
// Since extensions might add elements above the search box on mobile,
|
||||
// we need to calculate and set the max height dynamically.
|
||||
const resultsElementMargin = 14;
|
||||
const maxHeight =
|
||||
window.innerHeight - this.element.querySelector('.Search-input>.FormControl').getBoundingClientRect().bottom - resultsElementMargin;
|
||||
this.element.querySelector('.Search-results').style['max-height'] = `${maxHeight}px`;
|
||||
}
|
||||
|
||||
onupdate() {
|
||||
// Highlight the item that is currently selected.
|
||||
this.setIndex(this.getCurrentNumericIndex());
|
||||
@@ -121,12 +130,7 @@ export default class Search extends Component {
|
||||
// If there are no sources, the search view is not shown.
|
||||
if (!this.sources.length) return;
|
||||
|
||||
// Since extensions might add elements above the search box on mobile,
|
||||
// we need to calculate and set the max height dynamically.
|
||||
const resultsElementMargin = 14;
|
||||
const maxHeight =
|
||||
window.innerHeight - this.element.querySelector('.Search-input>.FormControl').getBoundingClientRect().bottom - resultsElementMargin;
|
||||
this.element.querySelector('.Search-results').style['max-height'] = `${maxHeight}px`;
|
||||
this.updateMaxHeight();
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
@@ -191,6 +195,13 @@ export default class Search extends Component {
|
||||
.one('mouseup', (e) => e.preventDefault())
|
||||
.select();
|
||||
});
|
||||
|
||||
this.updateMaxHeightHandler = this.updateMaxHeight.bind(this);
|
||||
window.addEventListener('resize', this.updateMaxHeightHandler);
|
||||
}
|
||||
|
||||
onremove() {
|
||||
window.removeEventListener('resize', this.updateMaxHeightHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -51,7 +51,7 @@ export default class UserPage extends Page {
|
||||
</div>
|
||||
</div>,
|
||||
]
|
||||
: [<LoadingIndicator className="LoadingIndicator--block" />]}
|
||||
: [<LoadingIndicator display="block" />]}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { throttle } from 'lodash-es';
|
||||
import { throttle } from 'throttle-debounce';
|
||||
import anchorScroll from '../../common/utils/anchorScroll';
|
||||
|
||||
class PostStreamState {
|
||||
@@ -50,8 +50,8 @@ class PostStreamState {
|
||||
*/
|
||||
this.forceUpdateScrubber = false;
|
||||
|
||||
this.loadNext = throttle(this._loadNext, 300);
|
||||
this.loadPrevious = throttle(this._loadPrevious, 300);
|
||||
this.loadNext = throttle(300, this._loadNext);
|
||||
this.loadPrevious = throttle(300, this._loadPrevious);
|
||||
|
||||
this.show(includedPosts);
|
||||
}
|
||||
|
@@ -11,7 +11,7 @@ import extractText from '../../common/utils/extractText';
|
||||
* The `DiscussionControls` utility constructs a list of buttons for a
|
||||
* discussion which perform actions on it.
|
||||
*/
|
||||
export default {
|
||||
const DiscussionControls = {
|
||||
/**
|
||||
* Get a list of controls for a discussion.
|
||||
*
|
||||
@@ -259,3 +259,5 @@ export default {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default DiscussionControls;
|
||||
|
@@ -8,7 +8,7 @@ import extractText from '../../common/utils/extractText';
|
||||
* The `PostControls` utility constructs a list of buttons for a post which
|
||||
* perform actions on it.
|
||||
*/
|
||||
export default {
|
||||
const PostControls = {
|
||||
/**
|
||||
* Get a list of controls for a post.
|
||||
*
|
||||
@@ -199,3 +199,5 @@ export default {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default PostControls;
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import Button from '../../common/components/Button';
|
||||
import Separator from '../../common/components/Separator';
|
||||
import EditUserModal from '../components/EditUserModal';
|
||||
import EditUserModal from '../../common/components/EditUserModal';
|
||||
import UserPage from '../components/UserPage';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
@@ -8,7 +8,7 @@ import ItemList from '../../common/utils/ItemList';
|
||||
* The `UserControls` utility constructs a list of buttons for a user which
|
||||
* perform actions on it.
|
||||
*/
|
||||
export default {
|
||||
const UserControls = {
|
||||
/**
|
||||
* Get a list of controls for a user.
|
||||
*
|
||||
@@ -141,3 +141,5 @@ export default {
|
||||
app.modal.show(EditUserModal, { user });
|
||||
},
|
||||
};
|
||||
|
||||
export default UserControls;
|
||||
|
@@ -24,4 +24,4 @@ module.exports = merge(config(), {
|
||||
});
|
||||
|
||||
module.exports['module'].rules[0].test = /\.(tsx?|js)$/;
|
||||
module.exports['module'].rules[0].use.options.presets.push('@babel/preset-typescript');
|
||||
module.exports['module'].rules[0].use[1].options.presets.push('@babel/preset-typescript');
|
||||
|
@@ -10,3 +10,4 @@
|
||||
@import "admin/ExtensionWidget";
|
||||
@import "admin/AppearancePage";
|
||||
@import "admin/MailPage";
|
||||
@import "admin/UsersListPage.less";
|
||||
|
@@ -9,7 +9,7 @@
|
||||
color: @muted-color;
|
||||
}
|
||||
|
||||
.AdminHeader-description {
|
||||
&-description {
|
||||
margin: 0;
|
||||
color: @control-color;
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
.ExtensionPage {
|
||||
|
||||
.ExtensionPage-header {
|
||||
&-header {
|
||||
.ExtensionTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -17,10 +17,28 @@
|
||||
margin: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
&Items {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionPage-header,
|
||||
.ExtensionPage-permissions-header {
|
||||
&-header,
|
||||
&-permissions-header {
|
||||
background: @control-bg;
|
||||
|
||||
h2 {
|
||||
@@ -60,40 +78,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -102,12 +86,12 @@
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ExtensionPage-headerTopItems {
|
||||
&TopItems {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (max-width: @screen-phone-max) {
|
||||
.ExtensionPage-headerTopItems {
|
||||
&TopItems {
|
||||
float: right;
|
||||
position: relative;
|
||||
}
|
||||
@@ -118,13 +102,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionPage-settings, .ExtensionPage-permissions {
|
||||
&-settings, &-permissions {
|
||||
.ExtensionPage-subHeader {
|
||||
margin: 5px 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionPage-settings {
|
||||
&-settings {
|
||||
margin-top: 20px;
|
||||
padding: 10px 0;
|
||||
|
||||
@@ -133,13 +117,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionPage-subHeader {
|
||||
&-subHeader {
|
||||
color: @muted-color;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
|
||||
.ExtensionPage-permissions {
|
||||
&-permissions {
|
||||
|
||||
.PermissionGrid-removeScope {
|
||||
display: none;
|
||||
@@ -150,9 +133,24 @@
|
||||
padding-bottom: 25vh;
|
||||
}
|
||||
|
||||
.ExtensionPage-permissions-header {
|
||||
&-header {
|
||||
margin: 20px 0 20px;
|
||||
padding: 5px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionInfo {
|
||||
margin-left: auto;
|
||||
|
||||
.item-authors {
|
||||
a {
|
||||
color: @muted-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionName {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
125
less/admin/UsersListPage.less
Normal file
125
less/admin/UsersListPage.less
Normal file
@@ -0,0 +1,125 @@
|
||||
.UserListPage {
|
||||
// Pad bottom of page to make nav area look less squashed
|
||||
padding-bottom: 24px;
|
||||
|
||||
&-grid {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
border-radius: @border-radius;
|
||||
|
||||
// Use CSS custom properties to define the number of columns in the grid
|
||||
grid-template-columns: repeat(var(--columns), max-content);
|
||||
|
||||
// Ensure mobile scrollbar isn't on top of content
|
||||
padding-bottom: 4px;
|
||||
|
||||
// Table refreshing overlay
|
||||
&--loadingPage {
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(128, 128, 128, 0.2);
|
||||
}
|
||||
|
||||
.LoadingIndicator-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&--loaded,
|
||||
&--loadingPage {
|
||||
display: grid;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
&-header {
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid @muted-more-color;
|
||||
padding: 8px 16px;
|
||||
background: @control-bg;
|
||||
}
|
||||
|
||||
&-rowItem {
|
||||
padding: 4px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&[data-column-name="editUser"] {
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&--shaded {
|
||||
background: darken(@body-bg, 3%);
|
||||
|
||||
& when (@config-dark-mode = true) {
|
||||
background: lighten(@body-bg, 5%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-gridPagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// Handles styling of default UserList columns
|
||||
.UserList {
|
||||
&-joinDate {
|
||||
cursor: help;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
|
||||
&-editModalBtn {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&-email {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
|
||||
&[data-email-shown="false"] {
|
||||
.UserList-emailAddress {
|
||||
user-select: none;
|
||||
filter: blur(4px);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&Address {
|
||||
flex-grow: 1;
|
||||
margin-right: 4px;
|
||||
transition: filter 0.2s ease-out;
|
||||
}
|
||||
|
||||
&IconBtn {
|
||||
margin-left: 12px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -90,14 +90,11 @@
|
||||
.Button-label {
|
||||
.transition(margin-right 0.1s);
|
||||
}
|
||||
.LoadingIndicator {
|
||||
|
||||
.LoadingIndicator-container {
|
||||
color: inherit;
|
||||
margin: 0 -5px 0 -15px;
|
||||
}
|
||||
&.loading {
|
||||
.Button-label {
|
||||
margin-right: 20px;
|
||||
}
|
||||
margin-top: -0.175em;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -3,17 +3,6 @@
|
||||
|
||||
.LoadingIndicator {
|
||||
@spin-time: 750ms;
|
||||
--size: 24px;
|
||||
--thickness: 2px;
|
||||
|
||||
&-container[data-size="large"] & {
|
||||
--size: 32px;
|
||||
--thickness: 3px;
|
||||
}
|
||||
|
||||
&-container[data-size="tiny"] & {
|
||||
--size: 18px;
|
||||
}
|
||||
|
||||
// Use the value of `color` to maintain backwards compatibility
|
||||
border-color: currentColor;
|
||||
@@ -30,6 +19,9 @@
|
||||
// <div> container around the spinner
|
||||
// Used for positioning
|
||||
&-container {
|
||||
--size: 24px;
|
||||
--thickness: 2px;
|
||||
|
||||
color: @muted-color;
|
||||
|
||||
// Center vertically and horizontally
|
||||
@@ -38,12 +30,26 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
// Size
|
||||
|
||||
&--large {
|
||||
--size: 32px;
|
||||
--thickness: 3px;
|
||||
}
|
||||
|
||||
&--small {
|
||||
--size: 18px;
|
||||
}
|
||||
|
||||
// Display types
|
||||
|
||||
&--block {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
&--inline {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -8,7 +8,8 @@
|
||||
&.focused {
|
||||
margin-left: -400px;
|
||||
|
||||
input, .Search-results {
|
||||
input,
|
||||
.Search-results {
|
||||
width: 400px;
|
||||
}
|
||||
}
|
||||
@@ -61,11 +62,21 @@
|
||||
.transition(all 0.4s);
|
||||
box-sizing: inherit !important;
|
||||
}
|
||||
|
||||
.LoadingIndicator-container {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.Button {
|
||||
float: left;
|
||||
margin-left: -36px;
|
||||
width: 36px !important;
|
||||
outline: none;
|
||||
width: 36px !important;
|
||||
|
||||
&.LoadingIndicator {
|
||||
width: var(--size) !important;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -17,6 +17,7 @@
|
||||
@import "Button";
|
||||
@import "Checkbox";
|
||||
@import "Dropdown";
|
||||
@import "EditUserModal";
|
||||
@import "Form";
|
||||
@import "FormControl";
|
||||
@import "LoadingIndicator";
|
||||
|
@@ -1,3 +1,4 @@
|
||||
@import "mixins/accessibility.less";
|
||||
@import "mixins/border-radius.less";
|
||||
@import "mixins/clearfix.less";
|
||||
@import "mixins/light-contents.less";
|
||||
|
100
less/common/mixins/accessibility.less
Normal file
100
less/common/mixins/accessibility.less
Normal file
@@ -0,0 +1,100 @@
|
||||
// This mixin should **only** be used in this file. If you want to define your own
|
||||
// custom outline style(s), override this mixin in your own theme extension, or in
|
||||
// the Custom Less section of the Admin dashboard.
|
||||
#private {
|
||||
.__focus-ring-styles() {
|
||||
// This uses the browser's default outline styles, rather than
|
||||
// using custom ones, which could introduce more issues
|
||||
|
||||
// Source: https://css-tricks.com/copy-the-browsers-native-focus-styles
|
||||
outline: 5px auto Highlight;
|
||||
outline: 5px auto -webkit-focus-ring-color;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a focus ring to an element.
|
||||
*
|
||||
* This is only shown when focus is provided via keyboard, using the
|
||||
* `:focus-visible` selector, and `:-moz-focusring` for older Firefox.
|
||||
*/
|
||||
.add-keyboard-focus-ring() {
|
||||
// We need to declare these separately, otherwise
|
||||
// browsers will ignore `:focus-visible` as they
|
||||
// don't understand `:-moz-focusring`
|
||||
|
||||
// These are the keyboard-only versions of :focus
|
||||
&:-moz-focusring {
|
||||
#private.__focus-ring-styles();
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
#private.__focus-ring-styles();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This mixin allows support for a custom focus
|
||||
* selector to be supplied.
|
||||
*
|
||||
* For example...
|
||||
*
|
||||
*? button { .addKeyboardFocusRing(":focus-within") }
|
||||
* becomes
|
||||
*? button:focus-within { <styles> }
|
||||
*
|
||||
* AND
|
||||
*
|
||||
*? button { .addKeyboardFocusRing(" :focus-within") }
|
||||
* becomes
|
||||
*? button :focus-within { <styles> }
|
||||
*/
|
||||
.add-keyboard-focus-ring(@customFocusSelector) {
|
||||
@realFocusSelector: ~"@{customFocusSelector}";
|
||||
|
||||
&@{realFocusSelector} {
|
||||
#private.__focus-ring-styles();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows an offset to be supplied for an a11y
|
||||
* outline.
|
||||
*
|
||||
* Useful for elements whose content is right up
|
||||
* against their bounds.
|
||||
*
|
||||
* `.addKeyboardFocusRingOffset(2px)` will add an
|
||||
* offset of 2 pixels to the outline.
|
||||
*/
|
||||
.add-keyboard-focus-ring-offset(@offset) {
|
||||
.offset() {
|
||||
outline-offset: @offset;
|
||||
}
|
||||
|
||||
&:-moz-focusring {
|
||||
.offset();
|
||||
}
|
||||
&:focus-visible {
|
||||
.offset();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows an offset to be supplied for an a11y
|
||||
* outline.
|
||||
*
|
||||
* Useful for elements whose content is right up
|
||||
* against their bounds.
|
||||
*/
|
||||
.add-keyboard-focus-ring-offset(@customSelector, @offset) {
|
||||
.offset() {
|
||||
outline-offset: @offset;
|
||||
}
|
||||
|
||||
@realFocusSelector: ~"@{customFocusSelector}";
|
||||
|
||||
&@{realFocusSelector} {
|
||||
.offset();
|
||||
}
|
||||
}
|
@@ -7,7 +7,6 @@
|
||||
@import "forum/DiscussionList";
|
||||
@import "forum/DiscussionListItem";
|
||||
@import "forum/DiscussionPage";
|
||||
@import "forum/EditUserModal";
|
||||
@import "forum/Hero";
|
||||
@import "forum/IndexPage";
|
||||
@import "forum/LogInButton";
|
||||
|
@@ -23,7 +23,7 @@
|
||||
&.dragover .Dropdown-toggle {
|
||||
opacity: 1;
|
||||
}
|
||||
.LoadingIndicator {
|
||||
.LoadingIndicator-container {
|
||||
color: #fff;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
|
@@ -92,7 +92,7 @@
|
||||
border-radius: @border-radius @border-radius 0 0;
|
||||
|
||||
&.active {
|
||||
display: block;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
.ComposerBody-editor {
|
||||
|
@@ -1,48 +1,60 @@
|
||||
|
||||
.NotificationList {
|
||||
overflow: hidden;
|
||||
& .loading-indicator {
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.NotificationList-header {
|
||||
@media @tablet-up {
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid @control-bg;
|
||||
&-header {
|
||||
@media @tablet-up {
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid @control-bg;
|
||||
|
||||
h4 {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
color: @muted-color;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h4 {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
color: @muted-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
.Button {
|
||||
float: right;
|
||||
margin-top: -11px;
|
||||
margin-right: -11px;
|
||||
|
||||
// The NotificationList may be displayed inside of the drawer as a
|
||||
// dropdown menu – but the drawer may have .light-contents() applied to
|
||||
// it. In this case we will need to reset the button's styles back to
|
||||
// normal.
|
||||
& when (@config-colored-header = true) {
|
||||
.Button--color(@control-color, @control-bg);
|
||||
// Mark all as read button
|
||||
.Button {
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: @link-color;
|
||||
// The NotificationList may be displayed inside of the drawer as a
|
||||
// dropdown menu – but the drawer may have .light-contents() applied to
|
||||
// it. In this case we will need to reset the button's styles back to
|
||||
// normal.
|
||||
& when (@config-colored-header = true) {
|
||||
color: @control-color;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: @link-color;
|
||||
}
|
||||
}
|
||||
|
||||
.add-keyboard-focus-ring();
|
||||
.add-keyboard-focus-ring-offset(4px);
|
||||
|
||||
.icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Message displayed when notifications are empty
|
||||
&-empty {
|
||||
color: @muted-color;
|
||||
text-align: center;
|
||||
padding: 50px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
.NotificationList-empty {
|
||||
color: @muted-color;
|
||||
text-align: center;
|
||||
padding: 50px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.NotificationGroup {
|
||||
border-top: 1px solid @control-bg;
|
||||
margin-top: -1px;
|
||||
@@ -50,99 +62,143 @@
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
.NotificationGroup-header {
|
||||
font-weight: bold;
|
||||
color: @heading-color !important;
|
||||
padding: 6px 15px;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.NotificationGroup-badges {
|
||||
margin-left: -2px;
|
||||
margin-right: 18px;
|
||||
vertical-align: 1px;
|
||||
|
||||
.Badge {
|
||||
margin-right: -13px;
|
||||
position: relative;
|
||||
.Badge--size(21px);
|
||||
&-header {
|
||||
font-weight: bold;
|
||||
color: @heading-color !important;
|
||||
padding: 8px 16px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
// Prevent outline overflowing parent
|
||||
.add-keyboard-focus-ring-offset(-1px);
|
||||
}
|
||||
|
||||
&-badges {
|
||||
@overlap: 13px;
|
||||
|
||||
margin-right: 8px;
|
||||
padding-right: @overlap;
|
||||
|
||||
.Badge {
|
||||
margin-right: -@overlap;
|
||||
position: relative;
|
||||
.Badge--size(21px);
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
.NotificationGroup-content {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.Notification {
|
||||
display: block;
|
||||
padding: 8px 15px 8px 70px;
|
||||
padding: 8px 16px;
|
||||
color: @muted-color !important; // required to override .light-contents applied to header
|
||||
overflow: hidden;
|
||||
|
||||
.unread& {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr auto;
|
||||
|
||||
grid-template-areas:
|
||||
"avatar icon title button"
|
||||
"x x excerpt excerpt";
|
||||
|
||||
align-items: baseline;
|
||||
row-gap: 1px;
|
||||
column-gap: 6px;
|
||||
|
||||
// Prevent outline overflowing parent
|
||||
.add-keyboard-focus-ring-offset(-1px);
|
||||
|
||||
&.unread {
|
||||
background: @control-bg;
|
||||
}
|
||||
&:hover {
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
text-decoration: none;
|
||||
background: @control-bg;
|
||||
|
||||
|
||||
.Notification-action {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.Avatar {
|
||||
.Avatar--size(24px);
|
||||
float: left;
|
||||
margin: -2px 0 -2px -55px;
|
||||
grid-area: avatar;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
font-size: 14px;
|
||||
grid-area: icon;
|
||||
}
|
||||
|
||||
&-title {
|
||||
grid-area: title;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
&-content {
|
||||
line-height: 19px;
|
||||
margin-right: 8px;
|
||||
|
||||
.username {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
time {
|
||||
line-height: inherit;
|
||||
font-size: 11px;
|
||||
line-height: 19px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.Notification-action {
|
||||
float: right;
|
||||
display: none;
|
||||
margin-top: -7px;
|
||||
margin-right: -10px;
|
||||
&-action {
|
||||
line-height: inherit;
|
||||
padding: 5px 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
|
||||
& when (@config-colored-header = true) {
|
||||
.Button--color(@control-color, @control-bg);
|
||||
.add-keyboard-focus-ring();
|
||||
.add-keyboard-focus-ring-offset(4px);
|
||||
|
||||
&:hover {
|
||||
grid-area: button;
|
||||
|
||||
// Needs more specificity to fix hover/focus styles not applying in dropdown
|
||||
.Notification & when (@config-colored-header = true) {
|
||||
color: @control-color;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: @link-color;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.Notification-icon {
|
||||
float: left;
|
||||
margin-left: -23px;
|
||||
font-size: 14px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.Notification-content {
|
||||
margin-right: 5px;
|
||||
|
||||
.username {
|
||||
font-weight: bold;
|
||||
&-excerpt {
|
||||
grid-area: excerpt;
|
||||
color: @muted-more-color;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
.Notification-excerpt {
|
||||
color: @muted-more-color;
|
||||
font-size: 11px;
|
||||
margin-top: 3px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
@@ -17,7 +17,7 @@ core:
|
||||
custom_header_heading: Custom Header
|
||||
custom_header_text: => core.ref.custom_header_text
|
||||
custom_styles_heading: Custom Styles
|
||||
custom_styles_text: Customize your forum's appearance by adding your own LESS/CSS code to be applied on top of Flarum's default styles.
|
||||
custom_styles_text: Customize your forum's appearance by adding your own Less/CSS code to be applied on top of Flarum's default styles.
|
||||
dark_mode_label: Dark Mode
|
||||
description: "Customize your forum's colors, logos, and other variables."
|
||||
edit_css_button: Edit Custom CSS
|
||||
@@ -58,7 +58,7 @@ core:
|
||||
|
||||
# These translations are used in the Edit Custom CSS modal dialog.
|
||||
edit_css:
|
||||
customize_text: "Customize your forum's appearance by adding your own LESS/CSS code to be applied on top of Flarum's <a>default styles</a>."
|
||||
customize_text: "Customize your forum's appearance by adding your own Less/CSS code to be applied on top of Flarum's <a>default styles</a>."
|
||||
submit_button: => core.ref.save_changes
|
||||
title: Edit Custom CSS
|
||||
|
||||
@@ -163,6 +163,8 @@ core:
|
||||
email_title: => core.admin.email.description
|
||||
permissions_button: => core.admin.permissions.title
|
||||
permissions_title: => core.admin.permissions.description
|
||||
userlist_button: => core.admin.users.title
|
||||
userlist_title: => core.admin.users.description
|
||||
search_placeholder: Search Extensions
|
||||
|
||||
# These translations are used in the Permissions page of the admin interface.
|
||||
@@ -199,7 +201,7 @@ core:
|
||||
# These translations are used in the dropdown menus on the Permissions page.
|
||||
permissions_controls:
|
||||
allow_indefinitely_button: Indefinitely
|
||||
allow_some_minutes_button: "For {count} minute|For {count} minutes"
|
||||
allow_some_minutes_button: "{count, plural, one {For # minute} other {For # minutes}}"
|
||||
allow_ten_minutes_button: For 10 minutes
|
||||
allow_until_reply_button: Until next reply
|
||||
everyone_button: Everyone
|
||||
@@ -217,6 +219,46 @@ core:
|
||||
remove_button: => core.ref.remove
|
||||
upload_button: Choose an Image...
|
||||
|
||||
# These translations are used for the users list on the admin dashboard.
|
||||
users:
|
||||
description: A paginated list of all users on your forum.
|
||||
|
||||
grid:
|
||||
columns:
|
||||
edit_user:
|
||||
button: => core.ref.edit
|
||||
title: => core.ref.edit_user
|
||||
tooltip: Edit {username}
|
||||
|
||||
email:
|
||||
title: => core.ref.email
|
||||
visibility_hide: Hide email address
|
||||
visibility_show: Show email address
|
||||
|
||||
group_badges:
|
||||
no_badges: None
|
||||
title: Groups
|
||||
|
||||
join_time:
|
||||
title: Joined
|
||||
|
||||
user_id:
|
||||
title: ID
|
||||
|
||||
username:
|
||||
profile_link_tooltip: Visit {username}'s profile
|
||||
title: => core.ref.username
|
||||
|
||||
invalid_column_content: Invalid
|
||||
|
||||
pagination:
|
||||
back_button: Previous page
|
||||
next_button: Next page
|
||||
page_counter: Page {current} of {total}
|
||||
|
||||
title: => core.ref.users
|
||||
total_users: "Total users: {count}"
|
||||
|
||||
# Translations in this namespace are used by the forum user interface.
|
||||
forum:
|
||||
|
||||
@@ -288,21 +330,6 @@ core:
|
||||
replied_text: "{username} replied {ago}"
|
||||
started_text: "{username} started {ago}"
|
||||
|
||||
# These translations are used in the Edit User modal dialog (admin function).
|
||||
edit_user:
|
||||
activate_button: Activate User
|
||||
email_heading: => core.ref.email
|
||||
email_label: => core.ref.email
|
||||
groups_heading: Groups
|
||||
nothing_available: There is nothing available for you to edit at this time.
|
||||
password_heading: => core.ref.password
|
||||
password_label: => core.ref.password
|
||||
set_password_label: Set new password
|
||||
submit_button: => core.ref.save_changes
|
||||
title: Edit User
|
||||
username_heading: => core.ref.username
|
||||
username_label: => core.ref.username
|
||||
|
||||
# These translations are used in the Forgot Password modal dialog.
|
||||
forgot_password:
|
||||
dismiss_button: => core.ref.okay
|
||||
@@ -388,7 +415,7 @@ core:
|
||||
now_link: Now
|
||||
original_post_link: Original Post
|
||||
unread_text: "{count} unread"
|
||||
viewing_text: "{index} of {count} post|{index} of {count} posts"
|
||||
viewing_text: "{count, plural, one {{index} of {formattedCount} post} other {{index} of {formattedCount} posts}}"
|
||||
|
||||
# These translations are displayed between posts in the post stream.
|
||||
post_stream:
|
||||
@@ -474,6 +501,20 @@ core:
|
||||
dropdown:
|
||||
toggle_dropdown_accessible_label: Toggle dropdown menu
|
||||
|
||||
# These translations are used in the Edit User modal dialog (admin function).
|
||||
edit_user:
|
||||
activate_button: Activate User
|
||||
email_heading: => core.ref.email
|
||||
email_label: => core.ref.email
|
||||
groups_heading: Groups
|
||||
password_heading: => core.ref.password
|
||||
password_label: => core.ref.password
|
||||
set_password_label: Set new password
|
||||
submit_button: => core.ref.save_changes
|
||||
title: => core.ref.edit_user
|
||||
username_heading: => core.ref.username
|
||||
username_label: => core.ref.username
|
||||
|
||||
# These translations are displayed as error messages.
|
||||
error:
|
||||
dependent_extensions_message: "Cannot disable {extension} until the following dependent extensions are disabled: {extensions}"
|
||||
@@ -483,6 +524,10 @@ core:
|
||||
permission_denied_message: You do not have permission to do that.
|
||||
rate_limit_exceeded_message: You're going a little too quickly. Please try again in a few seconds.
|
||||
|
||||
# These translations are used in the loading indicator component.
|
||||
loading_indicator:
|
||||
accessible_label: => core.ref.loading
|
||||
|
||||
# These translations are used as suffixes when abbreviating numbers.
|
||||
number_suffix:
|
||||
kilo_text: K
|
||||
@@ -505,7 +550,7 @@ core:
|
||||
content:
|
||||
javascript_disabled_message: This site is best viewed in a modern browser with JavaScript enabled.
|
||||
load_error_message: Something went wrong while trying to load the full version of this site. Try hard-refreshing this page to fix the error.
|
||||
loading_text: Loading...
|
||||
loading_text: => core.ref.loading
|
||||
|
||||
# Translations in this namespace are displayed in the basic HTML discussion view.
|
||||
discussion:
|
||||
@@ -620,10 +665,12 @@ core:
|
||||
delete_forever: Delete Forever
|
||||
discussions: Discussions # Referenced by flarum-statistics.yml
|
||||
edit: Edit
|
||||
edit_user: Edit User
|
||||
email: Email
|
||||
icon: Icon
|
||||
icon_text: "Enter the name of any <a>FontAwesome</a> icon class, <em>including</em> the <code>fas fa-</code> prefix."
|
||||
load_more: Load More
|
||||
loading: Loading...
|
||||
log_in: Log In
|
||||
log_out: Log Out
|
||||
mark_all_as_read: Mark All as Read
|
||||
@@ -641,7 +688,7 @@ core:
|
||||
save_changes: Save Changes # Referenced by flarum-suspend.yml, flarum-tags.yml
|
||||
settings: Settings
|
||||
sign_up: Sign Up
|
||||
some_others: "{count} other|{count} others" # Referenced by flarum-likes.yml, flarum-mentions.yml
|
||||
some_others: "{count, plural, one {# other} other {# others}}" # Referenced by flarum-likes.yml, flarum-mentions.yml
|
||||
start_a_discussion: Start a Discussion
|
||||
username: Username
|
||||
users: Users # Referenced by flarum-statistics.yml
|
||||
|
@@ -3,9 +3,9 @@ validation:
|
||||
active_url: "The :attribute is not a valid URL."
|
||||
after: "The :attribute must be a date after :date."
|
||||
after_or_equal: "The :attribute must be a date after or equal to :date."
|
||||
alpha: "The :attribute may only contain letters."
|
||||
alpha_dash: "The :attribute may only contain letters, numbers, dashes and underscores."
|
||||
alpha_num: "The :attribute may only contain letters and numbers."
|
||||
alpha: "The :attribute must only contain letters."
|
||||
alpha_dash: "The :attribute must only contain letters, numbers, dashes and underscores."
|
||||
alpha_num: "The :attribute must only contain letters and numbers."
|
||||
array: "The :attribute must be an array."
|
||||
before: "The :attribute must be a date before :date."
|
||||
before_or_equal: "The :attribute must be a date before or equal to :date."
|
||||
@@ -58,10 +58,10 @@ validation:
|
||||
string: "The :attribute must be less than or equal :value characters."
|
||||
array: "The :attribute must not have more than :value items."
|
||||
max:
|
||||
numeric: "The :attribute may not be greater than :max."
|
||||
file: "The :attribute may not be greater than :max kilobytes."
|
||||
string: "The :attribute may not be greater than :max characters."
|
||||
array: "The :attribute may not have more than :max items."
|
||||
numeric: "The :attribute must not be greater than :max."
|
||||
file: "The :attribute must not be greater than :max kilobytes."
|
||||
string: "The :attribute must not be greater than :max characters."
|
||||
array: "The :attribute must not have more than :max items."
|
||||
mimes: "The :attribute must be a file of type: :values."
|
||||
mimetypes: "The :attribute must be a file of type: :values."
|
||||
min:
|
||||
@@ -69,6 +69,7 @@ validation:
|
||||
file: "The :attribute must be at least :min kilobytes."
|
||||
string: "The :attribute must be at least :min characters."
|
||||
array: "The :attribute must have at least :min items."
|
||||
multiple_of: "The :attribute must be a multiple of :value."
|
||||
not_in: "The selected :attribute is invalid."
|
||||
not_regex: "The :attribute format is invalid."
|
||||
numeric: "The :attribute must be a number."
|
||||
@@ -82,6 +83,9 @@ validation:
|
||||
required_with_all: "The :attribute field is required when :values are present."
|
||||
required_without: "The :attribute field is required when :values is not present."
|
||||
required_without_all: "The :attribute field is required when none of :values are present."
|
||||
prohibited: "The :attribute field is prohibited."
|
||||
prohibited_if: "The :attribute field is prohibited when :other is :value."
|
||||
prohibited_unless: "The :attribute field is prohibited unless :other is in :values."
|
||||
same: "The :attribute and :other must match."
|
||||
size:
|
||||
numeric: "The :attribute must be :size."
|
||||
|
@@ -0,0 +1,27 @@
|
||||
<?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.
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Schema\Builder;
|
||||
|
||||
return [
|
||||
'up' => function (Builder $schema) {
|
||||
if (! $schema->hasColumn('migrations', 'id')) {
|
||||
$schema->table('migrations', function (Blueprint $table) {
|
||||
$table->increments('id')->first();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
'down' => function (Builder $schema) {
|
||||
$schema->table('migrations', function (Blueprint $table) {
|
||||
$table->dropColumn('id');
|
||||
});
|
||||
}
|
||||
];
|
@@ -27,6 +27,7 @@ use Flarum\Http\RouteHandlerFactory;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Locale\LocaleManager;
|
||||
use Flarum\Settings\Event\Saved;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Laminas\Stratigility\MiddlewarePipe;
|
||||
|
||||
class AdminServiceProvider extends AbstractServiceProvider
|
||||
@@ -36,8 +37,8 @@ class AdminServiceProvider extends AbstractServiceProvider
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$this->container->extend(UrlGenerator::class, function (UrlGenerator $url) {
|
||||
return $url->addCollection('admin', $this->container->make('flarum.admin.routes'), 'admin');
|
||||
$this->container->extend(UrlGenerator::class, function (UrlGenerator $url, Container $container) {
|
||||
return $url->addCollection('admin', $container->make('flarum.admin.routes'), 'admin');
|
||||
});
|
||||
|
||||
$this->container->singleton('flarum.admin.routes', function () {
|
||||
@@ -58,27 +59,29 @@ class AdminServiceProvider extends AbstractServiceProvider
|
||||
HttpMiddleware\SetLocale::class,
|
||||
'flarum.admin.route_resolver',
|
||||
HttpMiddleware\CheckCsrfToken::class,
|
||||
Middleware\RequireAdministrateAbility::class
|
||||
Middleware\RequireAdministrateAbility::class,
|
||||
HttpMiddleware\ReferrerPolicyHeader::class,
|
||||
HttpMiddleware\ContentTypeOptionsHeader::class
|
||||
];
|
||||
});
|
||||
|
||||
$this->container->bind('flarum.admin.error_handler', function () {
|
||||
$this->container->bind('flarum.admin.error_handler', function (Container $container) {
|
||||
return new HttpMiddleware\HandleErrors(
|
||||
$this->container->make(Registry::class),
|
||||
$this->container['flarum.config']->inDebugMode() ? $this->container->make(WhoopsFormatter::class) : $this->container->make(ViewFormatter::class),
|
||||
$this->container->tagged(Reporter::class)
|
||||
$container->make(Registry::class),
|
||||
$container['flarum.config']->inDebugMode() ? $container->make(WhoopsFormatter::class) : $container->make(ViewFormatter::class),
|
||||
$container->tagged(Reporter::class)
|
||||
);
|
||||
});
|
||||
|
||||
$this->container->bind('flarum.admin.route_resolver', function () {
|
||||
return new HttpMiddleware\ResolveRoute($this->container->make('flarum.admin.routes'));
|
||||
$this->container->bind('flarum.admin.route_resolver', function (Container $container) {
|
||||
return new HttpMiddleware\ResolveRoute($container->make('flarum.admin.routes'));
|
||||
});
|
||||
|
||||
$this->container->singleton('flarum.admin.handler', function () {
|
||||
$this->container->singleton('flarum.admin.handler', function (Container $container) {
|
||||
$pipe = new MiddlewarePipe;
|
||||
|
||||
foreach ($this->container->make('flarum.admin.middleware') as $middleware) {
|
||||
$pipe->pipe($this->container->make($middleware));
|
||||
foreach ($container->make('flarum.admin.middleware') as $middleware) {
|
||||
$pipe->pipe($container->make($middleware));
|
||||
}
|
||||
|
||||
$pipe->pipe(new HttpMiddleware\ExecuteRoute());
|
||||
@@ -86,9 +89,9 @@ class AdminServiceProvider extends AbstractServiceProvider
|
||||
return $pipe;
|
||||
});
|
||||
|
||||
$this->container->bind('flarum.assets.admin', function () {
|
||||
$this->container->bind('flarum.assets.admin', function (Container $container) {
|
||||
/** @var \Flarum\Frontend\Assets $assets */
|
||||
$assets = $this->container->make('flarum.assets.factory')('admin');
|
||||
$assets = $container->make('flarum.assets.factory')('admin');
|
||||
|
||||
$assets->js(function (SourceCollector $sources) {
|
||||
$sources->addFile(__DIR__.'/../../js/dist/admin.js');
|
||||
@@ -98,17 +101,17 @@ class AdminServiceProvider extends AbstractServiceProvider
|
||||
$sources->addFile(__DIR__.'/../../less/admin.less');
|
||||
});
|
||||
|
||||
$this->container->make(AddTranslations::class)->forFrontend('admin')->to($assets);
|
||||
$this->container->make(AddLocaleAssets::class)->to($assets);
|
||||
$container->make(AddTranslations::class)->forFrontend('admin')->to($assets);
|
||||
$container->make(AddLocaleAssets::class)->to($assets);
|
||||
|
||||
return $assets;
|
||||
});
|
||||
|
||||
$this->container->bind('flarum.frontend.admin', function () {
|
||||
$this->container->bind('flarum.frontend.admin', function (Container $container) {
|
||||
/** @var \Flarum\Frontend\Frontend $frontend */
|
||||
$frontend = $this->container->make('flarum.frontend.factory')('admin');
|
||||
$frontend = $container->make('flarum.frontend.factory')('admin');
|
||||
|
||||
$frontend->content($this->container->make(Content\AdminPayload::class));
|
||||
$frontend->content($container->make(Content\AdminPayload::class));
|
||||
|
||||
return $frontend;
|
||||
});
|
||||
|
@@ -14,6 +14,7 @@ use Flarum\Frontend\Document;
|
||||
use Flarum\Group\Permission;
|
||||
use Flarum\Settings\Event\Deserializing;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Flarum\User\User;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
@@ -81,5 +82,18 @@ class AdminPayload
|
||||
|
||||
$document->payload['phpVersion'] = PHP_VERSION;
|
||||
$document->payload['mysqlVersion'] = $this->db->selectOne('select version() as version')->version;
|
||||
|
||||
/**
|
||||
* Used in the admin user list. Implemented as this as it matches the API in flarum/statistics.
|
||||
* If flarum/statistics ext is enabled, it will override this data with its own stats.
|
||||
*
|
||||
* This allows the front-end code to be simpler and use one single source of truth to pull the
|
||||
* total user count from.
|
||||
*/
|
||||
$document->payload['modelStatistics'] = [
|
||||
'users' => [
|
||||
'total' => User::count()
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@@ -21,6 +21,7 @@ use Flarum\Http\Middleware as HttpMiddleware;
|
||||
use Flarum\Http\RouteCollection;
|
||||
use Flarum\Http\RouteHandlerFactory;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Laminas\Stratigility\MiddlewarePipe;
|
||||
|
||||
class ApiServiceProvider extends AbstractServiceProvider
|
||||
@@ -30,8 +31,8 @@ class ApiServiceProvider extends AbstractServiceProvider
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$this->container->extend(UrlGenerator::class, function (UrlGenerator $url) {
|
||||
return $url->addCollection('api', $this->container->make('flarum.api.routes'), 'api');
|
||||
$this->container->extend(UrlGenerator::class, function (UrlGenerator $url, Container $container) {
|
||||
return $url->addCollection('api', $container->make('flarum.api.routes'), 'api');
|
||||
});
|
||||
|
||||
$this->container->singleton('flarum.api.routes', function () {
|
||||
@@ -51,7 +52,7 @@ class ApiServiceProvider extends AbstractServiceProvider
|
||||
];
|
||||
});
|
||||
|
||||
$this->container->bind(Middleware\ThrottleApi::class, function ($container) {
|
||||
$this->container->bind(Middleware\ThrottleApi::class, function (Container $container) {
|
||||
return new Middleware\ThrottleApi($container->make('flarum.api.throttlers'));
|
||||
});
|
||||
|
||||
@@ -72,23 +73,23 @@ class ApiServiceProvider extends AbstractServiceProvider
|
||||
];
|
||||
});
|
||||
|
||||
$this->container->bind('flarum.api.error_handler', function () {
|
||||
$this->container->bind('flarum.api.error_handler', function (Container $container) {
|
||||
return new HttpMiddleware\HandleErrors(
|
||||
$this->container->make(Registry::class),
|
||||
new JsonApiFormatter($this->container['flarum.config']->inDebugMode()),
|
||||
$this->container->tagged(Reporter::class)
|
||||
$container->make(Registry::class),
|
||||
new JsonApiFormatter($container['flarum.config']->inDebugMode()),
|
||||
$container->tagged(Reporter::class)
|
||||
);
|
||||
});
|
||||
|
||||
$this->container->bind('flarum.api.route_resolver', function () {
|
||||
return new HttpMiddleware\ResolveRoute($this->container->make('flarum.api.routes'));
|
||||
$this->container->bind('flarum.api.route_resolver', function (Container $container) {
|
||||
return new HttpMiddleware\ResolveRoute($container->make('flarum.api.routes'));
|
||||
});
|
||||
|
||||
$this->container->singleton('flarum.api.handler', function () {
|
||||
$this->container->singleton('flarum.api.handler', function (Container $container) {
|
||||
$pipe = new MiddlewarePipe;
|
||||
|
||||
foreach ($this->container->make('flarum.api.middleware') as $middleware) {
|
||||
$pipe->pipe($this->container->make($middleware));
|
||||
$pipe->pipe($container->make($middleware));
|
||||
}
|
||||
|
||||
$pipe->pipe(new HttpMiddleware\ExecuteRoute());
|
||||
@@ -106,13 +107,13 @@ class ApiServiceProvider extends AbstractServiceProvider
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function boot()
|
||||
public function boot(Container $container)
|
||||
{
|
||||
$this->setNotificationSerializers();
|
||||
|
||||
AbstractSerializeController::setContainer($this->container);
|
||||
AbstractSerializeController::setContainer($container);
|
||||
|
||||
AbstractSerializer::setContainer($this->container);
|
||||
AbstractSerializer::setContainer($container);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -11,8 +11,9 @@ namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Illuminate\Contracts\Filesystem\Factory;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use League\Flysystem\FilesystemInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
class DeleteFaviconController extends AbstractDeleteController
|
||||
@@ -23,18 +24,18 @@ class DeleteFaviconController extends AbstractDeleteController
|
||||
protected $settings;
|
||||
|
||||
/**
|
||||
* @var FilesystemInterface
|
||||
* @var Filesystem
|
||||
*/
|
||||
protected $uploadDir;
|
||||
|
||||
/**
|
||||
* @param SettingsRepositoryInterface $settings
|
||||
* @param FilesystemInterface $uploadDir
|
||||
* @param Factory $filesystemFactory
|
||||
*/
|
||||
public function __construct(SettingsRepositoryInterface $settings, FilesystemInterface $uploadDir)
|
||||
public function __construct(SettingsRepositoryInterface $settings, Factory $filesystemFactory)
|
||||
{
|
||||
$this->settings = $settings;
|
||||
$this->uploadDir = $uploadDir;
|
||||
$this->uploadDir = $filesystemFactory->disk('flarum-assets');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,7 +49,7 @@ class DeleteFaviconController extends AbstractDeleteController
|
||||
|
||||
$this->settings->set('favicon_path', null);
|
||||
|
||||
if ($this->uploadDir->has($path)) {
|
||||
if ($this->uploadDir->exists($path)) {
|
||||
$this->uploadDir->delete($path);
|
||||
}
|
||||
|
||||
|
@@ -11,8 +11,9 @@ namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Illuminate\Contracts\Filesystem\Factory;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use League\Flysystem\FilesystemInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
class DeleteLogoController extends AbstractDeleteController
|
||||
@@ -23,18 +24,18 @@ class DeleteLogoController extends AbstractDeleteController
|
||||
protected $settings;
|
||||
|
||||
/**
|
||||
* @var FilesystemInterface
|
||||
* @var Filesystem
|
||||
*/
|
||||
protected $uploadDir;
|
||||
|
||||
/**
|
||||
* @param SettingsRepositoryInterface $settings
|
||||
* @param FilesystemInterface $uploadDir
|
||||
* @param Factory $filesystemFactory
|
||||
*/
|
||||
public function __construct(SettingsRepositoryInterface $settings, FilesystemInterface $uploadDir)
|
||||
public function __construct(SettingsRepositoryInterface $settings, Factory $filesystemFactory)
|
||||
{
|
||||
$this->settings = $settings;
|
||||
$this->uploadDir = $uploadDir;
|
||||
$this->uploadDir = $filesystemFactory->disk('flarum-assets');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,7 +49,7 @@ class DeleteLogoController extends AbstractDeleteController
|
||||
|
||||
$this->settings->set('logo_path', null);
|
||||
|
||||
if ($this->uploadDir->has($path)) {
|
||||
if ($this->uploadDir->exists($path)) {
|
||||
$this->uploadDir->delete($path);
|
||||
}
|
||||
|
||||
|
@@ -10,7 +10,6 @@
|
||||
namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Illuminate\Container\Container;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Mail\Message;
|
||||
use Laminas\Diactoros\Response\EmptyResponse;
|
||||
@@ -21,15 +20,12 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class SendTestMailController implements RequestHandlerInterface
|
||||
{
|
||||
protected $container;
|
||||
|
||||
protected $mailer;
|
||||
|
||||
protected $translator;
|
||||
|
||||
public function __construct(Container $container, Mailer $mailer, TranslatorInterface $translator)
|
||||
public function __construct(Mailer $mailer, TranslatorInterface $translator)
|
||||
{
|
||||
$this->container = $container;
|
||||
$this->mailer = $mailer;
|
||||
$this->translator = $translator;
|
||||
}
|
||||
@@ -39,7 +35,7 @@ class SendTestMailController implements RequestHandlerInterface
|
||||
$actor = RequestUtil::getActor($request);
|
||||
$actor->assertAdmin();
|
||||
|
||||
$body = $this->translator->trans('core.email.send_test.body', ['{username}' => $actor->username]);
|
||||
$body = $this->translator->trans('core.email.send_test.body', ['username' => $actor->username]);
|
||||
|
||||
$this->mailer->raw($body, function (Message $message) use ($actor) {
|
||||
$message->to($actor->email);
|
||||
|
@@ -11,10 +11,11 @@ namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Illuminate\Contracts\Filesystem\Factory;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
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;
|
||||
@@ -27,7 +28,7 @@ abstract class UploadImageController extends ShowForumController
|
||||
protected $settings;
|
||||
|
||||
/**
|
||||
* @var FilesystemInterface
|
||||
* @var Filesystem
|
||||
*/
|
||||
protected $uploadDir;
|
||||
|
||||
@@ -48,12 +49,12 @@ abstract class UploadImageController extends ShowForumController
|
||||
|
||||
/**
|
||||
* @param SettingsRepositoryInterface $settings
|
||||
* @param FilesystemInterface $uploadDir
|
||||
* @param Factory $filesystemFactory
|
||||
*/
|
||||
public function __construct(SettingsRepositoryInterface $settings, FilesystemInterface $uploadDir)
|
||||
public function __construct(SettingsRepositoryInterface $settings, Factory $filesystemFactory)
|
||||
{
|
||||
$this->settings = $settings;
|
||||
$this->uploadDir = $uploadDir;
|
||||
$this->uploadDir = $filesystemFactory->disk('flarum-assets');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,7 +74,7 @@ abstract class UploadImageController extends ShowForumController
|
||||
|
||||
$uploadName = $this->filenamePrefix.'-'.Str::lower(Str::random(8)).'.'.$this->fileExtension;
|
||||
|
||||
$this->uploadDir->write($uploadName, $encodedImage);
|
||||
$this->uploadDir->put($uploadName, $encodedImage);
|
||||
|
||||
$this->settings->set($this->filePathSettingKey, $uploadName);
|
||||
|
||||
|
@@ -13,6 +13,8 @@ use Flarum\Foundation\Application;
|
||||
use Flarum\Foundation\Config;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Illuminate\Contracts\Filesystem\Cloud;
|
||||
use Illuminate\Contracts\Filesystem\Factory;
|
||||
|
||||
class ForumSerializer extends AbstractSerializer
|
||||
{
|
||||
@@ -36,14 +38,21 @@ class ForumSerializer extends AbstractSerializer
|
||||
*/
|
||||
protected $url;
|
||||
|
||||
/**
|
||||
* @var Cloud
|
||||
*/
|
||||
protected $assetsFilesystem;
|
||||
|
||||
/**
|
||||
* @param Config $config
|
||||
* @param Factory $filesystemFactory
|
||||
* @param SettingsRepositoryInterface $settings
|
||||
* @param UrlGenerator $url
|
||||
*/
|
||||
public function __construct(Config $config, SettingsRepositoryInterface $settings, UrlGenerator $url)
|
||||
public function __construct(Config $config, Factory $filesystemFactory, SettingsRepositoryInterface $settings, UrlGenerator $url)
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->assetsFilesystem = $filesystemFactory->disk('flarum-assets');
|
||||
$this->settings = $settings;
|
||||
$this->url = $url;
|
||||
}
|
||||
@@ -107,7 +116,7 @@ class ForumSerializer extends AbstractSerializer
|
||||
{
|
||||
$logoPath = $this->settings->get('logo_path');
|
||||
|
||||
return $logoPath ? $this->url->to('forum')->path('assets/'.$logoPath) : null;
|
||||
return $logoPath ? $this->getAssetUrl($logoPath) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,6 +126,11 @@ class ForumSerializer extends AbstractSerializer
|
||||
{
|
||||
$faviconPath = $this->settings->get('favicon_path');
|
||||
|
||||
return $faviconPath ? $this->url->to('forum')->path('assets/'.$faviconPath) : null;
|
||||
return $faviconPath ? $this->getAssetUrl($faviconPath) : null;
|
||||
}
|
||||
|
||||
public function getAssetUrl($assetPath): string
|
||||
{
|
||||
return $this->assetsFilesystem->url($assetPath);
|
||||
}
|
||||
}
|
||||
|
@@ -13,13 +13,14 @@ use Flarum\Foundation\AbstractServiceProvider;
|
||||
use Illuminate\Bus\Dispatcher as BaseDispatcher;
|
||||
use Illuminate\Contracts\Bus\Dispatcher as DispatcherContract;
|
||||
use Illuminate\Contracts\Bus\QueueingDispatcher as QueueingDispatcherContract;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Contracts\Queue\Factory as QueueFactoryContract;
|
||||
|
||||
class BusServiceProvider extends AbstractServiceProvider
|
||||
{
|
||||
public function register()
|
||||
{
|
||||
$this->container->bind(BaseDispatcher::class, function ($container) {
|
||||
$this->container->bind(BaseDispatcher::class, function (Container $container) {
|
||||
return new Dispatcher($container, function ($connection = null) use ($container) {
|
||||
return $container[QueueFactoryContract::class]->connection($connection);
|
||||
});
|
||||
|
@@ -12,11 +12,13 @@ namespace Flarum\Console;
|
||||
use Flarum\Database\Console\MigrateCommand;
|
||||
use Flarum\Database\Console\ResetCommand;
|
||||
use Flarum\Foundation\AbstractServiceProvider;
|
||||
use Flarum\Foundation\Console\AssetsPublishCommand;
|
||||
use Flarum\Foundation\Console\CacheClearCommand;
|
||||
use Flarum\Foundation\Console\InfoCommand;
|
||||
use Illuminate\Console\Scheduling\Schedule as LaravelSchedule;
|
||||
use Illuminate\Console\Scheduling\ScheduleListCommand;
|
||||
use Illuminate\Console\Scheduling\ScheduleRunCommand;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
|
||||
class ConsoleServiceProvider extends AbstractServiceProvider
|
||||
{
|
||||
@@ -31,12 +33,13 @@ class ConsoleServiceProvider extends AbstractServiceProvider
|
||||
define('ARTISAN_BINARY', 'flarum');
|
||||
}
|
||||
|
||||
$this->container->singleton(LaravelSchedule::class, function () {
|
||||
return $this->container->make(Schedule::class);
|
||||
$this->container->singleton(LaravelSchedule::class, function (Container $container) {
|
||||
return $container->make(Schedule::class);
|
||||
});
|
||||
|
||||
$this->container->singleton('flarum.console.commands', function () {
|
||||
return [
|
||||
AssetsPublishCommand::class,
|
||||
CacheClearCommand::class,
|
||||
InfoCommand::class,
|
||||
MigrateCommand::class,
|
||||
@@ -54,11 +57,11 @@ class ConsoleServiceProvider extends AbstractServiceProvider
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function boot()
|
||||
public function boot(Container $container)
|
||||
{
|
||||
$schedule = $this->container->make(LaravelSchedule::class);
|
||||
$schedule = $container->make(LaravelSchedule::class);
|
||||
|
||||
foreach ($this->container->make('flarum.console.scheduled') as $scheduled) {
|
||||
foreach ($container->make('flarum.console.scheduled') as $scheduled) {
|
||||
$event = $schedule->command($scheduled['command'], $scheduled['args']);
|
||||
$scheduled['callback']($event);
|
||||
}
|
||||
|
@@ -88,12 +88,5 @@ class MigrateCommand extends AbstractCommand
|
||||
}
|
||||
|
||||
$this->container->make(SettingsRepositoryInterface::class)->set('version', Application::VERSION);
|
||||
|
||||
$this->info('Publishing assets...');
|
||||
|
||||
$this->container->make('files')->copyDirectory(
|
||||
$this->paths->vendor.'/components/font-awesome/webfonts',
|
||||
$this->paths->public.'/assets/fonts'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -98,6 +98,7 @@ class DatabaseMigrationRepository implements MigrationRepositoryInterface
|
||||
$schema = $this->connection->getSchemaBuilder();
|
||||
|
||||
$schema->create($this->table, function ($table) {
|
||||
$table->increments('id');
|
||||
$table->string('migration');
|
||||
$table->string('extension')->nullable();
|
||||
});
|
||||
|
@@ -10,6 +10,7 @@
|
||||
namespace Flarum\Database;
|
||||
|
||||
use Flarum\Foundation\AbstractServiceProvider;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Database\Capsule\Manager;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\ConnectionResolverInterface;
|
||||
@@ -21,10 +22,10 @@ class DatabaseServiceProvider extends AbstractServiceProvider
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$this->container->singleton(Manager::class, function ($container) {
|
||||
$this->container->singleton(Manager::class, function (Container $container) {
|
||||
$manager = new Manager($container);
|
||||
|
||||
$config = $this->container['flarum']->config('database');
|
||||
$config = $container['flarum']->config('database');
|
||||
$config['engine'] = 'InnoDB';
|
||||
$config['prefix_indexes'] = true;
|
||||
|
||||
@@ -33,7 +34,7 @@ class DatabaseServiceProvider extends AbstractServiceProvider
|
||||
return $manager;
|
||||
});
|
||||
|
||||
$this->container->singleton(ConnectionResolverInterface::class, function ($container) {
|
||||
$this->container->singleton(ConnectionResolverInterface::class, function (Container $container) {
|
||||
$manager = $container->make(Manager::class);
|
||||
$manager->setAsGlobal();
|
||||
$manager->bootEloquent();
|
||||
@@ -46,7 +47,7 @@ class DatabaseServiceProvider extends AbstractServiceProvider
|
||||
|
||||
$this->container->alias(ConnectionResolverInterface::class, 'db');
|
||||
|
||||
$this->container->singleton(ConnectionInterface::class, function ($container) {
|
||||
$this->container->singleton(ConnectionInterface::class, function (Container $container) {
|
||||
$resolver = $container->make(ConnectionResolverInterface::class);
|
||||
|
||||
return $resolver->connection();
|
||||
@@ -55,7 +56,7 @@ class DatabaseServiceProvider extends AbstractServiceProvider
|
||||
$this->container->alias(ConnectionInterface::class, 'db.connection');
|
||||
$this->container->alias(ConnectionInterface::class, 'flarum.db');
|
||||
|
||||
$this->container->singleton(MigrationRepositoryInterface::class, function ($container) {
|
||||
$this->container->singleton(MigrationRepositoryInterface::class, function (Container $container) {
|
||||
return new DatabaseMigrationRepository($container['flarum.db'], 'migrations');
|
||||
});
|
||||
|
||||
@@ -64,15 +65,12 @@ class DatabaseServiceProvider extends AbstractServiceProvider
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function boot()
|
||||
public function boot(Container $container)
|
||||
{
|
||||
AbstractModel::setConnectionResolver($this->container->make(ConnectionResolverInterface::class));
|
||||
AbstractModel::setEventDispatcher($this->container->make('events'));
|
||||
AbstractModel::setConnectionResolver($container->make(ConnectionResolverInterface::class));
|
||||
AbstractModel::setEventDispatcher($container->make('events'));
|
||||
|
||||
foreach ($this->container->make('flarum.database.model_private_checkers') as $modelClass => $checkers) {
|
||||
foreach ($container->make('flarum.database.model_private_checkers') as $modelClass => $checkers) {
|
||||
$modelClass::saving(function ($instance) use ($checkers) {
|
||||
foreach ($checkers as $checker) {
|
||||
if ($checker($instance) === true) {
|
||||
|
@@ -12,16 +12,12 @@ namespace Flarum\Discussion;
|
||||
use Flarum\Discussion\Access\ScopeDiscussionVisibility;
|
||||
use Flarum\Discussion\Event\Renamed;
|
||||
use Flarum\Foundation\AbstractServiceProvider;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
|
||||
class DiscussionServiceProvider extends AbstractServiceProvider
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function boot()
|
||||
public function boot(Dispatcher $events)
|
||||
{
|
||||
$events = $this->container->make('events');
|
||||
|
||||
$events->subscribe(DiscussionMetadataUpdater::class);
|
||||
|
||||
$events->listen(
|
||||
|
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace Flarum\Discussion\Search\Gambit;
|
||||
|
||||
use Flarum\Discussion\Discussion;
|
||||
use Flarum\Post\Post;
|
||||
use Flarum\Search\GambitInterface;
|
||||
use Flarum\Search\SearchState;
|
||||
@@ -29,6 +30,11 @@ class FulltextGambit implements GambitInterface
|
||||
$query = $search->getQuery();
|
||||
$grammar = $query->getGrammar();
|
||||
|
||||
$discussionSubquery = Discussion::select('id')
|
||||
->selectRaw('NULL as score')
|
||||
->selectRaw('first_post_id as most_relevant_post_id')
|
||||
->whereRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (? IN BOOLEAN MODE)', [$bit]);
|
||||
|
||||
// Construct a subquery to fetch discussions which contain relevant
|
||||
// posts. Retrieve the collective relevance of each discussion's posts,
|
||||
// which we will use later in the order by clause, and also retrieve
|
||||
@@ -39,7 +45,8 @@ class FulltextGambit implements GambitInterface
|
||||
->selectRaw('SUBSTRING_INDEX(GROUP_CONCAT('.$grammar->wrap('posts.id').' ORDER BY MATCH('.$grammar->wrap('posts.content').') AGAINST (?) DESC, '.$grammar->wrap('posts.number').'), \',\', 1) as most_relevant_post_id', [$bit])
|
||||
->where('posts.type', 'comment')
|
||||
->whereRaw('MATCH('.$grammar->wrap('posts.content').') AGAINST (? IN BOOLEAN MODE)', [$bit])
|
||||
->groupBy('posts.discussion_id');
|
||||
->groupBy('posts.discussion_id')
|
||||
->union($discussionSubquery);
|
||||
|
||||
// Join the subquery into the main search query and scope results to
|
||||
// discussions that have a relevant title or that contain relevant posts.
|
||||
@@ -51,11 +58,8 @@ class FulltextGambit implements GambitInterface
|
||||
'=',
|
||||
'discussions.id'
|
||||
)
|
||||
->addBinding($subquery->getBindings(), 'join')
|
||||
->where(function ($query) use ($grammar, $bit) {
|
||||
$query->whereRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (? IN BOOLEAN MODE)', [$bit])
|
||||
->orWhereNotNull('posts_ft.score');
|
||||
});
|
||||
->groupBy('discussions.id')
|
||||
->addBinding($subquery->getBindings(), 'join');
|
||||
|
||||
$search->setDefaultSort(function ($query) use ($grammar, $bit) {
|
||||
$query->orderByRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (?) desc', [$bit]);
|
||||
|
@@ -23,6 +23,7 @@ class Auth implements ExtenderInterface
|
||||
*
|
||||
* @param string $identifier: Unique identifier for password checker.
|
||||
* @param callable|string $callback: A closure or invokable class that contains the logic of the password checker.
|
||||
* Arguments are a User $object and string $password.
|
||||
* It should return:
|
||||
* - `true` if the given password is valid.
|
||||
* - `null` (or not return anything) if the given password is invalid, or this checker does not apply.
|
||||
|
@@ -42,7 +42,7 @@ class Event implements ExtenderInterface
|
||||
* Event subscribers are classes that may subscribe to multiple events from within the subscriber class itself,
|
||||
* allowing you to define several event handlers within a single class.
|
||||
*
|
||||
* @see https://laravel.com/docs/6.x/events#writing-event-subscribers
|
||||
* @see https://laravel.com/docs/8.x/events#writing-event-subscribers
|
||||
*
|
||||
* @param string $subscriber: The class attribute of the subscriber class
|
||||
*/
|
||||
|
85
src/Extend/Filesystem.php
Normal file
85
src/Extend/Filesystem.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Extend;
|
||||
|
||||
use Flarum\Extension\Extension;
|
||||
use Flarum\Foundation\ContainerUtil;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
|
||||
class Filesystem implements ExtenderInterface
|
||||
{
|
||||
private $disks = [];
|
||||
private $drivers = [];
|
||||
|
||||
/**
|
||||
* Declare a new filesystem disk.
|
||||
* Disks represent storage locations, and are backed by storage drivers.
|
||||
* Flarum core uses disks for storing assets and avatars.
|
||||
*
|
||||
* By default, the "local" driver will be used for disks.
|
||||
* The "local" driver represents the filesystem where your Flarum installation is running.
|
||||
*
|
||||
* To declare a new disk, you must provide default configuration a "local" driver.
|
||||
*
|
||||
* @param string $name: The name of the disk
|
||||
* @param string|callable $callback: A callback or invokable class name with parameters:
|
||||
* - \Flarum\Foundation\Paths $paths
|
||||
* - \Flarum\Http\UrlGenerator $url
|
||||
* which returns a Laravel disk config array.
|
||||
* The `driver` key is not necessary for this array, and will be ignored.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* ->disk('flarum-uploads', function (Paths $paths, UrlGenerator $url) {
|
||||
* return [
|
||||
* 'root' => "$paths->public/assets/uploads",
|
||||
* 'url' => $url->to('forum')->path('assets/uploads')
|
||||
* ];
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @see https://laravel.com/docs/8.x/filesystem#configuration
|
||||
*/
|
||||
public function disk(string $name, $callback)
|
||||
{
|
||||
$this->disks[$name] = $callback;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new filesystem driver.
|
||||
* Drivers must implement `\Flarum\Filesystem\DriverInterface`.
|
||||
*
|
||||
* @param string $name: The name of the driver
|
||||
* @param string $driverClass: The ::class attribute of the driver.
|
||||
*/
|
||||
public function driver(string $name, string $driverClass)
|
||||
{
|
||||
$this->drivers[$name] = $driverClass;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function extend(Container $container, Extension $extension = null)
|
||||
{
|
||||
$container->extend('flarum.filesystem.disks', function ($existingDisks) use ($container) {
|
||||
foreach ($this->disks as $name => $disk) {
|
||||
$existingDisks[$name] = ContainerUtil::wrapCallback($disk, $container);
|
||||
}
|
||||
|
||||
return $existingDisks;
|
||||
});
|
||||
|
||||
$container->extend('flarum.filesystem.drivers', function ($existingDrivers) {
|
||||
return array_merge($existingDrivers, $this->drivers);
|
||||
});
|
||||
}
|
||||
}
|
@@ -18,6 +18,7 @@ class Formatter implements ExtenderInterface, LifecycleInterface
|
||||
{
|
||||
private $configurationCallbacks = [];
|
||||
private $parsingCallbacks = [];
|
||||
private $unparsingCallbacks = [];
|
||||
private $renderingCallbacks = [];
|
||||
|
||||
/**
|
||||
@@ -58,6 +59,28 @@ class Formatter implements ExtenderInterface, LifecycleInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the system for unparsing. This can be used to modify the text that was parsed.
|
||||
* Please note that the parsed text must be returned, regardless of whether it's changed.
|
||||
*
|
||||
* @param callable|string $callback
|
||||
*
|
||||
* The callback can be a closure or invokable class, and should accept:
|
||||
* - mixed $context
|
||||
* - string $xml: The parsed text.
|
||||
*
|
||||
* The callback should return:
|
||||
* - string $xml: The text to be unparsed.
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function unparse($callback)
|
||||
{
|
||||
$this->unparsingCallbacks[] = $callback;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the system for rendering. This can be used to modify the xml that will be rendered, or to modify the renderer.
|
||||
* Please note that the xml to be rendered must be returned, regardless of whether it's changed.
|
||||
@@ -91,6 +114,10 @@ class Formatter implements ExtenderInterface, LifecycleInterface
|
||||
$formatter->addParsingCallback(ContainerUtil::wrapCallback($callback, $container));
|
||||
}
|
||||
|
||||
foreach ($this->unparsingCallbacks as $callback) {
|
||||
$formatter->addUnparsingCallback(ContainerUtil::wrapCallback($callback, $container));
|
||||
}
|
||||
|
||||
foreach ($this->renderingCallbacks as $callback) {
|
||||
$formatter->addRenderingCallback(ContainerUtil::wrapCallback($callback, $container));
|
||||
}
|
||||
|
@@ -97,11 +97,11 @@ class Frontend implements ExtenderInterface
|
||||
if ($this->js) {
|
||||
$assets->js(function (SourceCollector $sources) use ($moduleName) {
|
||||
$sources->addString(function () {
|
||||
return 'var module={}';
|
||||
return 'var module={};';
|
||||
});
|
||||
$sources->addFile($this->js);
|
||||
$sources->addString(function () use ($moduleName) {
|
||||
return "flarum.extensions['$moduleName']=module.exports";
|
||||
return "flarum.extensions['$moduleName']=module.exports;";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@@ -17,6 +17,7 @@ use Illuminate\Contracts\Container\Container;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use SplFileInfo;
|
||||
use Symfony\Component\Translation\MessageCatalogueInterface;
|
||||
|
||||
class LanguagePack implements ExtenderInterface, LifecycleInterface
|
||||
{
|
||||
@@ -107,6 +108,9 @@ class LanguagePack implements ExtenderInterface, LifecycleInterface
|
||||
// extension) with the list of known names and all extension IDs.
|
||||
$slug = $file->getBasename(".{$file->getExtension()}");
|
||||
|
||||
// Ignore ICU MessageFormat suffixes.
|
||||
$slug = str_replace(MessageCatalogueInterface::INTL_DOMAIN_SUFFIX, '', $slug);
|
||||
|
||||
if (in_array($slug, self::CORE_LOCALE_FILES, true)) {
|
||||
return true;
|
||||
}
|
||||
|
@@ -13,6 +13,7 @@ use DirectoryIterator;
|
||||
use Flarum\Extension\Extension;
|
||||
use Flarum\Locale\LocaleManager;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Symfony\Component\Translation\MessageCatalogueInterface;
|
||||
|
||||
class Locales implements ExtenderInterface, LifecycleInterface
|
||||
{
|
||||
@@ -38,8 +39,13 @@ class Locales implements ExtenderInterface, LifecycleInterface
|
||||
continue;
|
||||
}
|
||||
|
||||
$locale = $file->getBasename(".$extension");
|
||||
|
||||
// Ignore ICU MessageFormat suffixes.
|
||||
$locale = str_replace(MessageCatalogueInterface::INTL_DOMAIN_SUFFIX, '', $locale);
|
||||
|
||||
$locales->addTranslations(
|
||||
$file->getBasename(".$extension"),
|
||||
$locale,
|
||||
$file->getPathname()
|
||||
);
|
||||
}
|
||||
|
@@ -73,7 +73,7 @@ class SimpleFlarumSearch implements ExtenderInterface
|
||||
public function extend(Container $container, Extension $extension = null)
|
||||
{
|
||||
if (! is_null($this->fullTextGambit)) {
|
||||
$container->resolving('flarum.simple_search.fulltext_gambits', function ($oldFulltextGambits) {
|
||||
$container->extend('flarum.simple_search.fulltext_gambits', function ($oldFulltextGambits) {
|
||||
$oldFulltextGambits[$this->searcher] = $this->fullTextGambit;
|
||||
|
||||
return $oldFulltextGambits;
|
||||
|
@@ -27,7 +27,7 @@ class View implements ExtenderInterface, LifecycleInterface
|
||||
*
|
||||
* Views can then be used in your extension by injecting an instance of `Illuminate\Contracts\View\Factory`,
|
||||
* and calling its `make` method. The `make` method takes the view parameter in the format NAMESPACE::VIEW_NAME.
|
||||
* You can also pass variables into a view: for more information, see https://laravel.com/api/6.x/Illuminate/View/Factory.html#method_make
|
||||
* You can also pass variables into a view: for more information, see https://laravel.com/api/8.x/Illuminate/View/Factory.html#method_make
|
||||
*
|
||||
* @param string $namespace: The name of the namespace.
|
||||
* @param string|array $hints: This is a path (or an array of paths) to the folder(s)
|
||||
|
30
src/Extension/Exception/ExtensionBootError.php
Normal file
30
src/Extension/Exception/ExtensionBootError.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?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\Extension\Exception;
|
||||
|
||||
use Exception;
|
||||
use Flarum\Extension\Extension;
|
||||
use Throwable;
|
||||
|
||||
class ExtensionBootError extends Exception
|
||||
{
|
||||
public $extension;
|
||||
public $extender;
|
||||
|
||||
public function __construct(Extension $extension, $extender, Throwable $previous = null)
|
||||
{
|
||||
$this->extension = $extension;
|
||||
$this->extender = $extender;
|
||||
|
||||
$extenderClass = get_class($extender);
|
||||
|
||||
parent::__construct("Experienced an error while booting extension: {$extension->getTitle()}.\n\nError occurred while applying an extender of type: $extenderClass.", null, $previous);
|
||||
}
|
||||
}
|
@@ -11,16 +11,15 @@ namespace Flarum\Extension;
|
||||
|
||||
use Flarum\Database\Migrator;
|
||||
use Flarum\Extend\LifecycleInterface;
|
||||
use Flarum\Extension\Exception\ExtensionBootError;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem as FilesystemInterface;
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use League\Flysystem\Adapter\Local;
|
||||
use League\Flysystem\Filesystem;
|
||||
use League\Flysystem\FilesystemInterface;
|
||||
use League\Flysystem\MountManager;
|
||||
use League\Flysystem\Plugin\ListFiles;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @property string $name
|
||||
@@ -53,7 +52,7 @@ class Extension implements Arrayable
|
||||
|
||||
protected static function nameToId($name)
|
||||
{
|
||||
list($vendor, $package) = explode('/', $name);
|
||||
[$vendor, $package] = explode('/', $name);
|
||||
$package = str_replace(['flarum-ext-', 'flarum-'], '', $package);
|
||||
|
||||
return "$vendor-$package";
|
||||
@@ -134,7 +133,11 @@ class Extension implements Arrayable
|
||||
public function extend(Container $container)
|
||||
{
|
||||
foreach ($this->getExtenders() as $extender) {
|
||||
$extender->extend($container, $this);
|
||||
try {
|
||||
$extender->extend($container, $this);
|
||||
} catch (Throwable $e) {
|
||||
throw new ExtensionBootError($this, $extender, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +160,7 @@ class Extension implements Arrayable
|
||||
/**
|
||||
* Dot notation getter for composer.json attributes.
|
||||
*
|
||||
* @see https://laravel.com/docs/5.1/helpers#arrays
|
||||
* @see https://laravel.com/docs/8.x/helpers#arrays
|
||||
*
|
||||
* @param $name
|
||||
* @return mixed
|
||||
@@ -428,16 +431,13 @@ class Extension implements Arrayable
|
||||
return;
|
||||
}
|
||||
|
||||
$mount = new MountManager([
|
||||
'source' => $source = new Filesystem(new Local($this->getPath().'/assets')),
|
||||
'target' => $target,
|
||||
]);
|
||||
$source = new Filesystem();
|
||||
|
||||
$source->addPlugin(new ListFiles);
|
||||
$assetFiles = $source->listFiles('/', true);
|
||||
$assetFiles = $source->allFiles("$this->path/assets");
|
||||
|
||||
foreach ($assetFiles as $file) {
|
||||
$mount->copy("source://$file[path]", "target://extensions/$this->id/$file[path]");
|
||||
foreach ($assetFiles as $fullPath) {
|
||||
$relPath = substr($fullPath, strlen("$this->path/assets"));
|
||||
$target->put("extensions/$this->id/$relPath", $source->get($fullPath));
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -19,6 +19,7 @@ use Flarum\Foundation\Paths;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Contracts\Filesystem\Cloud;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\Schema\Builder;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
@@ -252,12 +253,7 @@ class ExtensionManager
|
||||
*/
|
||||
protected function publishAssets(Extension $extension)
|
||||
{
|
||||
if ($extension->hasAssets()) {
|
||||
$this->filesystem->copyDirectory(
|
||||
$extension->getPath().'/assets',
|
||||
$this->paths->public.'/assets/extensions/'.$extension->getId()
|
||||
);
|
||||
}
|
||||
$extension->copyAssetsTo($this->getAssetsFilesystem());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,7 +263,7 @@ class ExtensionManager
|
||||
*/
|
||||
protected function unpublishAssets(Extension $extension)
|
||||
{
|
||||
$this->filesystem->deleteDirectory($this->paths->public.'/assets/extensions/'.$extension->getId());
|
||||
$this->getAssetsFilesystem()->deleteDirectory('extensions/'.$extension->getId());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -279,7 +275,17 @@ class ExtensionManager
|
||||
*/
|
||||
public function getAsset(Extension $extension, $path)
|
||||
{
|
||||
return $this->paths->public.'/assets/extensions/'.$extension->getId().$path;
|
||||
return $this->getAssetsFilesystem()->url($extension->getId()."/$path");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an instance of the assets filesystem.
|
||||
* This is resolved dynamically because Flarum's filesystem configuration
|
||||
* might not be booted yet when the ExtensionManager singleton initializes.
|
||||
*/
|
||||
protected function getAssetsFilesystem(): Cloud
|
||||
{
|
||||
return resolve('filesystem')->disk('flarum-assets');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -11,6 +11,7 @@ namespace Flarum\Extension;
|
||||
|
||||
use Flarum\Extension\Event\Disabling;
|
||||
use Flarum\Foundation\AbstractServiceProvider;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
|
||||
class ExtensionServiceProvider extends AbstractServiceProvider
|
||||
{
|
||||
@@ -34,9 +35,9 @@ class ExtensionServiceProvider extends AbstractServiceProvider
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function boot()
|
||||
public function boot(Dispatcher $events)
|
||||
{
|
||||
$this->container->make('events')->listen(
|
||||
$events->listen(
|
||||
Disabling::class,
|
||||
DefaultLanguagePackGuard::class
|
||||
);
|
||||
|
39
src/Filesystem/DriverInterface.php
Normal file
39
src/Filesystem/DriverInterface.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Filesystem;
|
||||
|
||||
use Flarum\Foundation\Config;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Illuminate\Contracts\Filesystem\Cloud;
|
||||
|
||||
interface DriverInterface
|
||||
{
|
||||
/**
|
||||
* Construct a Laravel Cloud filesystem for this filesystem driver.
|
||||
* Settings and configuration can either be pulled from the Flarum settings repository
|
||||
* or the config.php file.
|
||||
*
|
||||
* Typically, this is done by wrapping a Flysystem adapter in Laravel's
|
||||
* `Illuminate\Filesystem\FilesystemAdapter` class.
|
||||
* You should ensure that the Flysystem adapter you use has a `getUrl` method.
|
||||
* If it doesn't, you should create a subclass implementing that method.
|
||||
* Otherwise, this driver won't work for public-facing disks
|
||||
* like `flarum-assets` or `flarum-avatars`.
|
||||
*
|
||||
* @param string $diskName: The name of a disk this driver is being used for.
|
||||
* This is generally used to locate disk-specific settings.
|
||||
* @param SettingsRepositoryInterface $settings: An instance of the Flarum settings repository.
|
||||
* @param Config $config: An instance of the wrapper class around `config.php`.
|
||||
* @param array $localConfig: The configuration array that would have been used
|
||||
* if this disk were using the 'local' filesystem driver.
|
||||
* Some of these settings might be useful (e.g. visibility, )
|
||||
*/
|
||||
public function build(string $diskName, SettingsRepositoryInterface $settings, Config $config, array $localConfig): Cloud;
|
||||
}
|
83
src/Filesystem/FilesystemManager.php
Normal file
83
src/Filesystem/FilesystemManager.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?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\Filesystem;
|
||||
|
||||
use Flarum\Foundation\Config;
|
||||
use Flarum\Foundation\Paths;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Filesystem\FilesystemManager as LaravelFilesystemManager;
|
||||
use Illuminate\Support\Arr;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class FilesystemManager extends LaravelFilesystemManager
|
||||
{
|
||||
protected $diskLocalConfig = [];
|
||||
protected $drivers = [];
|
||||
|
||||
public function __construct(Container $app, array $diskLocalConfig, array $drivers)
|
||||
{
|
||||
parent::__construct($app);
|
||||
|
||||
$this->diskLocalConfig = $diskLocalConfig;
|
||||
$this->drivers = $drivers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected function resolve($name): Filesystem
|
||||
{
|
||||
$driver = $this->getDriver($name);
|
||||
|
||||
$localConfig = $this->getLocalConfig($name);
|
||||
|
||||
if (empty($localConfig)) {
|
||||
throw new InvalidArgumentException("Disk [{$name}] has not been declared. Use the Filesystem extender to do this.");
|
||||
}
|
||||
|
||||
if ($driver === 'local') {
|
||||
return $this->createLocalDriver($localConfig);
|
||||
}
|
||||
|
||||
$settings = $this->app->make(SettingsRepositoryInterface::class);
|
||||
$config = $this->app->make(Config::class);
|
||||
|
||||
return $driver->build($name, $settings, $config, $localConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|DriverInterface
|
||||
*/
|
||||
protected function getDriver(string $name)
|
||||
{
|
||||
$config = $this->app->make(Config::class);
|
||||
$settings = $this->app->make(SettingsRepositoryInterface::class);
|
||||
|
||||
$key = "disk_driver.$name";
|
||||
$configuredDriver = Arr::get($config, $key, $settings->get($key, 'local'));
|
||||
|
||||
return Arr::get($this->drivers, $configuredDriver, 'local');
|
||||
}
|
||||
|
||||
protected function getLocalConfig(string $name): array
|
||||
{
|
||||
if (! array_key_exists($name, $this->diskLocalConfig)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$paths = $this->app->make(Paths::class);
|
||||
$url = $this->app->make(UrlGenerator::class);
|
||||
|
||||
return $this->diskLocalConfig[$name]($paths, $url);
|
||||
}
|
||||
}
|
64
src/Filesystem/FilesystemServiceProvider.php
Normal file
64
src/Filesystem/FilesystemServiceProvider.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?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\Filesystem;
|
||||
|
||||
use Flarum\Foundation\AbstractServiceProvider;
|
||||
use Flarum\Foundation\Paths;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
|
||||
class FilesystemServiceProvider extends AbstractServiceProvider
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$this->container->singleton('files', function () {
|
||||
return new Filesystem;
|
||||
});
|
||||
|
||||
$this->container->singleton('flarum.filesystem.disks', function () {
|
||||
return [
|
||||
'flarum-assets' => function (Paths $paths, UrlGenerator $url) {
|
||||
return [
|
||||
'root' => "$paths->public/assets",
|
||||
'url' => $url->to('forum')->path('assets')
|
||||
];
|
||||
},
|
||||
'flarum-avatars' => function (Paths $paths, UrlGenerator $url) {
|
||||
return [
|
||||
'root' => "$paths->public/assets/avatars",
|
||||
'url' => $url->to('forum')->path('assets/avatars')
|
||||
];
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
$this->container->singleton('flarum.filesystem.drivers', function () {
|
||||
return [];
|
||||
});
|
||||
|
||||
$this->container->singleton('flarum.filesystem.resolved_drivers', function (Container $container) {
|
||||
return array_map(function ($driverClass) use ($container) {
|
||||
return $container->make($driverClass);
|
||||
}, $container->make('flarum.filesystem.drivers'));
|
||||
});
|
||||
|
||||
$this->container->singleton('filesystem', function (Container $container) {
|
||||
return new FilesystemManager(
|
||||
$container,
|
||||
$container->make('flarum.filesystem.disks'),
|
||||
$container->make('flarum.filesystem.resolved_drivers')
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
@@ -17,6 +17,7 @@ use Flarum\Post\Filter as PostFilter;
|
||||
use Flarum\Post\Filter\PostFilterer;
|
||||
use Flarum\User\Filter\UserFilterer;
|
||||
use Flarum\User\Query as UserQuery;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class FilterServiceProvider extends AbstractServiceProvider
|
||||
@@ -55,7 +56,7 @@ class FilterServiceProvider extends AbstractServiceProvider
|
||||
});
|
||||
}
|
||||
|
||||
public function boot()
|
||||
public function boot(Container $container)
|
||||
{
|
||||
// We can resolve the filter mutators in the when->needs->give callback,
|
||||
// but we need to resolve at least one regardless so we know which
|
||||
@@ -63,7 +64,7 @@ class FilterServiceProvider extends AbstractServiceProvider
|
||||
$filters = $this->container->make('flarum.filter.filters');
|
||||
|
||||
foreach ($filters as $filterer => $filterClasses) {
|
||||
$this->container
|
||||
$container
|
||||
->when($filterer)
|
||||
->needs('$filters')
|
||||
->give(function () use ($filterClasses) {
|
||||
@@ -77,13 +78,13 @@ class FilterServiceProvider extends AbstractServiceProvider
|
||||
return $compiled;
|
||||
});
|
||||
|
||||
$this->container
|
||||
$container
|
||||
->when($filterer)
|
||||
->needs('$filterMutators')
|
||||
->give(function () use ($filterer) {
|
||||
->give(function () use ($container, $filterer) {
|
||||
return array_map(function ($filterMutatorClass) {
|
||||
return ContainerUtil::wrapCallback($filterMutatorClass, $this->container);
|
||||
}, Arr::get($this->container->make('flarum.filter.filter_mutators'), $filterer, []));
|
||||
}, Arr::get($container->make('flarum.filter.filter_mutators'), $filterer, []));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -20,6 +20,8 @@ class Formatter
|
||||
|
||||
protected $parsingCallbacks = [];
|
||||
|
||||
protected $unparsingCallbacks = [];
|
||||
|
||||
protected $renderingCallbacks = [];
|
||||
|
||||
/**
|
||||
@@ -52,6 +54,11 @@ class Formatter
|
||||
$this->parsingCallbacks[] = $callback;
|
||||
}
|
||||
|
||||
public function addUnparsingCallback($callback)
|
||||
{
|
||||
$this->unparsingCallbacks[] = $callback;
|
||||
}
|
||||
|
||||
public function addRenderingCallback($callback)
|
||||
{
|
||||
$this->renderingCallbacks[] = $callback;
|
||||
@@ -98,10 +105,15 @@ class Formatter
|
||||
* Unparse XML.
|
||||
*
|
||||
* @param string $xml
|
||||
* @param mixed $context
|
||||
* @return string
|
||||
*/
|
||||
public function unparse($xml)
|
||||
public function unparse($xml, $context = null)
|
||||
{
|
||||
foreach ($this->unparsingCallbacks as $callback) {
|
||||
$xml = $callback($context, $xml);
|
||||
}
|
||||
|
||||
return Unparser::unparse($xml);
|
||||
}
|
||||
|
||||
|
@@ -24,7 +24,7 @@ class FormatterServiceProvider extends AbstractServiceProvider
|
||||
$this->container->singleton('flarum.formatter', function (Container $container) {
|
||||
return new Formatter(
|
||||
new Repository($container->make('cache.filestore')),
|
||||
$this->container[Paths::class]->storage.'/formatter'
|
||||
$container[Paths::class]->storage.'/formatter'
|
||||
);
|
||||
});
|
||||
|
||||
|
@@ -31,6 +31,9 @@ use Flarum\Locale\LocaleManager;
|
||||
use Flarum\Settings\Event\Saved;
|
||||
use Flarum\Settings\Event\Saving;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Contracts\View\Factory;
|
||||
use Laminas\Stratigility\MiddlewarePipe;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
@@ -41,19 +44,19 @@ class ForumServiceProvider extends AbstractServiceProvider
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$this->container->extend(UrlGenerator::class, function (UrlGenerator $url) {
|
||||
return $url->addCollection('forum', $this->container->make('flarum.forum.routes'));
|
||||
$this->container->extend(UrlGenerator::class, function (UrlGenerator $url, Container $container) {
|
||||
return $url->addCollection('forum', $container->make('flarum.forum.routes'));
|
||||
});
|
||||
|
||||
$this->container->singleton('flarum.forum.routes', function () {
|
||||
$this->container->singleton('flarum.forum.routes', function (Container $container) {
|
||||
$routes = new RouteCollection;
|
||||
$this->populateRoutes($routes);
|
||||
$this->populateRoutes($routes, $container);
|
||||
|
||||
return $routes;
|
||||
});
|
||||
|
||||
$this->container->afterResolving('flarum.forum.routes', function (RouteCollection $routes) {
|
||||
$this->setDefaultRoute($routes);
|
||||
$this->container->afterResolving('flarum.forum.routes', function (RouteCollection $routes, Container $container) {
|
||||
$this->setDefaultRoute($routes, $container);
|
||||
});
|
||||
|
||||
$this->container->singleton('flarum.forum.middleware', function () {
|
||||
@@ -70,26 +73,28 @@ class ForumServiceProvider extends AbstractServiceProvider
|
||||
HttpMiddleware\CheckCsrfToken::class,
|
||||
HttpMiddleware\ShareErrorsFromSession::class,
|
||||
HttpMiddleware\FlarumPromotionHeader::class,
|
||||
HttpMiddleware\ReferrerPolicyHeader::class,
|
||||
HttpMiddleware\ContentTypeOptionsHeader::class
|
||||
];
|
||||
});
|
||||
|
||||
$this->container->bind('flarum.forum.error_handler', function () {
|
||||
$this->container->bind('flarum.forum.error_handler', function (Container $container) {
|
||||
return new HttpMiddleware\HandleErrors(
|
||||
$this->container->make(Registry::class),
|
||||
$this->container['flarum.config']->inDebugMode() ? $this->container->make(WhoopsFormatter::class) : $this->container->make(ViewFormatter::class),
|
||||
$this->container->tagged(Reporter::class)
|
||||
$container->make(Registry::class),
|
||||
$container['flarum.config']->inDebugMode() ? $container->make(WhoopsFormatter::class) : $container->make(ViewFormatter::class),
|
||||
$container->tagged(Reporter::class)
|
||||
);
|
||||
});
|
||||
|
||||
$this->container->bind('flarum.forum.route_resolver', function () {
|
||||
return new HttpMiddleware\ResolveRoute($this->container->make('flarum.forum.routes'));
|
||||
$this->container->bind('flarum.forum.route_resolver', function (Container $container) {
|
||||
return new HttpMiddleware\ResolveRoute($container->make('flarum.forum.routes'));
|
||||
});
|
||||
|
||||
$this->container->singleton('flarum.forum.handler', function () {
|
||||
$this->container->singleton('flarum.forum.handler', function (Container $container) {
|
||||
$pipe = new MiddlewarePipe;
|
||||
|
||||
foreach ($this->container->make('flarum.forum.middleware') as $middleware) {
|
||||
$pipe->pipe($this->container->make($middleware));
|
||||
foreach ($container->make('flarum.forum.middleware') as $middleware) {
|
||||
$pipe->pipe($container->make($middleware));
|
||||
}
|
||||
|
||||
$pipe->pipe(new HttpMiddleware\ExecuteRoute());
|
||||
@@ -97,55 +102,50 @@ class ForumServiceProvider extends AbstractServiceProvider
|
||||
return $pipe;
|
||||
});
|
||||
|
||||
$this->container->bind('flarum.assets.forum', function () {
|
||||
$this->container->bind('flarum.assets.forum', function (Container $container) {
|
||||
/** @var Assets $assets */
|
||||
$assets = $this->container->make('flarum.assets.factory')('forum');
|
||||
$assets = $container->make('flarum.assets.factory')('forum');
|
||||
|
||||
$assets->js(function (SourceCollector $sources) {
|
||||
$assets->js(function (SourceCollector $sources) use ($container) {
|
||||
$sources->addFile(__DIR__.'/../../js/dist/forum.js');
|
||||
$sources->addString(function () {
|
||||
return $this->container->make(Formatter::class)->getJs();
|
||||
$sources->addString(function () use ($container) {
|
||||
return $container->make(Formatter::class)->getJs();
|
||||
});
|
||||
});
|
||||
|
||||
$assets->css(function (SourceCollector $sources) {
|
||||
$assets->css(function (SourceCollector $sources) use ($container) {
|
||||
$sources->addFile(__DIR__.'/../../less/forum.less');
|
||||
$sources->addString(function () {
|
||||
return $this->container->make(SettingsRepositoryInterface::class)->get('custom_less', '');
|
||||
$sources->addString(function () use ($container) {
|
||||
return $container->make(SettingsRepositoryInterface::class)->get('custom_less', '');
|
||||
});
|
||||
});
|
||||
|
||||
$this->container->make(AddTranslations::class)->forFrontend('forum')->to($assets);
|
||||
$this->container->make(AddLocaleAssets::class)->to($assets);
|
||||
$container->make(AddTranslations::class)->forFrontend('forum')->to($assets);
|
||||
$container->make(AddLocaleAssets::class)->to($assets);
|
||||
|
||||
return $assets;
|
||||
});
|
||||
|
||||
$this->container->bind('flarum.frontend.forum', function () {
|
||||
return $this->container->make('flarum.frontend.factory')('forum');
|
||||
$this->container->bind('flarum.frontend.forum', function (Container $container) {
|
||||
return $container->make('flarum.frontend.factory')('forum');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function boot()
|
||||
public function boot(Container $container, Dispatcher $events, Factory $view)
|
||||
{
|
||||
$this->loadViewsFrom(__DIR__.'/../../views', 'flarum.forum');
|
||||
|
||||
$this->container->make('view')->share([
|
||||
'translator' => $this->container->make(TranslatorInterface::class),
|
||||
'settings' => $this->container->make(SettingsRepositoryInterface::class)
|
||||
$view->share([
|
||||
'translator' => $container->make(TranslatorInterface::class),
|
||||
'settings' => $container->make(SettingsRepositoryInterface::class)
|
||||
]);
|
||||
|
||||
$events = $this->container->make('events');
|
||||
|
||||
$events->listen(
|
||||
[Enabled::class, Disabled::class, ClearingCache::class],
|
||||
function () {
|
||||
function () use ($container) {
|
||||
$recompile = new RecompileFrontendAssets(
|
||||
$this->container->make('flarum.assets.forum'),
|
||||
$this->container->make(LocaleManager::class)
|
||||
$container->make('flarum.assets.forum'),
|
||||
$container->make(LocaleManager::class)
|
||||
);
|
||||
$recompile->flush();
|
||||
}
|
||||
@@ -153,17 +153,17 @@ class ForumServiceProvider extends AbstractServiceProvider
|
||||
|
||||
$events->listen(
|
||||
Saved::class,
|
||||
function (Saved $event) {
|
||||
function (Saved $event) use ($container) {
|
||||
$recompile = new RecompileFrontendAssets(
|
||||
$this->container->make('flarum.assets.forum'),
|
||||
$this->container->make(LocaleManager::class)
|
||||
$container->make('flarum.assets.forum'),
|
||||
$container->make(LocaleManager::class)
|
||||
);
|
||||
$recompile->whenSettingsSaved($event);
|
||||
|
||||
$validator = new ValidateCustomLess(
|
||||
$this->container->make('flarum.assets.forum'),
|
||||
$this->container->make('flarum.locales'),
|
||||
$this->container
|
||||
$container->make('flarum.assets.forum'),
|
||||
$container->make('flarum.locales'),
|
||||
$container
|
||||
);
|
||||
$validator->whenSettingsSaved($event);
|
||||
}
|
||||
@@ -171,11 +171,11 @@ class ForumServiceProvider extends AbstractServiceProvider
|
||||
|
||||
$events->listen(
|
||||
Saving::class,
|
||||
function (Saving $event) {
|
||||
function (Saving $event) use ($container) {
|
||||
$validator = new ValidateCustomLess(
|
||||
$this->container->make('flarum.assets.forum'),
|
||||
$this->container->make('flarum.locales'),
|
||||
$this->container
|
||||
$container->make('flarum.assets.forum'),
|
||||
$container->make('flarum.locales'),
|
||||
$container
|
||||
);
|
||||
$validator->whenSettingsSaving($event);
|
||||
}
|
||||
@@ -186,10 +186,11 @@ class ForumServiceProvider extends AbstractServiceProvider
|
||||
* Populate the forum client routes.
|
||||
*
|
||||
* @param RouteCollection $routes
|
||||
* @param Container $container
|
||||
*/
|
||||
protected function populateRoutes(RouteCollection $routes)
|
||||
protected function populateRoutes(RouteCollection $routes, Container $container)
|
||||
{
|
||||
$factory = $this->container->make(RouteHandlerFactory::class);
|
||||
$factory = $container->make(RouteHandlerFactory::class);
|
||||
|
||||
$callback = include __DIR__.'/routes.php';
|
||||
$callback($routes, $factory);
|
||||
@@ -199,11 +200,12 @@ class ForumServiceProvider extends AbstractServiceProvider
|
||||
* Determine the default route.
|
||||
*
|
||||
* @param RouteCollection $routes
|
||||
* @param Container $container
|
||||
*/
|
||||
protected function setDefaultRoute(RouteCollection $routes)
|
||||
protected function setDefaultRoute(RouteCollection $routes, Container $container)
|
||||
{
|
||||
$factory = $this->container->make(RouteHandlerFactory::class);
|
||||
$defaultRoute = $this->container->make('flarum.settings')->get('default_route');
|
||||
$factory = $container->make(RouteHandlerFactory::class);
|
||||
$defaultRoute = $container->make('flarum.settings')->get('default_route');
|
||||
|
||||
if (isset($routes->getRouteData()[0]['GET'][$defaultRoute]['handler'])) {
|
||||
$toDefaultController = $routes->getRouteData()[0]['GET'][$defaultRoute]['handler'];
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user