1
0
mirror of https://github.com/flarum/core.git synced 2025-08-06 08:27:42 +02:00

feat: admin search UI (#4022)

This commit is contained in:
Sami Mazouz
2024-09-28 09:35:37 +01:00
committed by GitHub
parent e08a9f6146
commit 5cea3d3b9b
75 changed files with 1722 additions and 897 deletions

View File

@@ -0,0 +1,26 @@
import Extend from 'flarum/common/extenders';
import app from 'flarum/admin/app';
export default [
new Extend.Admin()
.setting(() => ({
setting: 'flarum-akismet.api_key',
type: 'text',
label: app.translator.trans('flarum-akismet.admin.akismet_settings.api_key_label'),
}))
.setting(() => ({
// https://blog.akismet.com/2014/04/23/theres-a-ninja-in-your-akismet/
setting: 'flarum-akismet.delete_blatant_spam',
type: 'boolean',
label: app.translator.trans('flarum-akismet.admin.akismet_settings.delete_blatant_spam_label'),
help: app.translator.trans('flarum-akismet.admin.akismet_settings.delete_blatant_spam_help'),
}))
.permission(
() => ({
icon: 'fas fa-vote-yea',
label: app.translator.trans('flarum-akismet.admin.permissions.bypass_akismet'),
permission: 'bypassAkismet',
}),
'start'
),
];

View File

@@ -1,26 +1,7 @@
import app from 'flarum/admin/app';
export { default as extend } from './extend';
app.initializers.add('flarum-akismet', () => {
app.extensionData
.for('flarum-akismet')
.registerSetting({
setting: 'flarum-akismet.api_key',
type: 'text',
label: app.translator.trans('flarum-akismet.admin.akismet_settings.api_key_label'),
})
.registerSetting({
//https://blog.akismet.com/2014/04/23/theres-a-ninja-in-your-akismet/
setting: 'flarum-akismet.delete_blatant_spam',
type: 'boolean',
label: app.translator.trans('flarum-akismet.admin.akismet_settings.delete_blatant_spam_label'),
help: app.translator.trans('flarum-akismet.admin.akismet_settings.delete_blatant_spam_help'),
})
.registerPermission(
{
icon: 'fas fa-vote-yea',
label: app.translator.trans('flarum-akismet.admin.permissions.bypass_akismet'),
permission: 'bypassAkismet',
},
'start'
);
// ...
});

View File

@@ -1,43 +1,33 @@
import { extend } from 'flarum/common/extend';
import Extend from 'flarum/common/extenders';
import app from 'flarum/admin/app';
app.initializers.add('flarum-approval', () => {
extend(app, 'getRequiredPermissions', function (required, permission) {
if (permission === 'discussion.startWithoutApproval') {
required.push('startDiscussion');
}
if (permission === 'discussion.replyWithoutApproval') {
required.push('discussion.reply');
}
});
app.extensionData
.for('flarum-approval')
.registerPermission(
{
export default [
new Extend.Admin()
.permission(
() => ({
icon: 'fas fa-check',
label: app.translator.trans('flarum-approval.admin.permissions.start_discussions_without_approval_label'),
permission: 'discussion.startWithoutApproval',
},
}),
'start',
95
)
.registerPermission(
{
.permission(
() => ({
icon: 'fas fa-check',
label: app.translator.trans('flarum-approval.admin.permissions.reply_without_approval_label'),
permission: 'discussion.replyWithoutApproval',
},
}),
'reply',
95
)
.registerPermission(
{
.permission(
() => ({
icon: 'fas fa-check',
label: app.translator.trans('flarum-approval.admin.permissions.approve_posts_label'),
permission: 'discussion.approvePosts',
},
}),
'moderate',
65
);
});
),
];

View File

@@ -0,0 +1,15 @@
import { extend } from 'flarum/common/extend';
import app from 'flarum/admin/app';
export { default as extend } from './extend';
app.initializers.add('flarum-approval', () => {
extend(app, 'getRequiredPermissions', function (required, permission) {
if (permission === 'discussion.startWithoutApproval') {
required.push('startDiscussion');
}
if (permission === 'discussion.replyWithoutApproval') {
required.push('discussion.reply');
}
});
});

View File

@@ -0,0 +1,15 @@
{
// Use Flarum's tsconfig as a starting point
"extends": "flarum-tsconfig",
// This will match all .ts, .tsx, .d.ts, .js, .jsx files in your `src` folder
// and also tells your Typescript server to read core's global typings for
// access to `dayjs` and `$` in the global namespace.
"include": ["src/**/*", "../../../*/*/js/dist-typings/@types/**/*", "@types/**/*"],
"compilerOptions": {
// This will output typings to `dist-typings`
"declarationDir": "./dist-typings",
"paths": {
"flarum/*": ["../../../framework/core/js/dist-typings/*"]
}
}
}

View File

@@ -1,13 +1,14 @@
import Extend from 'flarum/common/extenders';
import app from 'flarum/admin/app';
import { version } from '../common/cdn';
app.initializers.add('flarum-emoji', () => {
app.extensionData.for('flarum-emoji').registerSetting({
export default [
new Extend.Admin().setting(() => ({
setting: 'flarum-emoji.cdn',
type: 'text',
label: app.translator.trans('flarum-emoji.admin.settings.cdn_label'),
help: app.translator.trans('flarum-emoji.admin.settings.cdn_help', {
version: version,
}),
});
});
})),
];

View File

@@ -0,0 +1,7 @@
import app from 'flarum/admin/app';
export { default as extend } from './extend';
app.initializers.add('flarum-emoji', () => {
// ...
});

View File

@@ -0,0 +1,15 @@
{
// Use Flarum's tsconfig as a starting point
"extends": "flarum-tsconfig",
// This will match all .ts, .tsx, .d.ts, .js, .jsx files in your `src` folder
// and also tells your Typescript server to read core's global typings for
// access to `dayjs` and `$` in the global namespace.
"include": ["src/**/*", "../../../*/*/js/dist-typings/@types/**/*", "@types/**/*"],
"compilerOptions": {
// This will output typings to `dist-typings`
"declarationDir": "./dist-typings",
"paths": {
"flarum/*": ["../../../framework/core/js/dist-typings/*"]
}
}
}

View File

@@ -0,0 +1,37 @@
import Extend from 'flarum/common/extenders';
import app from 'flarum/admin/app';
export default [
new Extend.Admin()
.setting(
() => ({
setting: 'flarum-flags.guidelines_url',
type: 'text',
label: app.translator.trans('flarum-flags.admin.settings.guidelines_url_label'),
}),
15
)
.setting(() => ({
setting: 'flarum-flags.can_flag_own',
type: 'boolean',
label: app.translator.trans('flarum-flags.admin.settings.flag_own_posts_label'),
}))
.permission(
() => ({
icon: 'fas fa-flag',
label: app.translator.trans('flarum-flags.admin.permissions.view_flags_label'),
permission: 'discussion.viewFlags',
}),
'moderate',
65
)
.permission(
() => ({
icon: 'fas fa-flag',
label: app.translator.trans('flarum-flags.admin.permissions.flag_posts_label'),
permission: 'discussion.flagPosts',
}),
'reply',
65
),
];

View File

@@ -1,38 +1,7 @@
import app from 'flarum/admin/app';
app.initializers.add('flarum-flags', () => {
app.extensionData
.for('flarum-flags')
.registerSetting(
{
setting: 'flarum-flags.guidelines_url',
type: 'text',
label: app.translator.trans('flarum-flags.admin.settings.guidelines_url_label'),
},
15
)
.registerSetting({
setting: 'flarum-flags.can_flag_own',
type: 'boolean',
label: app.translator.trans('flarum-flags.admin.settings.flag_own_posts_label'),
})
.registerPermission(
{
icon: 'fas fa-flag',
label: app.translator.trans('flarum-flags.admin.permissions.view_flags_label'),
permission: 'discussion.viewFlags',
},
'moderate',
65
)
export { default as extend } from './extend';
.registerPermission(
{
icon: 'fas fa-flag',
label: app.translator.trans('flarum-flags.admin.permissions.flag_posts_label'),
permission: 'discussion.flagPosts',
},
'reply',
65
);
app.initializers.add('flarum-flags', () => {
// ...
});

View File

@@ -1,20 +1,23 @@
import Extend from 'flarum/common/extenders';
import app from 'flarum/admin/app';
import commonExtend from '../common/extend';
app.initializers.add('flarum-likes', () => {
app.extensionData
.for('flarum-likes')
.registerPermission(
{
export default [
...commonExtend,
new Extend.Admin()
.permission(
() => ({
icon: 'far fa-thumbs-up',
label: app.translator.trans('flarum-likes.admin.permissions.like_posts_label'),
permission: 'discussion.likePosts',
},
}),
'reply'
)
.registerSetting({
.setting(() => ({
setting: 'flarum-likes.like_own_post',
type: 'bool',
label: app.translator.trans('flarum-likes.admin.settings.like_own_posts_label'),
help: app.translator.trans('flarum-likes.admin.settings.like_own_posts_help'),
});
});
})),
];

View File

@@ -0,0 +1,7 @@
import app from 'flarum/admin/app';
export { default as extend } from './extend';
app.initializers.add('flarum-likes', () => {
// ...
});

View File

@@ -1 +0,0 @@
export { default as default } from '../common/extend';

View File

@@ -1,15 +1,17 @@
import Extend from 'flarum/common/extenders';
import app from 'flarum/admin/app';
import commonExtend from '../common/extend';
export { default as extend } from './extend';
export default [
...commonExtend,
app.initializers.add('flarum-lock', () => {
app.extensionData.for('flarum-lock').registerPermission(
{
new Extend.Admin().permission(
() => ({
icon: 'fas fa-lock',
label: app.translator.trans('flarum-lock.admin.permissions.lock_discussions_label'),
permission: 'discussion.lock',
},
}),
'moderate',
95
);
});
),
];

View File

@@ -0,0 +1,7 @@
import app from 'flarum/admin/app';
export { default as extend } from './extend';
app.initializers.add('flarum-lock', () => {
// ...
});

View File

@@ -1,3 +1,23 @@
import Extend from 'flarum/common/extenders';
import app from 'flarum/admin/app';
import commonExtend from '../common/extend';
export default [...commonExtend];
export default [
...commonExtend,
new Extend.Admin()
.setting(() => ({
setting: 'flarum-mentions.allow_username_format',
type: 'boolean',
label: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_label'),
help: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_text'),
}))
.permission(
() => ({
permission: 'mentionGroups',
label: app.translator.trans('flarum-mentions.admin.permissions.mention_groups_label'),
icon: 'fas fa-at',
}),
'start'
),
];

View File

@@ -1,22 +0,0 @@
import app from 'flarum/admin/app';
export { default as extend } from './extend';
app.initializers.add('flarum-mentions', () => {
app.extensionData
.for('flarum-mentions')
.registerSetting({
setting: 'flarum-mentions.allow_username_format',
type: 'boolean',
label: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_label'),
help: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_text'),
})
.registerPermission(
{
permission: 'mentionGroups',
label: app.translator.trans('flarum-mentions.admin.permissions.mention_groups_label'),
icon: 'fas fa-at',
},
'start'
);
});

View File

@@ -0,0 +1,7 @@
import app from 'flarum/admin/app';
export { default as extend } from './extend';
app.initializers.add('flarum-mentions', () => {
// ...
});

View File

@@ -0,0 +1,58 @@
import Extend from 'flarum/common/extenders';
import app from 'flarum/admin/app';
import Alert from 'flarum/common/components/Alert';
import Link from 'flarum/common/components/Link';
export default [
new Extend.Admin()
.customSetting(function () {
if (app.data.settings.display_name_driver === 'nickname') return;
return (
<div className="Form-group">
<Alert dismissible={false}>
{app.translator.trans('flarum-nicknames.admin.wrong_driver', { a: <Link href={app.route('basics')}></Link> })}
</Alert>
</div>
);
})
.setting(() => ({
setting: 'flarum-nicknames.set_on_registration',
type: 'boolean',
label: app.translator.trans('flarum-nicknames.admin.settings.set_on_registration_label'),
}))
.setting(() => ({
setting: 'flarum-nicknames.random_username',
type: 'boolean',
label: app.translator.trans('flarum-nicknames.admin.settings.random_username_label'),
help: app.translator.trans('flarum-nicknames.admin.settings.random_username_help'),
}))
.setting(() => ({
setting: 'flarum-nicknames.unique',
type: 'boolean',
label: app.translator.trans('flarum-nicknames.admin.settings.unique_label'),
}))
.setting(() => ({
setting: 'flarum-nicknames.regex',
type: 'text',
label: app.translator.trans('flarum-nicknames.admin.settings.regex_label'),
}))
.setting(() => ({
setting: 'flarum-nicknames.min',
type: 'number',
label: app.translator.trans('flarum-nicknames.admin.settings.min_label'),
}))
.setting(() => ({
setting: 'flarum-nicknames.max',
type: 'number',
label: app.translator.trans('flarum-nicknames.admin.settings.max_label'),
}))
.permission(
() => ({
icon: 'fas fa-user-tag',
label: app.translator.trans('flarum-nicknames.admin.permissions.edit_own_nickname_label'),
permission: 'user.editOwnNickname',
}),
'start'
),
];

View File

@@ -1,64 +1,11 @@
import app from 'flarum/admin/app';
import Alert from 'flarum/common/components/Alert';
import Link from 'flarum/common/components/Link';
import BasicsPage from 'flarum/admin/components/BasicsPage';
import extractText from 'flarum/common/utils/extractText';
import { extend } from 'flarum/common/extend';
export { default as extend } from './extend';
app.initializers.add('flarum-nicknames', () => {
app.extensionData
.for('flarum-nicknames')
.registerSetting(function () {
if (app.data.settings.display_name_driver === 'nickname') return;
return (
<div className="Form-group">
<Alert dismissible={false}>
{app.translator.trans('flarum-nicknames.admin.wrong_driver', { a: <Link href={app.route('basics')}></Link> })}
</Alert>
</div>
);
})
.registerSetting({
setting: 'flarum-nicknames.set_on_registration',
type: 'boolean',
label: app.translator.trans('flarum-nicknames.admin.settings.set_on_registration_label'),
})
.registerSetting({
setting: 'flarum-nicknames.random_username',
type: 'boolean',
label: app.translator.trans('flarum-nicknames.admin.settings.random_username_label'),
help: app.translator.trans('flarum-nicknames.admin.settings.random_username_help'),
})
.registerSetting({
setting: 'flarum-nicknames.unique',
type: 'boolean',
label: app.translator.trans('flarum-nicknames.admin.settings.unique_label'),
})
.registerSetting({
setting: 'flarum-nicknames.regex',
type: 'text',
label: app.translator.trans('flarum-nicknames.admin.settings.regex_label'),
})
.registerSetting({
setting: 'flarum-nicknames.min',
type: 'number',
label: app.translator.trans('flarum-nicknames.admin.settings.min_label'),
})
.registerSetting({
setting: 'flarum-nicknames.max',
type: 'number',
label: app.translator.trans('flarum-nicknames.admin.settings.max_label'),
})
.registerPermission(
{
icon: 'fas fa-user-tag',
label: app.translator.trans('flarum-nicknames.admin.permissions.edit_own_nickname_label'),
permission: 'user.editOwnNickname',
},
'start'
);
extend(BasicsPage.prototype, 'driverLocale', function (locale) {
locale.display_name['nickname'] = extractText(app.translator.trans('flarum-nicknames.admin.basics.display_name_driver_options.nickname'));
});

View File

@@ -12,7 +12,7 @@ import ConfigureAuth from './ConfigureAuth';
export default class SettingsPage extends ExtensionPage {
content() {
const settings = app.extensionData.getSettings(this.extension.id);
const settings = app.registry.getSettings(this.extension.id);
const warnings = [app.translator.trans('flarum-extension-manager.admin.settings.access_warning')];

View File

@@ -0,0 +1,31 @@
import Extend from 'flarum/common/extenders';
import app from 'flarum/admin/app';
import extractText from 'flarum/common/utils/extractText';
import SettingsPage from './components/SettingsPage';
export default [
new Extend.Admin()
.setting(() => ({
setting: 'flarum-extension-manager.queue_jobs',
label: app.translator.trans('flarum-extension-manager.admin.settings.queue_jobs'),
help: m.trust(
extractText(
app.translator.trans('flarum-extension-manager.admin.settings.queue_jobs_help', {
basic_impl_link: 'https://discuss.flarum.org/d/28151-database-queue-the-simplest-queue-even-for-shared-hosting',
adv_impl_link: 'https://discuss.flarum.org/d/21873-redis-sessions-cache-queues',
php_version: `<strong>${app.data.phpVersion}</strong>`,
folder_perms_link: 'https://docs.flarum.org/install#folder-ownership',
})
)
),
type: 'boolean',
disabled: app.data['flarum-extension-manager.using_sync_queue'],
}))
.setting(() => ({
setting: 'flarum-extension-manager.task_retention_days',
label: app.translator.trans('flarum-extension-manager.admin.settings.task_retention_days'),
help: app.translator.trans('flarum-extension-manager.admin.settings.task_retention_days_help'),
type: 'number',
}))
.page(SettingsPage),
];

View File

@@ -4,13 +4,13 @@ import ExtensionPage from 'flarum/admin/components/ExtensionPage';
import Button from 'flarum/common/components/Button';
import LoadingModal from 'flarum/admin/components/LoadingModal';
import isExtensionEnabled from 'flarum/admin/utils/isExtensionEnabled';
import SettingsPage from './components/SettingsPage';
import Task from './models/Task';
import jumpToQueue from './utils/jumpToQueue';
import extractText from 'flarum/common/utils/extractText';
import { AsyncBackendResponse } from './shims';
import ExtensionManagerState from './states/ExtensionManagerState';
export { default as extend } from './extend';
app.initializers.add('flarum-extension-manager', (app) => {
app.store.models['extension-manager-tasks'] = Task;
@@ -20,32 +20,6 @@ app.initializers.add('flarum-extension-manager', (app) => {
app.data.settings['flarum-extension-manager.queue_jobs'] = '0';
}
app.extensionData
.for('flarum-extension-manager')
.registerSetting({
setting: 'flarum-extension-manager.queue_jobs',
label: app.translator.trans('flarum-extension-manager.admin.settings.queue_jobs'),
help: m.trust(
extractText(
app.translator.trans('flarum-extension-manager.admin.settings.queue_jobs_help', {
basic_impl_link: 'https://discuss.flarum.org/d/28151-database-queue-the-simplest-queue-even-for-shared-hosting',
adv_impl_link: 'https://discuss.flarum.org/d/21873-redis-sessions-cache-queues',
php_version: `<strong>${app.data.phpVersion}</strong>`,
folder_perms_link: 'https://docs.flarum.org/install#folder-ownership',
})
)
),
type: 'boolean',
disabled: app.data['flarum-extension-manager.using_sync_queue'],
})
.registerSetting({
setting: 'flarum-extension-manager.task_retention_days',
label: app.translator.trans('flarum-extension-manager.admin.settings.task_retention_days'),
help: app.translator.trans('flarum-extension-manager.admin.settings.task_retention_days_help'),
type: 'number',
})
.registerPage(SettingsPage);
extend(ExtensionPage.prototype, 'topItems', function (items) {
if (this.extension.id === 'flarum-extension-manager' || isExtensionEnabled(this.extension.id)) {
return;

View File

@@ -0,0 +1,38 @@
import Extend from 'flarum/common/extenders';
import app from 'flarum/admin/app';
export default [
new Extend.Admin()
.setting(
() => ({
setting: 'flarum-pusher.app_id',
label: app.translator.trans('flarum-pusher.admin.pusher_settings.app_id_label'),
type: 'text',
}),
30
)
.setting(
() => ({
setting: 'flarum-pusher.app_key',
label: app.translator.trans('flarum-pusher.admin.pusher_settings.app_key_label'),
type: 'text',
}),
20
)
.setting(
() => ({
setting: 'flarum-pusher.app_secret',
label: app.translator.trans('flarum-pusher.admin.pusher_settings.app_secret_label'),
type: 'text',
}),
10
)
.setting(
() => ({
setting: 'flarum-pusher.app_cluster',
label: app.translator.trans('flarum-pusher.admin.pusher_settings.app_cluster_label'),
type: 'text',
}),
0
),
];

View File

@@ -1,38 +1,7 @@
import app from 'flarum/admin/app';
export { default as extend } from './extend';
app.initializers.add('flarum-pusher', () => {
app.extensionData
.for('flarum-pusher')
.registerSetting(
{
setting: 'flarum-pusher.app_id',
label: app.translator.trans('flarum-pusher.admin.pusher_settings.app_id_label'),
type: 'text',
},
30
)
.registerSetting(
{
setting: 'flarum-pusher.app_key',
label: app.translator.trans('flarum-pusher.admin.pusher_settings.app_key_label'),
type: 'text',
},
20
)
.registerSetting(
{
setting: 'flarum-pusher.app_secret',
label: app.translator.trans('flarum-pusher.admin.pusher_settings.app_secret_label'),
type: 'text',
},
10
)
.registerSetting(
{
setting: 'flarum-pusher.app_cluster',
label: app.translator.trans('flarum-pusher.admin.pusher_settings.app_cluster_label'),
type: 'text',
},
0
);
// ...
});

View File

@@ -0,0 +1,4 @@
import Extend from 'flarum/common/extenders';
import StatisticsPage from './components/StatisticsPage';
export default [new Extend.Admin().page(StatisticsPage)];

View File

@@ -1,15 +1,13 @@
import app from 'flarum/admin/app';
import { extend } from 'flarum/common/extend';
import DashboardPage from 'flarum/admin/components/DashboardPage';
import MiniStatisticsWidget from './components/MiniStatisticsWidget';
import StatisticsPage from './components/StatisticsPage';
export { default as extend } from './extend';
app.initializers.add('flarum-statistics', () => {
extend(DashboardPage.prototype, 'availableWidgets', function (widgets) {
widgets.add('statistics', <MiniStatisticsWidget />, 20);
});
app.extensionData.for('flarum-statistics').registerPage(StatisticsPage);
});

View File

@@ -1 +0,0 @@
export { default as default } from '../common/extend';

View File

@@ -1,15 +1,17 @@
import Extend from 'flarum/common/extenders';
import app from 'flarum/admin/app';
import commonExtend from '../common/extend';
export { default as extend } from './extend';
export default [
...commonExtend,
app.initializers.add('flarum-sticky', () => {
app.extensionData.for('flarum-sticky').registerPermission(
{
new Extend.Admin().permission(
() => ({
icon: 'fas fa-thumbtack',
label: app.translator.trans('flarum-sticky.admin.permissions.sticky_discussions_label'),
permission: 'discussion.sticky',
},
}),
'moderate',
95
);
});
),
];

View File

@@ -0,0 +1,7 @@
import app from 'flarum/admin/app';
export { default as extend } from './extend';
app.initializers.add('flarum-sticky', () => {
// ...
});

View File

@@ -1 +0,0 @@
export { default as default } from '../common/extend';

View File

@@ -1,14 +1,16 @@
import Extend from 'flarum/common/extenders';
import app from 'flarum/admin/app';
import commonExtend from '../common/extend';
export { default as extend } from './extend';
export default [
...commonExtend,
app.initializers.add('flarum-suspend', () => {
app.extensionData.for('flarum-suspend').registerPermission(
{
new Extend.Admin().permission(
() => ({
icon: 'fas fa-ban',
label: app.translator.trans('flarum-suspend.admin.permissions.suspend_users_label'),
permission: 'user.suspend',
},
}),
'moderate'
);
});
),
];

View File

@@ -0,0 +1,7 @@
import app from 'flarum/admin/app';
export { default as extend } from './extend';
app.initializers.add('flarum-suspend', () => {
// ...
});

View File

@@ -2,7 +2,7 @@ import { extend } from 'flarum/common/extend';
import BasicsPage from 'flarum/admin/components/BasicsPage';
export default function () {
extend(BasicsPage.prototype, 'homePageItems', (items) => {
extend(BasicsPage, 'homePageItems', (items) => {
items.add('tags', {
path: '/tags',
label: app.translator.trans('flarum-tags.admin.basics.tags_label'),

View File

@@ -5,5 +5,4 @@ import './components/EditTagModal';
import './addTagsHomePageOption';
import './addTagChangePermission';
import './addTagPermission';
import './addTagsPermissionScope';

View File

@@ -1,22 +1,29 @@
export default function () {
app.extensionData
.for('flarum-tags')
.registerPermission(
{
import Extend from 'flarum/common/extenders';
import commonExtend from '../common/extend';
import app from 'flarum/admin/app';
import TagsPage from './components/TagsPage';
export default [
...commonExtend,
new Extend.Admin()
.page(TagsPage)
.permission(
() => ({
icon: 'fas fa-tag',
label: app.translator.trans('flarum-tags.admin.permissions.tag_discussions_label'),
permission: 'discussion.tag',
},
}),
'moderate',
95
)
.registerPermission(
{
.permission(
() => ({
icon: 'fas fa-tags',
label: app.translator.trans('flarum-tags.admin.permissions.bypass_tag_counts_label'),
permission: 'bypassTagCounts',
},
}),
'start',
89
);
}
),
];

View File

@@ -1,21 +1,16 @@
import app from 'flarum/admin/app';
import addTagsPermissionScope from './addTagsPermissionScope';
import addTagPermission from './addTagPermission';
import addTagsHomePageOption from './addTagsHomePageOption';
import addTagChangePermission from './addTagChangePermission';
import addTagSelectionSettingComponent from './addTagSelectionSettingComponent';
import TagsPage from './components/TagsPage';
import TagListState from '../common/states/TagListState';
export { default as extend } from '../common/extend';
export { default as extend } from './extend';
app.initializers.add('flarum-tags', (app) => {
app.tagList = new TagListState();
app.extensionData.for('flarum-tags').registerPage(TagsPage);
addTagsPermissionScope();
addTagPermission();
addTagsHomePageOption();
addTagChangePermission();
addTagSelectionSettingComponent();

View File

@@ -4,10 +4,17 @@ import routes, { AdminRoutes } from './routes';
import Application, { ApplicationData } from '../common/Application';
import Navigation from '../common/components/Navigation';
import AdminNav from './components/AdminNav';
import ExtensionData from './utils/ExtensionData';
import AdminRegistry from './utils/AdminRegistry';
import IHistory from '../common/IHistory';
import SearchManager from '../common/SearchManager';
import SearchState from '../common/states/SearchState';
import app from './app';
import BasicsPage from './components/BasicsPage';
import GeneralSearchIndex from './states/GeneralSearchIndex';
import AppearancePage from './components/AppearancePage';
import MailPage from './components/MailPage';
import AdvancedPage from './components/AdvancedPage';
import PermissionsPage from './components/PermissionsPage';
export type Extension = {
id: string;
@@ -32,6 +39,7 @@ export type Extension = {
extra: {
'flarum-extension': {
title: string;
category?: string;
'database-support'?: string[];
};
};
@@ -66,7 +74,13 @@ export interface AdminApplicationData extends ApplicationData {
}
export default class AdminApplication extends Application {
extensionData = new ExtensionData();
/**
* Stores the available settings, permissions, and custom pages of the app.
* Allows the global search to find these items.
*
* @internal
*/
registry = new AdminRegistry();
extensionCategories = {
feature: 30,
@@ -88,6 +102,12 @@ export default class AdminApplication extends Application {
search: SearchManager<SearchState> = new SearchManager(new SearchState());
/**
* Custom settings and custom permissions do not go through the registry.
* The general index is used to manually add these items to be picked up by the search.
*/
generalIndex: GeneralSearchIndex = new GeneralSearchIndex();
/**
* Settings are serialized to the admin dashboard as strings.
* Additional encoding/decoding is possible, but must take
@@ -108,6 +128,14 @@ export default class AdminApplication extends Application {
this.route = (Object.getPrototypeOf(Object.getPrototypeOf(this)) as Application).route.bind(this);
}
protected beforeMount(): void {
BasicsPage.register();
AppearancePage.register();
MailPage.register();
AdvancedPage.register();
PermissionsPage.register();
}
/**
* @inheritdoc
*/

View File

@@ -1,7 +1,7 @@
import '../common/common';
import './utils/saveSettings';
import './utils/ExtensionData';
import './utils/AdminRegistry';
import './utils/isExtensionEnabled';
import './utils/getCategorizedExtensions';

View File

@@ -168,7 +168,7 @@ export default abstract class AdminPage<CustomAttrs extends IPageAttrs = IPageAt
this.refreshAfterSaving.push(setting);
}
return <FormGroup stream={bidi} {...attrs} />;
return <FormGroup stream={bidi} getSetting={this.setting.bind(this)} {...attrs} />;
}
/**
@@ -243,7 +243,7 @@ export default abstract class AdminPage<CustomAttrs extends IPageAttrs = IPageAt
.catch(this.onsavefailed.bind(this));
}
modelLocale(): Record<string, string> {
static modelLocale(): Record<string, string> {
return {
'Flarum\\Discussion\\Discussion': extractText(app.translator.trans('core.admin.models.discussions')),
'Flarum\\User\\User': extractText(app.translator.trans('core.admin.models.users')),

View File

@@ -86,7 +86,7 @@ export default class AdvancedPage<CustomAttrs extends IPageAttrs = IPageAttrs> e
<Form>
{Object.keys(this.searchDriverOptions).map((model) => {
const options = this.searchDriverOptions[model];
const modelLocale = this.modelLocale()[model] || model;
const modelLocale = AdminPage.modelLocale()[model] || model;
if (Object.keys(options).length > 1) {
return this.buildSettingComponent({
@@ -208,4 +208,32 @@ export default class AdvancedPage<CustomAttrs extends IPageAttrs = IPageAttrs> e
</FormSection>
);
}
static register() {
app.generalIndex.group('core-advanced', {
label: app.translator.trans('core.admin.advanced.title', {}, true),
icon: {
name: 'fas fa-cog',
},
link: app.route('advanced'),
});
app.generalIndex.for('core-advanced').add('settings', [
{
id: 'maintenance_mode',
label: app.translator.trans('core.admin.advanced.maintenance.section_label', {}, true),
help: app.translator.trans('core.admin.advanced.maintenance.help', {}, true),
},
{
id: 'safe_mode_extensions',
label: app.translator.trans('core.admin.advanced.maintenance.safe_mode_extensions', {}, true),
visible: () => app.data.maintenanceMode === MaintenanceMode.SAFE_MODE,
},
{
id: 'extension_bisect',
label: app.translator.trans('core.admin.advanced.maintenance.bisect.label', {}, true),
help: app.translator.trans('core.admin.advanced.maintenance.bisect.help', {}, true),
},
]);
}
}

View File

@@ -147,4 +147,55 @@ export default class AppearancePage extends AdminPage {
onsaved() {
window.location.reload();
}
static register() {
app.generalIndex.group('core-appearance', {
label: app.translator.trans('core.admin.appearance.title', {}, true),
icon: {
name: 'fas fa-paint-brush',
},
link: app.route('appearance'),
});
app.generalIndex.for('core-appearance').add('settings', [
{
id: 'colors_heading',
label: app.translator.trans('core.admin.appearance.colors_heading', {}, true),
help: app.translator.trans('core.admin.appearance.colors_text', {}, true),
},
{
id: 'color_scheme',
label: app.translator.trans('core.admin.appearance.color_scheme_label', {}, true),
},
{
id: 'colored_header',
label: app.translator.trans('core.admin.appearance.colored_header_label', {}, true),
},
{
id: 'logo_heading',
label: app.translator.trans('core.admin.appearance.logo_heading', {}, true),
help: app.translator.trans('core.admin.appearance.logo_text', {}, true),
},
{
id: 'favicon_heading',
label: app.translator.trans('core.admin.appearance.favicon_heading', {}, true),
help: app.translator.trans('core.admin.appearance.favicon_text', {}, true),
},
{
id: 'custom_header_heading',
label: app.translator.trans('core.admin.appearance.custom_header_heading', {}, true),
help: app.translator.trans('core.admin.appearance.custom_header_text', {}, true),
},
{
id: 'custom_footer_heading',
label: app.translator.trans('core.admin.appearance.custom_footer_heading', {}, true),
help: app.translator.trans('core.admin.appearance.custom_footer_text', {}, true),
},
{
id: 'custom_styles_heading',
label: app.translator.trans('core.admin.appearance.custom_styles_heading', {}, true),
help: app.translator.trans('core.admin.appearance.custom_styles_text', {}, true),
},
]);
}
}

View File

@@ -14,30 +14,8 @@ export type DriverLocale = {
};
export default class BasicsPage<CustomAttrs extends IPageAttrs = IPageAttrs> extends AdminPage<CustomAttrs> {
localeOptions: Record<string, string> = {};
displayNameOptions: Record<string, string> = {};
slugDriverOptions: Record<string, Record<string, string>> = {};
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode);
Object.keys(app.data.locales).forEach((i) => {
this.localeOptions[i] = `${app.data.locales[i]} (${i})`;
});
const driverLocale = this.driverLocale();
app.data.displayNameDrivers.forEach((identifier) => {
this.displayNameOptions[identifier] = driverLocale.display_name[identifier] || identifier;
});
Object.keys(app.data.slugDrivers).forEach((model) => {
this.slugDriverOptions[model] = {};
app.data.slugDrivers[model].forEach((option) => {
this.slugDriverOptions[model][option] = (driverLocale.slug[model] && driverLocale.slug[model][option]) || option;
});
});
}
headerInfo() {
@@ -50,86 +28,11 @@ export default class BasicsPage<CustomAttrs extends IPageAttrs = IPageAttrs> ext
}
content() {
const settings = app.registry.getSettings('core-basics');
return [
<Form>
{this.buildSettingComponent({
type: 'text',
setting: 'forum_title',
label: app.translator.trans('core.admin.basics.forum_title_heading'),
})}
{this.buildSettingComponent({
type: 'text',
setting: 'forum_description',
label: app.translator.trans('core.admin.basics.forum_description_heading'),
help: app.translator.trans('core.admin.basics.forum_description_text'),
})}
{Object.keys(this.localeOptions).length > 1 && (
<>
{this.buildSettingComponent({
type: 'select',
setting: 'default_locale',
options: this.localeOptions,
label: app.translator.trans('core.admin.basics.default_language_heading'),
})}
{this.buildSettingComponent({
type: 'switch',
setting: 'show_language_selector',
label: app.translator.trans('core.admin.basics.show_language_selector_label'),
})}
</>
)}
<FieldSet
className="BasicsPage-homePage Form-group"
label={app.translator.trans('core.admin.basics.home_page_heading')}
description={app.translator.trans('core.admin.basics.home_page_text')}
>
{this.homePageItems()
.toArray()
.map(({ path, label }) => (
<label className="checkbox">
<input type="radio" name="homePage" value={path} bidi={this.setting('default_route')} />
{label}
</label>
))}
</FieldSet>
<div className="Form-group BasicsPage-welcomeBanner-input">
<label>{app.translator.trans('core.admin.basics.welcome_banner_heading')}</label>
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>
<div className="StackedFormControl">
<input type="text" className="FormControl" bidi={this.setting('welcome_title')} />
<textarea className="FormControl" bidi={this.setting('welcome_message')} cols={80} rows={6} />
</div>
</div>
{Object.keys(this.displayNameOptions).length > 1 &&
this.buildSettingComponent({
type: 'select',
setting: 'display_name_driver',
options: this.displayNameOptions,
label: app.translator.trans('core.admin.basics.display_name_heading'),
help: app.translator.trans('core.admin.basics.display_name_text'),
})}
{Object.keys(this.slugDriverOptions).map((model) => {
const options = this.slugDriverOptions[model];
const modelLocale = this.modelLocale()[model] || model;
if (Object.keys(options).length > 1) {
return this.buildSettingComponent({
type: 'select',
setting: `slug_driver_${model}`,
options,
label: app.translator.trans('core.admin.basics.slug_driver_heading', { model: modelLocale }),
help: app.translator.trans('core.admin.basics.slug_driver_text', { model: modelLocale }),
});
}
return null;
})}
{settings?.map(this.buildSettingComponent.bind(this))}
<div className="Form-group Form-controls">{this.submitButton()}</div>
</Form>,
];
@@ -139,7 +42,7 @@ export default class BasicsPage<CustomAttrs extends IPageAttrs = IPageAttrs> ext
* Build a list of options for the default homepage. Each option must be an
* object with `path` and `label` properties.
*/
homePageItems() {
static homePageItems() {
const items = new ItemList<HomePageItem>();
items.add('allDiscussions', {
@@ -150,7 +53,7 @@ export default class BasicsPage<CustomAttrs extends IPageAttrs = IPageAttrs> ext
return items;
}
driverLocale(): DriverLocale {
static driverLocale(): DriverLocale {
return {
display_name: {
username: extractText(app.translator.trans('core.admin.basics.display_name_driver_options.username')),
@@ -167,4 +70,118 @@ export default class BasicsPage<CustomAttrs extends IPageAttrs = IPageAttrs> ext
},
};
}
static register() {
app.generalIndex.group('core-basics', {
label: app.translator.trans('core.admin.basics.title', {}, true),
icon: {
name: 'fas fa-pencil-alt',
},
link: app.route('basics'),
});
const localeOptions: Record<string, string> = {};
const displayNameOptions: Record<string, string> = {};
const slugDriverOptions: Record<string, Record<string, string>> = {};
const driverLocale = BasicsPage.driverLocale();
Object.keys(app.data.locales).forEach((i) => {
localeOptions[i] = `${app.data.locales[i]} (${i})`;
});
app.data.displayNameDrivers.forEach((identifier) => {
displayNameOptions[identifier] = driverLocale.display_name[identifier] || identifier;
});
Object.keys(app.data.slugDrivers).forEach((model) => {
slugDriverOptions[model] = {};
app.data.slugDrivers[model].forEach((option) => {
slugDriverOptions[model][option] = (driverLocale.slug[model] && driverLocale.slug[model][option]) || option;
});
});
app.registry.for('core-basics');
app.registry
.registerSetting({
type: 'text',
setting: 'forum_title',
label: app.translator.trans('core.admin.basics.forum_title_heading'),
})
.registerSetting({
type: 'text',
setting: 'forum_description',
label: app.translator.trans('core.admin.basics.forum_description_heading'),
help: app.translator.trans('core.admin.basics.forum_description_text'),
});
if (Object.keys(localeOptions).length > 1) {
app.registry
.registerSetting({
type: 'select',
setting: 'default_locale',
options: localeOptions,
label: app.translator.trans('core.admin.basics.default_language_heading'),
})
.registerSetting({
type: 'switch',
setting: 'show_language_selector',
label: app.translator.trans('core.admin.basics.show_language_selector_label'),
});
}
app.registry
.registerSetting({
type: 'radio',
setting: 'default_route',
options: BasicsPage.homePageItems()
.toArray()
.map((item: HomePageItem) => ({
...item,
value: item.path,
})),
label: app.translator.trans('core.admin.basics.home_page_heading', {}, true),
help: app.translator.trans('core.admin.basics.home_page_text', {}, true),
containerClassName: 'BasicsPage-homePage',
})
.registerSetting({
type: 'stacked-text',
setting: 'welcome_title',
textArea: {
setting: 'welcome_message',
cols: 80,
rows: 6,
},
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
help: app.translator.trans('core.admin.basics.welcome_banner_text'),
containerClassName: 'BasicsPage-welcomeBanner-input',
});
if (Object.keys(displayNameOptions).length > 1) {
app.registry.registerSetting({
type: 'select',
setting: 'display_name_driver',
options: displayNameOptions,
label: app.translator.trans('core.admin.basics.display_name_heading'),
help: app.translator.trans('core.admin.basics.display_name_text'),
});
}
Object.keys(slugDriverOptions).forEach((model) => {
const options = slugDriverOptions[model];
const modelLocale = AdminPage.modelLocale()[model] || model;
if (Object.keys(options).length > 1) {
app.registry.registerSetting({
type: 'select',
setting: `slug_driver_${model}`,
options,
label: app.translator.trans('core.admin.basics.slug_driver_heading', { model: modelLocale }),
help: app.translator.trans('core.admin.basics.slug_driver_text', { model: modelLocale }),
});
}
});
}
}

View File

@@ -149,7 +149,7 @@ export default class ExtensionPage<Attrs extends ExtensionPageAttrs = ExtensionP
</div>
</div>
<div className="container">
{app.extensionData.extensionHasPermissions(this.extension.id) ? (
{app.registry.extensionHasPermissions(this.extension.id) ? (
<ExtensionPermissionGrid extensionId={this.extension.id} />
) : (
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_permissions')}</h3>
@@ -162,7 +162,7 @@ export default class ExtensionPage<Attrs extends ExtensionPageAttrs = ExtensionP
}
content(vnode: Mithril.VnodeDOM<Attrs, this>) {
const settings = app.extensionData.getSettings(this.extension.id);
const settings = app.registry.getSettings(this.extension.id);
return (
<div className="ExtensionPage-settings">

View File

@@ -34,19 +34,19 @@ export default class ExtensionPermissionGrid<
}
viewItems() {
return app.extensionData.getExtensionPermissions(this.extensionId, 'view') || new ItemList();
return app.registry.getExtensionPermissions(this.extensionId, 'view') || new ItemList();
}
startItems() {
return app.extensionData.getExtensionPermissions(this.extensionId, 'start') || new ItemList();
return app.registry.getExtensionPermissions(this.extensionId, 'start') || new ItemList();
}
replyItems() {
return app.extensionData.getExtensionPermissions(this.extensionId, 'reply') || new ItemList();
return app.registry.getExtensionPermissions(this.extensionId, 'reply') || new ItemList();
}
moderateItems() {
return app.extensionData.getExtensionPermissions(this.extensionId, 'moderate') || new ItemList();
return app.registry.getExtensionPermissions(this.extensionId, 'moderate') || new ItemList();
}
scopeControlItems() {

View File

@@ -0,0 +1,242 @@
import type Mithril from 'mithril';
import app from '../app';
import highlight from '../../common/helpers/highlight';
import type { SearchSource } from './Search';
import extractText from '../../common/utils/extractText';
import Link from '../../common/components/Link';
import Icon from '../../common/components/Icon';
import PermissionGrid from './PermissionGrid';
import escapeRegExp from '../../common/utils/escapeRegExp';
import { GeneralIndexData, GeneralIndexItem } from '../states/GeneralSearchIndex';
import { ExtensionConfig, SettingConfigInternal } from '../utils/AdminRegistry';
import ItemList from '../../common/utils/ItemList';
export class GeneralSearchResult {
constructor(
public id: string,
public category: string,
public icon: { name: string; [key: string]: any },
public tree: string[],
public link: string,
public help?: string
) {}
}
/**
* Finds and displays settings, permissions and installed extensions (i.e. general search results) in the search dropdown.
*/
export default class GeneralSearchSource implements SearchSource {
protected results = new Map<string, GeneralSearchResult[]>();
public resource: string = 'general';
title(): string {
return extractText(app.translator.trans('core.admin.search_source.general.heading'));
}
isCached(query: string): boolean {
return this.results.has(query.toLowerCase());
}
async search(query: string, limit: number): Promise<void> {
query = query.toLowerCase();
return new Promise((resolve) => {
const results: GeneralSearchResult[] = [];
// extensions.
for (const extensionId in app.data.extensions) {
const extension = app.data.extensions[extensionId];
const title = extension.extra['flarum-extension'].title || extensionId.replace('core-', '');
const icon = extension.icon || { name: 'fas fa-cog' };
const category = extension.extra['flarum-extension'].category || 'other';
if (this.itemHasQuery(title, query)) {
results.push(
new GeneralSearchResult(
extensionId,
app.translator.trans('core.admin.nav.categories.' + category, {}, true),
icon,
[title],
app.route('extension', { id: extensionId })
)
);
}
}
// extension registered settings && permissions
results.push(...this.lookup(app.registry.getData(), query));
// manually registered settings && permissions into the search index.
results.push(...this.lookup(app.generalIndex.getData(), query));
this.results.set(query, results);
m.redraw();
resolve();
});
}
protected lookup(
data:
| GeneralIndexData
| {
[key: string]: ExtensionConfig | undefined;
},
query: string
): GeneralSearchResult[] {
const extensions = app.data.extensions;
const permissionItems = PermissionGrid.prototype.permissionItems();
const results: GeneralSearchResult[] = [];
for (const extensionId in data) {
// settings
const settings = data[extensionId]!.settings;
let normalizedSettings: GeneralIndexItem[] | SettingConfigInternal[] = [];
if (settings instanceof ItemList) {
normalizedSettings = settings?.toArray();
} else if (settings) {
normalizedSettings = settings;
}
for (const setting of normalizedSettings) {
if ('visible' in setting && !setting.visible()) {
continue;
}
const label = 'label' in setting ? extractText(setting.label) : '';
const help = 'help' in setting ? extractText(setting.help) : '';
const corePage = !extensions[extensionId] ? extensionId.replace('core-', '') : null;
const group = app.generalIndex.getGroup(extensionId);
if (this.itemHasQuery(label, query) || this.itemHasQuery(help, query)) {
const id = extensionId + '-' + ('setting' in setting ? setting : setting.id);
results.push(
new GeneralSearchResult(
id,
group?.label ||
extensions[extensionId]?.extra['flarum-extension'].title ||
app.translator.trans('core.admin.' + corePage + '.title', {}, true),
group?.icon || extensions[extensionId]?.icon || { name: 'fas fa-cog' },
'tree' in setting && setting.tree ? setting.tree.map(extractText).push(label) : [label],
group?.link || (corePage ? app.route(corePage) : app.route('extension', { id: extensionId })),
help
)
);
}
}
// permissions
const permissions = data[extensionId]!.permissions || {};
for (const permissionType in permissions) {
// @ts-ignore
const permissionList = (permissions[permissionType] || []).toArray();
for (const permission of permissionList) {
const label = extractText(permission.label);
if (this.itemHasQuery(label, query)) {
const id = extensionId + '-' + permissionType + '-' + permission.permission;
const corePage = !extensions[extensionId] ? extensionId.replace('core-', '') : null;
const group = app.generalIndex.getGroup(extensionId);
results.push(
new GeneralSearchResult(
id,
group?.label ||
extensions[extensionId]?.extra['flarum-extension'].title ||
app.translator.trans('core.admin.' + corePage + '.title', {}, true),
group?.icon || extensions[extensionId]?.icon || { name: 'fas fa-key' },
[
app.translator.trans('core.admin.permissions.title', {}, true),
extractText(permissionItems.get(permissionType)?.label) || permissionType,
label,
],
group?.link || (corePage ? app.route(corePage) : app.route('extension', { id: extensionId }))
)
);
}
}
}
}
return results;
}
protected itemHasQuery(item: string, query: string): boolean {
return query.split(' ').every((part) => item.toLowerCase().includes(part));
}
view(query: string): Array<Mithril.Vnode> {
const results = (this.results.get(query) || []).slice(0, 30);
const categories = Array.from(new Set([...results.map((r) => r.category)]));
if (!categories.length) return [];
return categories.map((category) => {
return (
<>
<li className="GeneralSearchResult-group Dropdown-header">{category}</li>
{results
.filter((r) => r.category === category)
.map((result) => {
const phrase = escapeRegExp(query);
const highlightRegExp = new RegExp(phrase + '|' + phrase.trim().replace(/\s+/g, '|'), 'gi');
const tree: any[] = result.tree.map((part) => {
return highlight(part, highlightRegExp, undefined, true);
});
let help: any = result.help;
if (help) {
help = highlight(help, highlightRegExp, 100, true);
}
return (
<li className="GeneralSearchResult" data-index={'general-' + result.id} data-id={result.id}>
<Link href={result.link}>
<div className="ExtensionIcon" style={result.icon}>
<Icon name={result.icon.name} />
</div>
<div className="GeneralSearchResult-info">
<div className="GeneralSearchResult-tree">
{tree.map((part, i) => {
return [
<span>{part}</span>,
i < tree.length - 1 ? (
<span className="GeneralSearchResult-tree-separator">
<Icon name="fas fa-arrow-right" />
</span>
) : null,
];
})}
</div>
{help ? <div className="GeneralSearchResult-help">{help}</div> : null}
</div>
</Link>
</li>
);
})}
</>
);
});
}
customGrouping(): boolean {
return true;
}
fullPage(query: string): null {
return null;
}
gotoItem(id: string): string | null {
return null;
}
}

View File

@@ -1,9 +1,11 @@
import app from '../../admin/app';
import app from '../app';
import Component from '../../common/Component';
import LinkButton from '../../common/components/LinkButton';
import SessionDropdown from './SessionDropdown';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
import type Mithril from 'mithril';
import Search from './Search';
/**
* The `HeaderSecondary` component displays secondary header controls.
@@ -15,11 +17,11 @@ export default class HeaderSecondary extends Component {
/**
* Build an item list for the controls.
*
* @return {ItemList<import('mithril').Children>}
*/
items() {
const items = new ItemList();
const items = new ItemList<Mithril.Children>();
items.add('search', <Search state={app.search.state} />, 30);
items.add(
'help',

View File

@@ -193,4 +193,35 @@ export default class MailPage<CustomAttrs extends IPageAttrs = IPageAttrs> exten
saveSettings(e: SaveSubmitEvent) {
return super.saveSettings(e).then(() => this.refresh());
}
static register() {
app.generalIndex.group('core-mail', {
label: app.translator.trans('core.admin.email.title', {}, true),
icon: {
name: 'fas fa-envelope',
},
link: app.route('mail'),
});
app.generalIndex.for('core-mail').add('settings', [
{
id: 'mail_from',
label: app.translator.trans('core.admin.email.addresses_heading', {}, true),
},
{
id: 'mail_format',
label: app.translator.trans('core.admin.email.format_heading', {}, true),
help: app.translator.trans('core.admin.email.format_help', {}, true),
},
{
id: 'mail_driver',
label: app.translator.trans('core.admin.email.driver_heading', {}, true),
},
{
id: 'send_test_mail_heading',
label: app.translator.trans('core.admin.email.send_test_mail_heading', {}, true),
help: app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user!.email() }, true),
},
]);
}
}

View File

@@ -8,9 +8,11 @@ import type Mithril from 'mithril';
import Icon from '../../common/components/Icon';
export interface PermissionConfig {
permission: string;
permission?: string;
icon: string;
label: Mithril.Children;
id?: string;
setting?: () => Mithril.Children;
allowGuest?: boolean;
}
@@ -137,64 +139,7 @@ export default class PermissionGrid<CustomAttrs extends IPermissionGridAttrs = I
viewItems() {
const items = new ItemList<PermissionGridEntry>();
items.add(
'viewForum',
{
icon: 'fas fa-eye',
label: app.translator.trans('core.admin.permissions.view_forum_label'),
permission: 'viewForum',
allowGuest: true,
},
100
);
items.add(
'viewHiddenGroups',
{
icon: 'fas fa-users',
label: app.translator.trans('core.admin.permissions.view_hidden_groups_label'),
permission: 'viewHiddenGroups',
},
100
);
items.add(
'searchUsers',
{
icon: 'fas fa-users',
label: app.translator.trans('core.admin.permissions.search_users_label'),
permission: 'searchUsers',
allowGuest: true,
},
100
);
items.add(
'signUp',
{
icon: 'fas fa-user-plus',
label: app.translator.trans('core.admin.permissions.sign_up_label'),
setting: () => (
<SettingDropdown
key="allow_sign_up"
options={[
{ value: '1', label: app.translator.trans('core.admin.permissions_controls.signup_open_button') },
{ value: '0', label: app.translator.trans('core.admin.permissions_controls.signup_closed_button') },
]}
lazyDraw
/>
),
},
90
);
items.add('viewLastSeenAt', {
icon: 'far fa-clock',
label: app.translator.trans('core.admin.permissions.view_last_seen_at_label'),
permission: 'user.viewLastSeenAt',
});
items.merge(app.extensionData.getAllExtensionPermissions('view'));
items.merge(app.registry.getAllPermissions('view'));
return items;
}
@@ -202,56 +147,7 @@ export default class PermissionGrid<CustomAttrs extends IPermissionGridAttrs = I
startItems() {
const items = new ItemList<PermissionGridEntry>();
items.add(
'start',
{
icon: 'fas fa-edit',
label: app.translator.trans('core.admin.permissions.start_discussions_label'),
permission: 'startDiscussion',
},
100
);
items.add(
'allowRenaming',
{
icon: 'fas fa-i-cursor',
label: app.translator.trans('core.admin.permissions.allow_renaming_label'),
setting: () => {
const minutes = parseInt(app.data.settings.allow_renaming, 10);
return (
<SettingDropdown
defaultLabel={
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={[
{ value: '-1', label: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button') },
{ value: '10', label: app.translator.trans('core.admin.permissions_controls.allow_ten_minutes_button') },
{ value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button') },
]}
lazyDraw
/>
);
},
},
90
);
items.add(
'createAccessToken',
{
icon: 'fas fa-key',
label: app.translator.trans('core.admin.permissions.create_access_token_label'),
permission: 'createAccessToken',
},
80
);
items.merge(app.extensionData.getAllExtensionPermissions('start'));
items.merge(app.registry.getAllPermissions('start'));
return items;
}
@@ -259,70 +155,7 @@ export default class PermissionGrid<CustomAttrs extends IPermissionGridAttrs = I
replyItems() {
const items = new ItemList<PermissionGridEntry>();
items.add(
'reply',
{
icon: 'fas fa-reply',
label: app.translator.trans('core.admin.permissions.reply_to_discussions_label'),
permission: 'discussion.reply',
},
100
);
items.add(
'allowPostEditing',
{
icon: 'fas fa-pencil-alt',
label: app.translator.trans('core.admin.permissions.allow_post_editing_label'),
setting: () => {
const minutes = parseInt(app.data.settings.allow_post_editing, 10);
return (
<SettingDropdown
defaultLabel={
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={[
{ value: '-1', label: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button') },
{ value: '10', label: app.translator.trans('core.admin.permissions_controls.allow_ten_minutes_button') },
{ value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button') },
]}
/>
);
},
},
90
);
items.add(
'hideOwnPosts',
{
icon: 'far fa-trash-alt',
label: app.translator.trans('core.admin.permissions.allow_hide_own_posts_label'),
setting: () => {
const minutes = parseInt(app.data.settings.allow_hide_own_posts, 10);
return SettingDropdown.component({
defaultLabel: 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_hide_own_posts',
options: [
{ value: '-1', label: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button') },
{ value: '10', label: app.translator.trans('core.admin.permissions_controls.allow_ten_minutes_button') },
{ value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button') },
{ value: '0', label: app.translator.trans('core.admin.permissions_controls.allow_never_button') },
],
});
},
},
80
);
items.merge(app.extensionData.getAllExtensionPermissions('reply'));
items.merge(app.registry.getAllPermissions('reply'));
return items;
}
@@ -330,127 +163,7 @@ export default class PermissionGrid<CustomAttrs extends IPermissionGridAttrs = I
moderateItems() {
const items = new ItemList<PermissionGridEntry>();
items.add(
'viewIpsPosts',
{
icon: 'fas fa-bullseye',
label: app.translator.trans('core.admin.permissions.view_post_ips_label'),
permission: 'discussion.viewIpsPosts',
},
110
);
items.add(
'renameDiscussions',
{
icon: 'fas fa-i-cursor',
label: app.translator.trans('core.admin.permissions.rename_discussions_label'),
permission: 'discussion.rename',
},
100
);
items.add(
'hideDiscussions',
{
icon: 'far fa-trash-alt',
label: app.translator.trans('core.admin.permissions.delete_discussions_label'),
permission: 'discussion.hide',
},
90
);
items.add(
'deleteDiscussions',
{
icon: 'fas fa-times',
label: app.translator.trans('core.admin.permissions.delete_discussions_forever_label'),
permission: 'discussion.delete',
},
80
);
items.add(
'postWithoutThrottle',
{
icon: 'fas fa-swimmer',
label: app.translator.trans('core.admin.permissions.post_without_throttle_label'),
permission: 'postWithoutThrottle',
},
70
);
items.add(
'editPosts',
{
icon: 'fas fa-pencil-alt',
label: app.translator.trans('core.admin.permissions.edit_posts_label'),
permission: 'discussion.editPosts',
},
70
);
items.add(
'hidePosts',
{
icon: 'far fa-trash-alt',
label: app.translator.trans('core.admin.permissions.delete_posts_label'),
permission: 'discussion.hidePosts',
},
60
);
items.add(
'deletePosts',
{
icon: 'fas fa-times',
label: app.translator.trans('core.admin.permissions.delete_posts_forever_label'),
permission: 'discussion.deletePosts',
},
60
);
items.add(
'userEditCredentials',
{
icon: 'fas fa-user-cog',
label: app.translator.trans('core.admin.permissions.edit_users_credentials_label'),
permission: 'user.editCredentials',
},
60
);
items.add(
'userEditGroups',
{
icon: 'fas fa-users-cog',
label: app.translator.trans('core.admin.permissions.edit_users_groups_label'),
permission: 'user.editGroups',
},
60
);
items.add(
'userEdit',
{
icon: 'fas fa-address-card',
label: app.translator.trans('core.admin.permissions.edit_users_label'),
permission: 'user.edit',
},
60
);
items.add(
'moderateAccessTokens',
{
icon: 'fas fa-key',
label: app.translator.trans('core.admin.permissions.moderate_access_tokens_label'),
permission: 'moderateAccessTokens',
},
60
);
items.merge(app.extensionData.getAllExtensionPermissions('moderate'));
items.merge(app.registry.getAllPermissions('moderate'));
return items;
}

View File

@@ -5,6 +5,7 @@ import Group from '../../common/models/Group';
import PermissionGrid from './PermissionGrid';
import AdminPage from './AdminPage';
import Icon from '../../common/components/Icon';
import SettingDropdown from './SettingDropdown';
export default class PermissionsPage extends AdminPage {
headerInfo() {
@@ -41,4 +42,325 @@ export default class PermissionsPage extends AdminPage {
</>
);
}
static register() {
app.generalIndex.group('core-permissions', {
label: app.translator.trans('core.admin.permissions.title', {}, true),
icon: {
name: 'fas fa-key',
},
link: app.route('permissions'),
});
app.registry.for('core-permissions');
PermissionsPage.registerViewPermissions();
PermissionsPage.registerStartPermissions();
PermissionsPage.registerReplyPermissions();
PermissionsPage.registerModeratePermissions();
}
static registerViewPermissions() {
app.registry.registerPermission(
{
icon: 'fas fa-eye',
label: app.translator.trans('core.admin.permissions.view_forum_label'),
permission: 'viewForum',
allowGuest: true,
},
'view',
100
);
app.registry.registerPermission(
{
icon: 'fas fa-users',
label: app.translator.trans('core.admin.permissions.view_hidden_groups_label'),
permission: 'viewHiddenGroups',
},
'view',
100
);
app.registry.registerPermission(
{
icon: 'fas fa-users',
label: app.translator.trans('core.admin.permissions.search_users_label'),
permission: 'searchUsers',
allowGuest: true,
},
'view',
100
);
app.registry.registerPermission(
{
icon: 'fas fa-user-plus',
label: app.translator.trans('core.admin.permissions.sign_up_label'),
id: 'allow_sign_up',
setting: () => (
<SettingDropdown
key="allow_sign_up"
options={[
{ value: '1', label: app.translator.trans('core.admin.permissions_controls.signup_open_button') },
{ value: '0', label: app.translator.trans('core.admin.permissions_controls.signup_closed_button') },
]}
lazyDraw
/>
),
},
'view',
90
);
app.registry.registerPermission(
{
icon: 'far fa-clock',
label: app.translator.trans('core.admin.permissions.view_last_seen_at_label'),
permission: 'user.viewLastSeenAt',
},
'view'
);
}
static registerStartPermissions() {
app.registry.registerPermission(
{
icon: 'fas fa-edit',
label: app.translator.trans('core.admin.permissions.start_discussions_label'),
permission: 'startDiscussion',
},
'start',
100
);
app.registry.registerPermission(
{
icon: 'fas fa-i-cursor',
label: app.translator.trans('core.admin.permissions.allow_renaming_label'),
id: 'allow_renaming',
setting: () => {
const minutes = parseInt(app.data.settings.allow_renaming, 10);
return (
<SettingDropdown
defaultLabel={
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={[
{ value: '-1', label: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button') },
{ value: '10', label: app.translator.trans('core.admin.permissions_controls.allow_ten_minutes_button') },
{ value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button') },
]}
lazyDraw
/>
);
},
},
'start',
90
);
app.registry.registerPermission(
{
icon: 'fas fa-key',
label: app.translator.trans('core.admin.permissions.create_access_token_label'),
permission: 'createAccessToken',
},
'start',
80
);
}
static registerReplyPermissions() {
app.registry.registerPermission(
{
icon: 'fas fa-reply',
label: app.translator.trans('core.admin.permissions.reply_to_discussions_label'),
permission: 'discussion.reply',
},
'reply',
100
);
app.registry.registerPermission(
{
icon: 'fas fa-pencil-alt',
label: app.translator.trans('core.admin.permissions.allow_post_editing_label'),
id: 'allow_post_editing',
setting: () => {
const minutes = parseInt(app.data.settings.allow_post_editing, 10);
return (
<SettingDropdown
defaultLabel={
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={[
{ value: '-1', label: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button') },
{ value: '10', label: app.translator.trans('core.admin.permissions_controls.allow_ten_minutes_button') },
{ value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button') },
]}
/>
);
},
},
'reply',
90
);
app.registry.registerPermission(
{
icon: 'far fa-trash-alt',
label: app.translator.trans('core.admin.permissions.allow_hide_own_posts_label'),
id: 'allow_hide_own_posts',
setting: () => {
const minutes = parseInt(app.data.settings.allow_hide_own_posts, 10);
return SettingDropdown.component({
defaultLabel: 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_hide_own_posts',
options: [
{ value: '-1', label: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button') },
{ value: '10', label: app.translator.trans('core.admin.permissions_controls.allow_ten_minutes_button') },
{ value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button') },
{ value: '0', label: app.translator.trans('core.admin.permissions_controls.allow_never_button') },
],
});
},
},
'reply',
80
);
}
static registerModeratePermissions() {
app.registry.registerPermission(
{
icon: 'fas fa-bullseye',
label: app.translator.trans('core.admin.permissions.view_post_ips_label'),
permission: 'discussion.viewIpsPosts',
},
'moderate',
110
);
app.registry.registerPermission(
{
icon: 'fas fa-i-cursor',
label: app.translator.trans('core.admin.permissions.rename_discussions_label'),
permission: 'discussion.rename',
},
'moderate',
100
);
app.registry.registerPermission(
{
icon: 'far fa-trash-alt',
label: app.translator.trans('core.admin.permissions.delete_discussions_label'),
permission: 'discussion.hide',
},
'moderate',
90
);
app.registry.registerPermission(
{
icon: 'fas fa-times',
label: app.translator.trans('core.admin.permissions.delete_discussions_forever_label'),
permission: 'discussion.delete',
},
'moderate',
80
);
app.registry.registerPermission(
{
icon: 'fas fa-swimmer',
label: app.translator.trans('core.admin.permissions.post_without_throttle_label'),
permission: 'postWithoutThrottle',
},
'moderate',
70
);
app.registry.registerPermission(
{
icon: 'fas fa-pencil-alt',
label: app.translator.trans('core.admin.permissions.edit_posts_label'),
permission: 'discussion.editPosts',
},
'moderate',
70
);
app.registry.registerPermission(
{
icon: 'far fa-trash-alt',
label: app.translator.trans('core.admin.permissions.delete_posts_label'),
permission: 'discussion.hidePosts',
},
'moderate',
60
);
app.registry.registerPermission(
{
icon: 'fas fa-times',
label: app.translator.trans('core.admin.permissions.delete_posts_forever_label'),
permission: 'discussion.deletePosts',
},
'moderate',
60
);
app.registry.registerPermission(
{
icon: 'fas fa-user-cog',
label: app.translator.trans('core.admin.permissions.edit_users_credentials_label'),
permission: 'user.editCredentials',
},
'moderate',
60
);
app.registry.registerPermission(
{
icon: 'fas fa-users-cog',
label: app.translator.trans('core.admin.permissions.edit_users_groups_label'),
permission: 'user.editGroups',
},
'moderate',
60
);
app.registry.registerPermission(
{
icon: 'fas fa-address-card',
label: app.translator.trans('core.admin.permissions.edit_users_label'),
permission: 'user.edit',
},
'moderate',
60
);
app.registry.registerPermission(
{
icon: 'fas fa-key',
label: app.translator.trans('core.admin.permissions.moderate_access_tokens_label'),
permission: 'moderateAccessTokens',
},
'moderate',
60
);
}
}

View File

@@ -0,0 +1,21 @@
import ItemList from '../../common/utils/ItemList';
import AbstractSearch, { type SearchAttrs, type SearchSource as BaseSearchSource } from '../../common/components/AbstractSearch';
import GeneralSearchSource from './GeneralSearchSource';
import app from '../app';
export interface SearchSource extends BaseSearchSource {}
export default class Search extends AbstractSearch {
static initAttrs(attrs: SearchAttrs) {
attrs.label = app.translator.trans('core.admin.header.search_placeholder', {}, true);
attrs.a11yRoleLabel = app.translator.trans('core.admin.header.search_role_label', {}, true);
}
sourceItems(): ItemList<SearchSource> {
const items = new ItemList<SearchSource>();
items.add('general', new GeneralSearchSource());
return items;
}
}

View File

@@ -13,7 +13,7 @@ export default class ExtensionPageResolver<
static extension: string | null = null;
onmatch(args: Attrs & RouteArgs, requestedPath: string, route: string) {
const extensionPage = app.extensionData.getPage<Attrs>(args.id);
const extensionPage = app.registry.getPage<Attrs>(args.id);
if (extensionPage) {
return Promise.resolve(extensionPage);

View File

@@ -0,0 +1,53 @@
export type GeneralIndexItem = {
id: string;
tree?: string[];
label: string;
help?: string;
link?: string;
visible?: () => boolean;
};
export type GeneralIndexData = Record<string, Record<'settings' | 'permissions', GeneralIndexItem[]>>;
export type GeneralIndexGroup = {
label: string;
icon: {
name: string;
[key: string]: any;
};
link: string;
};
export default class GeneralSearchIndex {
protected currentId: string = '';
protected data: GeneralIndexData = {};
protected groups: Record<string, GeneralIndexGroup> = {};
public group(id: string, data: GeneralIndexGroup) {
this.groups[id] = data;
return this;
}
public for(id: string) {
this.currentId = id;
return this;
}
public add(type: 'settings' | 'permissions', items: GeneralIndexItem[]) {
this.data[this.currentId] ||= {
settings: [],
permissions: [],
};
this.data[this.currentId][type].push(...items);
}
public getData() {
return this.data;
}
public getGroup(id: string): null | GeneralIndexGroup {
return this.groups[id] || null;
}
}

View File

@@ -2,15 +2,15 @@ import type Mithril from 'mithril';
import ItemList from '../../common/utils/ItemList';
import { SettingsComponentOptions } from '../components/AdminPage';
import ExtensionPage, { ExtensionPageAttrs } from '../components/ExtensionPage';
import { PermissionConfig, PermissionType } from '../components/PermissionGrid';
import type { PermissionConfig, PermissionType } from '../components/PermissionGrid';
type SettingConfigInput = SettingsComponentOptions | (() => Mithril.Children);
export type SettingConfigInput = SettingsComponentOptions | (() => Mithril.Children);
type SettingConfigInternal = SettingsComponentOptions | ((() => Mithril.Children) & { setting: string });
export type SettingConfigInternal = SettingsComponentOptions | ((() => Mithril.Children) & { setting: string });
export type CustomExtensionPage<Attrs extends ExtensionPageAttrs = ExtensionPageAttrs> = new () => ExtensionPage<Attrs>;
type ExtensionConfig = {
export type ExtensionConfig = {
settings?: ItemList<SettingConfigInternal>;
permissions?: {
view?: ItemList<PermissionConfig>;
@@ -37,7 +37,7 @@ type InnerDataActiveExtension = {
const noActiveExtensionErrorMessage = 'You must select an active extension via `.for()` before using extensionData.';
export default class ExtensionData {
export default class AdminRegistry {
protected state: InnerDataActiveExtension | InnerDataNoActiveExtension = {
currentExtension: null,
data: {},
@@ -47,7 +47,7 @@ export default class ExtensionData {
* This function simply takes the extension id
*
* @example
* app.extensionData.for('flarum-tags')
* app.registry.for('flarum-tags')
*
* flarum/flags -> flarum-flags | acme/extension -> acme-extension
*/
@@ -114,7 +114,11 @@ export default class ExtensionData {
const permissionsForType = permissions[permissionType] || new ItemList();
permissionsForType.add(content.permission, content, priority);
if (!content.permission && !content.id) {
throw new Error('Permission definition must have either a permission or id attribute.');
}
permissionsForType.add(content.permission || content.id!, content, priority);
this.state.data[this.state.currentExtension].permissions = { ...permissions, [permissionType]: permissionsForType };
@@ -145,7 +149,7 @@ export default class ExtensionData {
/**
* Get an ItemList of all extensions' registered permissions
*/
getAllExtensionPermissions(type: PermissionType): ItemList<PermissionConfig> {
getAllPermissions(type: PermissionType): ItemList<PermissionConfig> {
const items = new ItemList<PermissionConfig>();
Object.keys(this.state.data).map((extension) => {
@@ -183,4 +187,8 @@ export default class ExtensionData {
getPage<Attrs extends ExtensionPageAttrs = ExtensionPageAttrs>(extension: string): CustomExtensionPage<Attrs> | undefined {
return this.state.data[extension]?.page as CustomExtensionPage<Attrs> | undefined;
}
getData() {
return this.state.data;
}
}

View File

@@ -280,7 +280,7 @@ export default class Application {
this.translator.setLocale(payload.locale);
}
public boot() {
protected initialize(): CallableFunction[] {
const caughtInitializationErrors: CallableFunction[] = [];
this.initializers.toArray().forEach((initializer) => {
@@ -301,12 +301,20 @@ export default class Application {
}
});
return caughtInitializationErrors;
}
public boot() {
const caughtInitializationErrors: CallableFunction[] = this.initialize();
this.store.pushPayload({ data: this.data.resources });
this.forum = this.store.getById('forums', '1')!;
this.session = new Session(this.store.getById<User>('users', String(this.data.session.userId)) ?? null, this.data.session.csrfToken);
this.beforeMount();
this.mount();
this.initialRoute = window.location.href;
@@ -314,6 +322,10 @@ export default class Application {
caughtInitializationErrors.forEach((handler) => handler());
}
protected beforeMount(): void {
// ...
}
public bootExtensions(extensions: Record<string, { extend?: IExtender[] }>) {
Object.keys(extensions).forEach((name) => {
const extension = extensions[name];

View File

@@ -0,0 +1,133 @@
import app from '../app';
import Component, { ComponentAttrs } from '../Component';
import SearchState from '../states/SearchState';
import extractText from '../utils/extractText';
import ItemList from '../utils/ItemList';
import Input from './Input';
import type Mithril from 'mithril';
export interface SearchAttrs extends ComponentAttrs {
/** The type of alert this is. Will be used to give the alert a class name of `Alert--{type}`. */
state: SearchState;
label: string;
a11yRoleLabel: string;
}
/**
* The `SearchSource` interface defines a section of search results in the
* search dropdown.
*
* Search sources should be registered with the `Search` component class
* by extending the `sourceItems` method. When the user types a
* query, each search source will be prompted to load search results via the
* `search` method. When the dropdown is redrawn, it will be constructed by
* putting together the output from the `view` method of each source.
*/
export interface SearchSource {
/**
* The resource type that this search source is responsible for.
*/
resource: string;
/**
* Get the title for this search source.
*/
title(): string;
/**
* Check if a query has been cached for this search source.
*/
isCached(query: string): boolean;
/**
* Make a request to get results for the given query.
* The results will be updated internally in the search source, not exposed.
*/
search(query: string, limit: number): Promise<void>;
/**
* Get an array of virtual <li>s that list the search results for the given
* query.
*/
view(query: string): Array<Mithril.Vnode>;
/**
* Whether the search results view uses custom grouping of the results.
* Prevents the `Search Preview` default group from display.
*/
customGrouping(): boolean;
/**
* Get a list item for the full search results page.
*/
fullPage(query: string): Mithril.Vnode | null;
/**
* Get to the result item page. Only called if each list item has a data-id.
*/
gotoItem(id: string): string | null;
}
/**
* The `Search` component displays a primary search input at the top of the frontend (forum or admin).
* When clicked, it opens an advanced search modal with results from various sources.
*
* Must be extended and the abstract methods implemented per-frontend.
*/
export default abstract class AbstractSearch<T extends SearchAttrs = SearchAttrs> extends Component<T, SearchState> {
/**
* The instance of `SearchState` for this component.
*/
protected searchState!: SearchState;
oninit(vnode: Mithril.Vnode<T, this>) {
super.oninit(vnode);
this.searchState = this.attrs.state;
}
view() {
// Hide the search view if no sources were loaded
if (this.sourceItems().isEmpty()) return <div></div>;
const openSearchModal = () => {
this.$('input').blur() &&
app.modal.show(() => import('../../common/components/SearchModal'), { searchState: this.searchState, sources: this.sourceItems().toArray() });
};
return (
<div role="search" className="Search" aria-label={this.attrs.a11yRoleLabel}>
<Input
type="search"
className="Search-input"
clearable={this.searchState.getValue()}
clearLabel={app.translator.trans('core.lib.search.search_clear_button_accessible_label')}
prefixIcon="fas fa-search"
aria-label={this.attrs.label}
readonly={true}
placeholder={this.attrs.label}
value={this.searchState.getValue()}
onchange={(value: string) => {
if (!value) this.searchState.clear();
else this.searchState.setValue(value);
}}
inputAttrs={{
onclick: () => setTimeout(() => openSearchModal(), 150),
// for keyboard navigation, click event would be triggered on keydown
onkeydown: (e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
this.$('input').blur() && openSearchModal();
}
},
}}
/>
</div>
);
}
/**
* A list of search sources that can be used to query for search results.
*/
abstract sourceItems(): ItemList<SearchSource>;
}

View File

@@ -11,6 +11,7 @@ import type { IUploadImageButtonAttrs } from './UploadImageButton';
import type { ComponentAttrs } from '../Component';
import type Mithril from 'mithril';
import MultiSelect from './MultiSelect';
import FieldSet from './FieldSet';
/**
* A type that matches any valid value for the `type` attribute on an HTML `<input>` element.
@@ -63,9 +64,11 @@ export interface HTMLInputFieldComponentOptions extends CommonFieldOptions {
const BooleanSettingTypes = ['bool', 'checkbox', 'switch', 'boolean'] as const;
const SelectSettingTypes = ['select', 'dropdown', 'selectdropdown'] as const;
const RadioSettingTypes = ['radio'] as const;
const TextareaSettingTypes = ['textarea'] as const;
const ColorPreviewSettingType = 'color-preview' as const;
const ImageUploadSettingType = 'image-upload' as const;
const StackedFormControlType = 'stacked-text' as const;
/**
* Valid options for the setting component builder to generate a Switch.
@@ -78,7 +81,7 @@ export interface SwitchFieldComponentOptions extends CommonFieldOptions {
* Valid options for the setting component builder to generate a Select dropdown.
*/
export interface SelectFieldComponentOptions extends CommonFieldOptions {
type: typeof SelectSettingTypes[number];
type: typeof SelectSettingTypes[number] | typeof RadioSettingTypes[number];
/**
* Map of values to their labels
*/
@@ -112,6 +115,14 @@ export interface ImageUploadFieldComponentOptions extends CommonFieldOptions, IU
type: typeof ImageUploadSettingType;
}
export interface StackedFormControlFieldComponentOptions extends CommonFieldOptions {
type: typeof StackedFormControlType;
textArea: {
setting: string;
[key: string]: unknown;
};
}
export interface CustomFieldComponentOptions extends CommonFieldOptions {
type: string;
[key: string]: unknown;
@@ -127,11 +138,13 @@ export type FieldComponentOptions =
| TextareaFieldComponentOptions
| ColorPreviewFieldComponentOptions
| ImageUploadFieldComponentOptions
| StackedFormControlFieldComponentOptions
| CustomFieldComponentOptions;
export type IFormGroupAttrs = ComponentAttrs &
FieldComponentOptions & {
stream?: Stream<any>;
getSetting?: (key: string) => Stream<any>;
};
/**
@@ -166,7 +179,7 @@ export default class FormGroup<CustomAttrs extends IFormGroupAttrs = IFormGroupA
view(vnode: Mithril.Vnode<CustomAttrs, this>): Mithril.Children {
const customFieldComponents = this.customFieldComponents();
const { help, type, label, stream, ...componentAttrs } = this.attrs;
const { help, type, label, stream, getSetting, containerClassName, ...componentAttrs } = this.attrs;
// TypeScript being TypeScript
const attrs = componentAttrs as unknown as Omit<IFormGroupAttrs, 'stream' | 'label' | 'help' | 'type'>;
@@ -183,7 +196,7 @@ export default class FormGroup<CustomAttrs extends IFormGroupAttrs = IFormGroupA
return (
// TODO: Add aria-describedby for switch help text.
//? Requires changes to Checkbox component to allow providing attrs directly for the element(s).
<div className="Form-group">
<div className={classList('Form-group', containerClassName)}>
<Switch state={!!value && value !== '0'} onchange={stream} {...attrs}>
{label}
</Switch>
@@ -198,6 +211,29 @@ export default class FormGroup<CustomAttrs extends IFormGroupAttrs = IFormGroupA
settingElement = (
<Tag id={inputId} aria-describedby={helpTextId} value={value || defaultValue} options={options} onchange={stream} {...otherAttrs} />
);
} else if ((RadioSettingTypes as readonly string[]).includes(type)) {
const { default: defaultValue, options, multiple, ...otherAttrs } = attrs;
settingElement = (
<FieldSet {...otherAttrs}>
{options.map(({ value, label }: { value: string; label: string }) => (
<label className="checkbox">
<input type="radio" name="homePage" value={value} bidi={stream} />
{label}
</label>
))}
</FieldSet>
);
} else if (type === StackedFormControlType) {
const { textArea, ...otherAttrs } = attrs;
const { setting: textAreaSetting, ...textAreaAttrs } = textArea;
settingElement = (
<div className="StackedFormControl" {...otherAttrs}>
<input type="text" className="FormControl" bidi={stream} />
<textarea className="FormControl" bidi={getSetting?.(textAreaSetting)} {...textAreaAttrs} />
</div>
);
} else if (type === ImageUploadSettingType) {
const { value, ...otherAttrs } = attrs;
@@ -223,7 +259,7 @@ export default class FormGroup<CustomAttrs extends IFormGroupAttrs = IFormGroupA
}
return (
<div className="Form-group">
<div className={classList('Form-group', containerClassName)}>
{label && <label for={inputId}>{label}</label>}
{help && (
<div id={helpTextId} className="helpText">

View File

@@ -1,4 +1,4 @@
import app from '../../forum/app';
import app from '../../common/app';
import Component from '../Component';
import Icon from './Icon';
import LoadingIndicator from './LoadingIndicator';

View File

@@ -1,20 +1,20 @@
import app from '../app';
import type { IFormModalAttrs } from '../../common/components/FormModal';
import FormModal from '../../common/components/FormModal';
import type { IFormModalAttrs } from './FormModal';
import FormModal from './FormModal';
import type Mithril from 'mithril';
import type SearchState from '../../common/states/SearchState';
import KeyboardNavigatable from '../../common/utils/KeyboardNavigatable';
import SearchManager from '../../common/SearchManager';
import extractText from '../../common/utils/extractText';
import Input from '../../common/components/Input';
import Button from '../../common/components/Button';
import Stream from '../../common/utils/Stream';
import InfoTile from '../../common/components/InfoTile';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import type { SearchSource } from './Search';
import type IGambit from '../../common/query/IGambit';
import ItemList from '../../common/utils/ItemList';
import GambitsAutocomplete from '../../common/utils/GambitsAutocomplete';
import type SearchState from '../states/SearchState';
import KeyboardNavigatable from '../utils/KeyboardNavigatable';
import SearchManager from '../SearchManager';
import extractText from '../utils/extractText';
import Input from './Input';
import Button from './Button';
import Stream from '../utils/Stream';
import InfoTile from './InfoTile';
import LoadingIndicator from './LoadingIndicator';
import type IGambit from '../query/IGambit';
import ItemList from '../utils/ItemList';
import GambitsAutocomplete from '../utils/GambitsAutocomplete';
import type { SearchSource } from './AbstractSearch';
export interface ISearchModalAttrs extends IFormModalAttrs {
onchange: (value: string) => void;
@@ -72,7 +72,7 @@ export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearch
}
title(): Mithril.Children {
return app.translator.trans('core.forum.search.title');
return app.translator.trans('core.lib.search.title');
}
className(): string {
@@ -87,7 +87,7 @@ export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearch
(value: string) => this.search(value)
);
const searchLabel = extractText(app.translator.trans('core.forum.search.placeholder'));
const searchLabel = extractText(app.translator.trans('core.lib.search.placeholder'));
return (
<div className="Modal-body SearchModal-body">
@@ -97,7 +97,7 @@ export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearch
type="search"
loading={!!this.loadingSources.length}
clearable={true}
clearLabel={app.translator.trans('core.forum.header.search_clear_button_accessible_label')}
clearLabel={app.translator.trans('core.lib.header.search_clear_button_accessible_label')}
prefixIcon="fas fa-search"
aria-label={searchLabel}
placeholder={searchLabel}
@@ -157,6 +157,7 @@ export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearch
const gambits = this.gambits();
const fullPageLink = this.activeSource().fullPage(this.query());
const results = this.activeSource()?.view(this.query());
const customGrouping = this.activeSource().customGrouping();
if (shouldShowResults && fullPageLink) {
items.add(
@@ -175,7 +176,7 @@ export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearch
<div className="SearchModal-section">
<hr className="Modal-divider" />
<ul className="Dropdown-menu SearchModal-options" aria-live={gambits.length ? 'polite' : undefined}>
<li className="Dropdown-header">{app.translator.trans('core.forum.search.options_heading')}</li>
<li className="Dropdown-header">{app.translator.trans('core.lib.search.options_heading')}</li>
{gambits}
</ul>
</div>,
@@ -188,16 +189,16 @@ export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearch
<div className="SearchModal-section">
<hr className="Modal-divider" />
<ul className="Dropdown-menu SearchModal-results" aria-live={shouldShowResults ? 'polite' : undefined}>
<li className="Dropdown-header">{app.translator.trans('core.forum.search.preview_heading')}</li>
{!customGrouping && <li className="Dropdown-header">{app.translator.trans('core.lib.search.preview_heading')}</li>}
{!shouldShowResults && (
<li className="Dropdown-message">
<InfoTile icon="fas fa-search">{app.translator.trans('core.forum.search.no_search_text')}</InfoTile>
<InfoTile icon="fas fa-search">{app.translator.trans('core.lib.search.no_search_text')}</InfoTile>
</li>
)}
{shouldShowResults && results}
{shouldShowResults && !results?.length && (
<li className="Dropdown-message">
<InfoTile icon="far fa-tired">{app.translator.trans('core.forum.search.no_results_text')}</InfoTile>
<InfoTile icon="far fa-tired">{app.translator.trans('core.lib.search.no_results_text')}</InfoTile>
</li>
)}
{loading && (

View File

@@ -0,0 +1,63 @@
import IExtender, { IExtensionModule } from './IExtender';
import type AdminApplication from '../../admin/AdminApplication';
import type { CustomExtensionPage, SettingConfigInternal } from '../../admin/utils/AdminRegistry';
import type { PermissionConfig, PermissionType } from '../../admin/components/PermissionGrid';
import Mithril from 'mithril';
export default class Admin implements IExtender<AdminApplication> {
protected settings: { setting?: () => SettingConfigInternal; customSetting?: () => Mithril.Children; priority: number }[] = [];
protected permissions: { permission: () => PermissionConfig; type: PermissionType; priority: number }[] = [];
protected customPage: CustomExtensionPage | null = null;
/**
* Register a setting to be shown on the extension's settings page.
*/
setting(setting: () => SettingConfigInternal, priority = 0) {
this.settings.push({ setting, priority });
return this;
}
/**
* Register a custom setting to be shown on the extension's settings page.
*/
customSetting(setting: () => Mithril.Children, priority = 0) {
this.settings.push({ customSetting: setting, priority });
return this;
}
/**
* Register a permission to be shown on the extension's permissions page.
*/
permission(permission: () => PermissionConfig, type: PermissionType, priority = 0) {
this.permissions.push({ permission, type, priority });
return this;
}
/**
* Register a custom page to be shown in the admin interface.
*/
page(page: CustomExtensionPage) {
this.customPage = page;
return this;
}
extend(app: AdminApplication, extension: IExtensionModule) {
app.registry.for(extension.name);
this.settings.forEach(({ setting, customSetting, priority }) => {
app.registry.registerSetting(setting ? setting() : customSetting!, priority);
});
this.permissions.forEach(({ permission, type, priority }) => {
app.registry.registerPermission(permission(), type, priority);
});
if (this.customPage) {
app.registry.registerPage(this.customPage);
}
}
}

View File

@@ -1,10 +1,9 @@
import IExtender, { IExtensionModule } from './IExtender';
import type Component from '../Component';
import ForumApplication from '../../forum/ForumApplication';
import type Application from '../Application';
import type ForumApplication from '../../forum/ForumApplication';
import type { NewComponent } from '../Application';
export default class Notification implements IExtender {
export default class Notification implements IExtender<ForumApplication> {
private notificationComponents: Record<string, new () => Component> = {};
/**
@@ -19,7 +18,7 @@ export default class Notification implements IExtender {
return this;
}
extend(app: Application, extension: IExtensionModule): void {
Object.assign((app as unknown as ForumApplication).notificationComponents, this.notificationComponents);
extend(app: ForumApplication, extension: IExtensionModule): void {
Object.assign(app.notificationComponents, this.notificationComponents);
}
}

View File

@@ -1,8 +1,7 @@
import IExtender, { IExtensionModule } from './IExtender';
import Application from '../Application';
import ForumApplication from '../../forum/ForumApplication';
import type ForumApplication from '../../forum/ForumApplication';
export default class PostTypes implements IExtender {
export default class PostTypes implements IExtender<ForumApplication> {
private postComponents: Record<string, any> = {};
/**
@@ -18,7 +17,7 @@ export default class PostTypes implements IExtender {
return this;
}
extend(app: Application, extension: IExtensionModule): void {
Object.assign((app as unknown as ForumApplication).postComponents, this.postComponents);
extend(app: ForumApplication, extension: IExtensionModule): void {
Object.assign(app.postComponents, this.postComponents);
}
}

View File

@@ -5,6 +5,7 @@ import Store from './Store';
import Search from './Search';
import Notification from './Notification';
import ThemeMode from './ThemeMode';
import Admin from './Admin';
const extenders = {
Model,
@@ -14,6 +15,7 @@ const extenders = {
Search,
Notification,
ThemeMode,
Admin,
};
export default extenders;

View File

@@ -52,6 +52,10 @@ export default class DiscussionsSearchSource implements SearchSource {
}) as Array<Mithril.Vnode>;
}
customGrouping(): boolean {
return false;
}
fullPage(query: string): Mithril.Vnode {
const filter = app.search.gambits.apply('discussions', { q: query });
const q = filter.q || null;

View File

@@ -52,6 +52,10 @@ export default class PostsSearchSource implements SearchSource {
}) as Array<Mithril.Vnode>;
}
customGrouping(): boolean {
return false;
}
fullPage(query: string): Mithril.Vnode {
const filter = app.search.gambits.apply('posts', { q: query });
const q = filter.q || null;

View File

@@ -1,130 +1,18 @@
import app from '../../forum/app';
import Component, { ComponentAttrs } from '../../common/Component';
import extractText from '../../common/utils/extractText';
import Input from '../../common/components/Input';
import SearchState from '../../common/states/SearchState';
import SearchModal from './SearchModal';
import type Mithril from 'mithril';
import ItemList from '../../common/utils/ItemList';
import DiscussionsSearchSource from './DiscussionsSearchSource';
import UsersSearchSource from './UsersSearchSource';
import PostsSearchSource from './PostsSearchSource';
import AbstractSearch, { type SearchAttrs, type SearchSource as BaseSearchSource } from '../../common/components/AbstractSearch';
export interface SearchAttrs extends ComponentAttrs {
/** The type of alert this is. Will be used to give the alert a class name of `Alert--{type}`. */
state: SearchState;
}
export interface SearchSource extends BaseSearchSource {}
/**
* The `SearchSource` interface defines a section of search results in the
* search dropdown.
*
* Search sources should be registered with the `Search` component class
* by extending the `sourceItems` method. When the user types a
* query, each search source will be prompted to load search results via the
* `search` method. When the dropdown is redrawn, it will be constructed by
* putting together the output from the `view` method of each source.
*/
export interface SearchSource {
/**
* The resource type that this search source is responsible for.
*/
resource: string;
/**
* Get the title for this search source.
*/
title(): string;
/**
* Check if a query has been cached for this search source.
*/
isCached(query: string): boolean;
/**
* Make a request to get results for the given query.
* The results will be updated internally in the search source, not exposed.
*/
search(query: string, limit: number): Promise<void>;
/**
* Get an array of virtual <li>s that list the search results for the given
* query.
*/
view(query: string): Array<Mithril.Vnode>;
/**
* Get a list item for the full search results page.
*/
fullPage(query: string): Mithril.Vnode | null;
/**
* Get to the result item page. Only called if each list item has a data-id.
*/
gotoItem(id: string): string | null;
}
/**
* The `Search` component displays a menu of as-you-type results from a variety
* of sources.
*
* The search box will be 'activated' if the app's search state's
* getInitialSearch() value is a truthy value. If this is the case, an 'x'
* button will be shown next to the search field, and clicking it will clear the search.
*
* ATTRS:
*
* - state: SearchState instance.
*/
export default class Search<T extends SearchAttrs = SearchAttrs> extends Component<T, SearchState> {
/**
* The instance of `SearchState` for this component.
*/
protected searchState!: SearchState;
oninit(vnode: Mithril.Vnode<T, this>) {
super.oninit(vnode);
this.searchState = this.attrs.state;
export default class Search extends AbstractSearch {
static initAttrs(attrs: SearchAttrs) {
attrs.label = app.translator.trans('core.forum.header.search_placeholder', {}, true);
attrs.a11yRoleLabel = app.translator.trans('core.forum.header.search_role_label', {}, true);
}
view() {
// Hide the search view if no sources were loaded
if (this.sourceItems().isEmpty()) return <div></div>;
const searchLabel = extractText(app.translator.trans('core.forum.header.search_placeholder'));
return (
<div role="search" className="Search" aria-label={app.translator.trans('core.forum.header.search_role_label')}>
<Input
type="search"
className="Search-input"
clearable={this.searchState.getValue()}
clearLabel={app.translator.trans('core.forum.header.search_clear_button_accessible_label')}
prefixIcon="fas fa-search"
aria-label={searchLabel}
readonly={true}
placeholder={searchLabel}
value={this.searchState.getValue()}
onchange={(value: string) => {
if (!value) this.searchState.clear();
else this.searchState.setValue(value);
}}
inputAttrs={{
onfocus: () =>
setTimeout(() => {
this.$('input').blur() &&
app.modal.show(() => import('./SearchModal'), { searchState: this.searchState, sources: this.sourceItems().toArray() });
}, 150),
}}
/>
</div>
);
}
/**
* A list of search sources that can be used to query for search results.
*/
sourceItems(): ItemList<SearchSource> {
const items = new ItemList<SearchSource>();

View File

@@ -70,6 +70,10 @@ export default class UsersSearchSource implements SearchSource {
});
}
customGrouping(): boolean {
return false;
}
fullPage(query: string): null {
return null;
}

View File

@@ -16,3 +16,4 @@
@import "admin/MailPage";
@import "admin/NoJs";
@import "admin/UsersListPage";
@import "admin/GeneralSearchResult";

View File

@@ -0,0 +1,35 @@
.GeneralSearchResult {
.ExtensionIcon {
--size: 32px;
flex-shrink: 0;
}
&-group {
border-top: none;
}
&-tree {
font-weight: bold;
&-separator {
color: var(--muted-more-color);
padding: 0 8px;
}
}
&-help {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted-color);
font-size: 12px;
}
&-info {
overflow: hidden;
}
mark {
background-color: var(--highlight-color);
}
}

View File

@@ -66,6 +66,10 @@
}
}
.FieldSet-label:empty {
display: none;
}
.FieldSet--min .FieldSet-items > * {
width: auto;
}

View File

@@ -213,6 +213,8 @@
--hero-bg: var(--control-bg);
--hero-color: var(--control-color);
--highlight-color: #FFE300;
.light-contents-vars();
.Button--color-vars(@control-success-color, @control-success-bg, 'control-success');

View File

@@ -74,7 +74,7 @@ p {
}
mark {
background: #FFE300;
background: var(--highlight-color);
padding: 1px;
border-radius: var(--border-radius);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.1);

View File

@@ -246,6 +246,8 @@ core:
header:
get_help: Get Help
log_out_button: => core.ref.log_out
search_placeholder: Search...
search_role_label: Search
# These translations are used in the modal dialog displayed when loading extensions.
loading:
@@ -331,6 +333,11 @@ core:
signup_closed_button: Closed
signup_open_button: Open
# These translations are used by the admin-specific search sources.
search_source:
general:
heading: General
# These translations are used generically in setting fields.
settings:
saved_message: Your changes were saved.
@@ -480,7 +487,6 @@ core:
log_in_link: => core.ref.log_in
log_out_button: => core.ref.log_out
profile_button: Profile
search_clear_button_accessible_label: Clear search query
search_placeholder: Search Forum
search_role_label: Search Forum
session_dropdown_accessible_label: Toggle session options dropdown menu
@@ -583,17 +589,6 @@ core:
submit_button: => core.ref.rename
title: Rename Discussion
# These translations are used by the search modal.
search:
gambit_plus_button_a11y_label: Add a positive filter
gambit_minus_button_a11y_label: Add a negative filter
title: Search
no_results_text: It looks like there are no results here.
no_search_text: You have not searched for anything yet.
options_heading: Search options
placeholder: Search...
preview_heading: Search preview
# These translations are used in the Security page.
security:
browser_on_operating_system: "{browser} on {os}"
@@ -744,6 +739,32 @@ core:
rate_limit_exceeded_message: You're going a little too quickly. Please try again in a few seconds.
render_failed_message: Sorry, we encountered an error while displaying this content. If you're a user, please try again later. If you're an administrator, take a look in your Flarum log files for more information.
# These translations are used by gambits. Gambit keys must be in snake_case, no spaces.
gambits:
boolean_key: is
discussions:
author:
key: author
hint: username or comma-separated list of usernames
created:
key: created
hint: 2020-12-31 or 2020-12-31..2021-09-30
hidden:
key: hidden
unread:
key: unread
posts:
discussion:
key: discussion
hint: the ID of the discussion
users:
email:
key: email
hint: example@machine.local
group:
key: group
hint: singular or plural group names
# These translations are used in the input component.
input:
clear_button: Clear input
@@ -781,6 +802,18 @@ core:
kilo_text: K
mega_text: M
# These translations are used by the abstract search component and search modal.
search:
gambit_plus_button_a11y_label: Add a positive filter
gambit_minus_button_a11y_label: Add a negative filter
title: Search
no_results_text: It looks like there are no results here.
no_search_text: You have not searched for anything yet.
options_heading: Search options
placeholder: Search...
preview_heading: Search preview
search_clear_button_accessible_label: Clear search query
# These translations are used by search sources.
search_source:
discussions:
@@ -792,32 +825,6 @@ core:
users:
heading: => core.ref.users
# These translations are used by gambits. Gambit keys must be in snake_case, no spaces.
gambits:
boolean_key: is
discussions:
author:
key: author
hint: username or comma-separated list of usernames
created:
key: created
hint: 2020-12-31 or 2020-12-31..2021-09-30
hidden:
key: hidden
unread:
key: unread
posts:
discussion:
key: discussion
hint: the ID of the discussion
users:
email:
key: email
hint: example@machine.local
group:
key: group
hint: singular or plural group names
# These translations are used to punctuate a series of items.
series:
glue_text: ", "